diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 193c47e1dad..515b4103d1d 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -557,6 +557,7 @@ describe('Gemini Client (client.ts)', () => { getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false), getUseModelRouter: vi.fn().mockReturnValue(false), getProjectRoot: vi.fn().mockReturnValue('/test/project/root'), + getTargetDir: vi.fn().mockReturnValue('/test/project/root'), getCwd: vi.fn().mockReturnValue('/test/project/root'), storage: { getProjectTempDir: vi.fn().mockReturnValue('/test/temp'), diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 46290172731..ffe05e56c75 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -1764,7 +1764,7 @@ export class GeminiClient { this.getHistoryShallow(), lastCompletionTimestamp, this.config.getClearContextOnIdle(), - opts, + { ...opts, projectRoot: this.config.getTargetDir() }, ); if (!mcResult.meta) { return false; diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index e3230e8f73b..86a0db3c132 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -1775,7 +1775,7 @@ export class GeminiChat { this.history, null, this.config.getClearContextOnIdle(), - { force: true }, + { force: true, projectRoot: this.config.getTargetDir() }, ); const mcMeta = mcResult.meta; diff --git a/packages/core/src/services/memoryPressureMonitor.test.ts b/packages/core/src/services/memoryPressureMonitor.test.ts index fb697a2ef16..d0d5696bb38 100644 --- a/packages/core/src/services/memoryPressureMonitor.test.ts +++ b/packages/core/src/services/memoryPressureMonitor.test.ts @@ -162,6 +162,7 @@ function createMockConfig( } : overrides.geminiClient; return { + getTargetDir: () => '/project', getFileReadCache: () => ({ clear: vi.fn(), diff --git a/packages/core/src/services/memoryPressureMonitor.ts b/packages/core/src/services/memoryPressureMonitor.ts index 9c44871d8d5..aef62f068a7 100644 --- a/packages/core/src/services/memoryPressureMonitor.ts +++ b/packages/core/src/services/memoryPressureMonitor.ts @@ -716,13 +716,18 @@ export class MemoryPressureMonitor extends EventEmitter { const chat = client.getChat(); const history = chat.getHistoryShallow?.() ?? chat.getHistory(); const settings = this.coreConfig.getClearContextOnIdle(); - const result = microcompactHistory(history, Date.now() - 1, { - ...settings, - toolResultsThresholdMinutes: - (settings.toolResultsThresholdMinutes ?? 0) < 0 - ? settings.toolResultsThresholdMinutes - : 0, - }); + const result = microcompactHistory( + history, + Date.now() - 1, + { + ...settings, + toolResultsThresholdMinutes: + (settings.toolResultsThresholdMinutes ?? 0) < 0 + ? settings.toolResultsThresholdMinutes + : 0, + }, + { projectRoot: this.coreConfig.getTargetDir() }, + ); if (result.meta) { chat.setHistory(result.history); // Explicitly clear fileReadCache here instead of relying on diff --git a/packages/core/src/services/microcompaction/microcompact.test.ts b/packages/core/src/services/microcompaction/microcompact.test.ts index 8653a9cbbd8..02cb9560b85 100644 --- a/packages/core/src/services/microcompaction/microcompact.test.ts +++ b/packages/core/src/services/microcompaction/microcompact.test.ts @@ -5,8 +5,13 @@ */ import { describe, expect, it, afterEach } from 'vitest'; +import path from 'node:path'; import type { Content } from '@google/genai'; import type { ClearContextOnIdleSettings } from '../../config/config.js'; +import { + getAutoMemoryRoot, + getUserAutoMemoryRoot, +} from '../../memory/paths.js'; import { evaluateTimeBasedTrigger, @@ -1269,6 +1274,131 @@ describe('microcompactHistory evictedReadPaths (issue #4239)', () => { }); }); +describe('microcompactHistory managed memory reads (issue #6713)', () => { + const projectRoot = '/project'; + const managedMemoryPath = path.join( + getAutoMemoryRoot(projectRoot), + 'user', + 'preferences.md', + ); + + function fileCall(id: string, filePath: string): Content { + return { + role: 'model', + parts: [ + { + functionCall: { + id, + name: 'read_file', + args: { file_path: filePath }, + }, + }, + ], + }; + } + + function fileResult(id: string, output: string): Content { + return { + role: 'user', + parts: [ + { functionResponse: { id, name: 'read_file', response: { output } } }, + ], + }; + } + + it('keeps managed memory reads during idle compaction', () => { + const history: Content[] = [ + fileCall('memory', managedMemoryPath), + fileResult('memory', 'managed memory content'), + fileCall('ordinary', '/project/old.ts'), + fileResult('ordinary', 'ordinary content '.repeat(50)), + fileCall('recent', '/project/recent.ts'), + fileResult('recent', 'recent content'), + ]; + + const result = microcompactHistory( + history, + Date.now() - 2 * 60 * 60 * 1000, + { + toolResultsThresholdMinutes: 5, + toolResultsNumToKeep: 1, + }, + { projectRoot }, + ); + + expect( + result.history[1]?.parts?.[0]?.functionResponse?.response?.['output'], + ).toBe('managed memory content'); + expect( + result.history[3]?.parts?.[0]?.functionResponse?.response?.['output'], + ).toBe(MICROCOMPACT_CLEARED_MESSAGE); + expect(result.meta?.toolsCleared).toBe(1); + }); + + it('keeps managed memory reads during size compaction', () => { + const history: Content[] = [ + fileCall('memory', managedMemoryPath), + fileResult('memory', 'managed memory content '.repeat(50)), + fileCall('ordinary', '/project/old.ts'), + fileResult('ordinary', 'ordinary content '.repeat(50)), + fileCall('recent', '/project/recent.ts'), + fileResult('recent', 'recent content'), + ]; + + const result = microcompactHistory( + history, + Date.now(), + { + toolResultsThresholdMinutes: 60, + toolResultsNumToKeep: 1, + toolResultsTotalCharsThreshold: 20, + }, + { sizeOnly: true, projectRoot }, + ); + + expect( + result.history[1]?.parts?.[0]?.functionResponse?.response?.['output'], + ).toBe('managed memory content '.repeat(50)); + expect( + result.history[3]?.parts?.[0]?.functionResponse?.response?.['output'], + ).toBe(MICROCOMPACT_CLEARED_MESSAGE); + expect(result.meta?.toolsCleared).toBe(1); + }); + + it('keeps user-level managed memory reads during idle compaction', () => { + const userMemoryPath = path.join( + getUserAutoMemoryRoot(), + 'user', + 'preferences.md', + ); + const history: Content[] = [ + fileCall('memory', userMemoryPath), + fileResult('memory', 'user memory content'), + fileCall('ordinary', '/project/old.ts'), + fileResult('ordinary', 'ordinary content '.repeat(50)), + fileCall('recent', '/project/recent.ts'), + fileResult('recent', 'recent content'), + ]; + + const result = microcompactHistory( + history, + Date.now() - 2 * 60 * 60 * 1000, + { + toolResultsThresholdMinutes: 5, + toolResultsNumToKeep: 1, + }, + { projectRoot }, + ); + + expect( + result.history[1]?.parts?.[0]?.functionResponse?.response?.['output'], + ).toBe('user memory content'); + expect( + result.history[3]?.parts?.[0]?.functionResponse?.response?.['output'], + ).toBe(MICROCOMPACT_CLEARED_MESSAGE); + }); +}); + describe('microcompactHistory — force option', () => { afterEach(clearEnv); diff --git a/packages/core/src/services/microcompaction/microcompact.ts b/packages/core/src/services/microcompaction/microcompact.ts index 5c14d4b8812..63afdadaf76 100644 --- a/packages/core/src/services/microcompaction/microcompact.ts +++ b/packages/core/src/services/microcompaction/microcompact.ts @@ -8,6 +8,7 @@ import type { Content, Part } from '@google/genai'; import type { ClearContextOnIdleSettings } from '../../config/config.js'; import { DEFAULT_TOOL_RESULTS_TOTAL_CHARS_THRESHOLD } from '../../config/clearContextDefaults.js'; +import { isAnyAutoMemPath } from '../../memory/paths.js'; import { sanitizeMimeForPlaceholder } from '../compactionInputSlimming.js'; import { ToolNames } from '../../tools/tool-names.js'; @@ -149,17 +150,37 @@ function hasNestedMedia(part: Part): boolean { * `toolResultsNumToKeep: 1` keeps 1 tool result AND 1 media item, not * 1 entry total across the combined list. */ -function collectCompactablePartRefs(history: Content[]): CollectedRefs { +function isManagedMemoryRead( + part: Part, + callIdToFilePath: Map, + projectRoot: string | undefined, +): boolean { + if (!projectRoot || part.functionResponse?.name !== ToolNames.READ_FILE) { + return false; + } + const paths = getFilePathsForResponse(part, callIdToFilePath); + return paths?.length === 1 && isAnyAutoMemPath(paths[0]!, projectRoot); +} + +function collectCompactablePartRefs( + history: Content[], + projectRoot?: string, +): CollectedRefs { const tool: PartRef[] = []; const media: PartRef[] = []; const nestedMedia: PartRef[] = []; + const callIdToFilePath = buildCallIdToFilePath(history); for (let ci = 0; ci < history.length; ci++) { const content = history[ci]!; if (content.role !== 'user' || !content.parts) continue; for (let pi = 0; pi < content.parts.length; pi++) { const part = content.parts[pi]!; const fnName = part.functionResponse?.name; - if (fnName && COMPACTABLE_TOOLS.has(fnName)) { + if ( + fnName && + COMPACTABLE_TOOLS.has(fnName) && + !isManagedMemoryRead(part, callIdToFilePath, projectRoot) + ) { tool.push({ contentIndex: ci, partIndex: pi, kind: 'tool' }); } else if (part.functionResponse && hasNestedMedia(part)) { // Non-compactable tool result with media attached — clear only @@ -347,6 +368,7 @@ function planSizeBasedClearing( settings: ClearContextOnIdleSettings, keepRecent: number, pendingContent: Content | Content[] | undefined, + projectRoot?: string, ): SizeClearPlan | null { const threshold = getToolResultsTotalCharsThreshold(settings); if (!Number.isFinite(threshold) || threshold < 0) { @@ -356,7 +378,7 @@ function planSizeBasedClearing( const pending = normalizePendingContent(pendingContent); const virtualHistory = pending.length > 0 ? [...history, ...pending] : history; - const { tool } = collectCompactablePartRefs(virtualHistory); + const { tool } = collectCompactablePartRefs(virtualHistory, projectRoot); const charsByRef = new Map(); let totalChars = 0; let pendingChars = 0; @@ -412,6 +434,7 @@ export interface MicrocompactOptions { force?: boolean; sizeOnly?: boolean; pendingContent?: Content | Content[]; + projectRoot?: string; } export interface MicrocompactMeta { @@ -494,7 +517,10 @@ export function microcompactHistory( } if (triggerReason === 'force' || triggerReason === 'idle') { - ({ tool, media, nestedMedia } = collectCompactablePartRefs(history)); + ({ tool, media, nestedMedia } = collectCompactablePartRefs( + history, + opts?.projectRoot, + )); // Each kind gets its own keepRecent budget: setting // `toolResultsNumToKeep: 1` keeps 1 of each, not 1 total. This // matches what users typically expect when they configure the @@ -514,6 +540,7 @@ export function microcompactHistory( settings, keepRecent, pending, + opts?.projectRoot, ); if (!sizePlan) { return { history };