diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 5d51ef2a0f6..76d2da8b654 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -302,21 +302,21 @@ The `extra_body` field allows you to add custom parameters to the request body s #### context -| Setting | Type | Description | Default | -| ----------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------- | -| `context.fileName` | string or array of strings | The name of the context file(s). | `undefined` | -| `context.autoCompactThreshold` | number | Target fraction of the context window at which auto-compaction triggers. Must be greater than 0 and at most 1. Default is `0.85` (85%). Acts as a ceiling on the trigger: on large windows it is the effective trigger (~85%), while on smaller windows compaction may fire earlier to leave room to summarize. Replaces the old `model.chatCompression.contextPercentageThreshold`. | `undefined` (uses internal 0.85) | -| `context.importFormat` | string | The format to use when importing memory. | `undefined` | -| `context.includeDirectories` | array | Additional directories to include in the workspace context. Specifies an array of additional absolute or relative paths to include in the workspace context. Missing directories will be skipped with a warning by default. Paths can use `~` to refer to the user's home directory. This setting can be combined with the `--include-directories` command-line flag. | `[]` | -| `context.loadFromIncludeDirectories` | boolean | Controls the behavior of the `/memory refresh` command. If set to `true`, `QWEN.md` files should be loaded from all directories that are added. If set to `false`, `QWEN.md` should only be loaded from the current directory. | `false` | -| `context.fileFiltering.respectGitIgnore` | boolean | Respect .gitignore files when searching. | `true` | -| `context.fileFiltering.respectQwenIgnore` | boolean | Respect .qwenignore and configured custom ignore files when searching. | `true` | -| `context.fileFiltering.customIgnoreFiles` | array | Project-root-relative ignore files to use instead of the default compatibility files (`.agentignore`, `.aiignore`) when `respectQwenIgnore` is enabled. `.qwenignore` is always included. | `[".agentignore", ".aiignore"]` | -| `context.fileFiltering.enableRecursiveFileSearch` | boolean | Whether to enable searching recursively for filenames under the current tree when completing `@` prefixes in the prompt. | `true` | -| `context.fileFiltering.enableFuzzySearch` | boolean | When `true`, enables fuzzy search capabilities when searching for files. Set to `false` to improve performance on projects with a large number of files. | `true` | -| `context.clearContextOnIdle.toolResultsThresholdMinutes` | number | Minutes of inactivity before clearing old tool result content. Use `-1` to disable the idle trigger. | `60` | -| `context.clearContextOnIdle.toolResultsNumToKeep` | integer | Integer number of most-recent compactable tool results to preserve when clearing. Values below 1 are floored to 1. | `5` | -| `context.clearContextOnIdle.toolResultsTotalCharsThreshold` | number | Total compactable tool result output characters allowed in history before clearing oldest results. Use `-1` to disable the size trigger. This is a soft threshold: protected recent tool results may keep the total above it. | `500000` | +| Setting | Type | Description | Default | +| ----------------------------------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | +| `context.fileName` | string or array of strings | The name of the context file(s). | `undefined` | +| `context.autoCompactThreshold` | number | Target fraction of the context window at which auto-compaction triggers. Must be greater than 0 and at most 1. Default is `0.85` (85%). Acts as a ceiling on the trigger: on large windows it is the effective trigger (~85%), while on smaller windows compaction may fire earlier to leave room to summarize. Replaces the old `model.chatCompression.contextPercentageThreshold`. | `undefined` (uses internal 0.85) | +| `context.importFormat` | string | The format to use when importing memory. | `undefined` | +| `context.includeDirectories` | array | Additional directories to include in the workspace context. Specifies an array of additional absolute or relative paths to include in the workspace context. Missing directories will be skipped with a warning by default. Paths can use `~` to refer to the user's home directory. This setting can be combined with the `--include-directories` command-line flag. | `[]` | +| `context.loadFromIncludeDirectories` | boolean | Controls the behavior of the `/memory refresh` command. If set to `true`, `QWEN.md` files should be loaded from all directories that are added. If set to `false`, `QWEN.md` should only be loaded from the current directory. | `false` | +| `context.fileFiltering.respectGitIgnore` | boolean | Respect .gitignore files when searching. | `true` | +| `context.fileFiltering.respectQwenIgnore` | boolean | Respect .qwenignore and configured custom ignore files when searching. | `true` | +| `context.fileFiltering.customIgnoreFiles` | array | Project-root-relative ignore files to use instead of the default compatibility files (`.agentignore`, `.aiignore`) when `respectQwenIgnore` is enabled. `.qwenignore` is always included. | `[".agentignore", ".aiignore"]` | +| `context.fileFiltering.enableRecursiveFileSearch` | boolean | Whether to enable searching recursively for filenames under the current tree when completing `@` prefixes in the prompt. | `true` | +| `context.fileFiltering.enableFuzzySearch` | boolean | When `true`, enables fuzzy search capabilities when searching for files. Set to `false` to improve performance on projects with a large number of files. | `true` | +| `context.clearContextOnIdle.toolResultsThresholdMinutes` | number | Minutes of inactivity before clearing old tool result content. Use `-1` to disable the idle trigger. | `60` | +| `context.clearContextOnIdle.toolResultsNumToKeep` | integer | Integer number of most-recent compactable tool results to preserve when clearing. Values below 1 are floored to 1. | `5` | +| `context.clearContextOnIdle.toolResultsTotalCharsThreshold` | number | Total compactable tool result output characters allowed in history before clearing oldest results. When exceeded, oldest results are cleared down to half this threshold (best effort) so later turns keep reusing the provider prompt cache instead of rewriting history every turn. Use `-1` to disable the size trigger. This is a soft threshold: protected recent tool results may keep the total above it. | `500000` | #### Troubleshooting File Search Performance diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index cca7d0b9201..c21eaa9ebbd 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1879,7 +1879,7 @@ const SETTINGS_SCHEMA = { requiresRestart: false, default: DEFAULT_TOOL_RESULTS_TOTAL_CHARS_THRESHOLD as number, description: - 'Total compactable tool result output characters allowed in history before clearing oldest results. Use -1 to disable. This is a soft threshold: protected recent tool results may keep the total above it.', + 'Total compactable tool result output characters allowed in history before clearing oldest results. When exceeded, oldest results are cleared down to half this threshold (best effort) to preserve the provider prompt cache on later turns. Use -1 to disable. This is a soft threshold: protected recent tool results may keep the total above it.', showInDialog: false, }, }, diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 3b5676827d9..bb9bc5f9ea0 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -3509,7 +3509,7 @@ describe('Gemini Client (client.ts)', () => { functionResponse: { id: 'pending-shell', name: 'run_shell_command', - response: { output: 'Y'.repeat(50_000) }, + response: { output: 'Y'.repeat(140_000) }, }, }, ], @@ -3527,14 +3527,70 @@ describe('Gemini Client (client.ts)', () => { compacted[1]!.parts![0]!.functionResponse!.response!['output'], ).toBe('[Old tool result content cleared]'); expect(clear).not.toHaveBeenCalled(); - expect(markReadEvictedFromHistory).toHaveBeenCalledTimes(1); + // Three reads are blanked while clearing down to the 250K watermark. + expect(markReadEvictedFromHistory).toHaveBeenCalledTimes(3); + expect(mockClientDebugLogger.info).toHaveBeenCalledWith( + expect.stringContaining( + '[TOOL-RESULT MC] tool result chars 620000 > 500000', + ), + ); expect(mockClientDebugLogger.info).toHaveBeenCalledWith( expect.stringContaining( - '[TOOL-RESULT MC] tool result chars 530000 > 500000', + 'history now 120000 (+140000 pending), target 250000 (soft-exceeded)', ), ); + }); + + it('omits the soft-exceeded marker when clearing lands exactly on the watermark', async () => { + // Pins the marker's absence at the boundary: virtual total after + // clearing == watermark must NOT be flagged (kills the `>=` and + // always-true mutants of the marker condition). + const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub(); + const { history } = await makeReadFileResponses(3, 150_000); + const setHistory = vi.fn(); + client['chat'] = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue(history), + setHistory, + } as unknown as GeminiChat; + vi.mocked(mockConfig.getClearContextOnIdle).mockReturnValue({ + toolResultsThresholdMinutes: 60, + toolResultsNumToKeep: 1, + toolResultsTotalCharsThreshold: 500_000, + }); + client['lastApiCompletionTimestamp'] = Date.now(); + mockClientDebugLogger.info.mockClear(); + + const stream = client.sendMessageStream( + [ + { + functionResponse: { + id: 'pending-shell-exact', + name: 'run_shell_command', + response: { output: 'Y'.repeat(100_000) }, + }, + }, + ], + new AbortController().signal, + 'prompt-toolresult-watermark-boundary', + { type: SendMessageType.ToolResult }, + ); + for await (const _ of stream) { + /* drain */ + } + + // 550K total → clear two 150K reads → 150K committed + 100K pending + // sits exactly on the 250K watermark. + expect(setHistory).toHaveBeenCalled(); + expect(clear).not.toHaveBeenCalled(); + expect(markReadEvictedFromHistory).toHaveBeenCalledTimes(2); expect(mockClientDebugLogger.info).toHaveBeenCalledWith( - expect.stringContaining('history now 360000 (+50000 pending)'), + expect.stringContaining( + 'history now 150000 (+100000 pending), target 250000', + ), + ); + expect(mockClientDebugLogger.info).not.toHaveBeenCalledWith( + expect.stringContaining('(soft-exceeded)'), ); }); @@ -3576,6 +3632,9 @@ describe('Gemini Client (client.ts)', () => { expect(mockClientDebugLogger.info).toHaveBeenCalledWith( expect.stringContaining('cleared 0 tool result(s)'), ); + expect(mockClientDebugLogger.info).toHaveBeenCalledWith( + expect.stringContaining('target 250000 (soft-exceeded)'), + ); expect(mockClientDebugLogger.info).toHaveBeenCalledWith( expect.stringContaining('history now 800000'), ); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index f8c3ef56fde..c291e3a818c 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -2024,12 +2024,21 @@ export class GeminiClient { m.pendingToolResultChars && m.pendingToolResultChars > 0 ? ` (+${m.pendingToolResultChars} pending)` : ''; + const virtualAfter = + (m.toolResultCharsAfter ?? 0) + (m.pendingToolResultChars ?? 0); + const targetNote = + m.toolResultsLowWatermark !== undefined + ? `, target ${m.toolResultsLowWatermark}` + + (virtualAfter > m.toolResultsLowWatermark + ? ' (soft-exceeded)' + : '') + : ''; debugLogger.info( `[TOOL-RESULT MC] tool result chars ${m.toolResultCharsBefore} > ` + `${m.toolResultsTotalCharsThreshold}, cleared ${m.toolsCleared} ` + `tool result(s) (~${m.tokensSaved} tokens), history now ` + - `${m.toolResultCharsAfter}${pendingNote}, kept ${m.toolsKept} ` + - `tool result(s)`, + `${m.toolResultCharsAfter}${pendingNote}${targetNote}, kept ` + + `${m.toolsKept} tool result(s)`, ); } else { debugLogger.info( diff --git a/packages/core/src/services/microcompaction/microcompact.test.ts b/packages/core/src/services/microcompaction/microcompact.test.ts index ae5f70f2ba0..1e34b9b27cc 100644 --- a/packages/core/src/services/microcompaction/microcompact.test.ts +++ b/packages/core/src/services/microcompaction/microcompact.test.ts @@ -834,9 +834,12 @@ describe('microcompactHistory', () => { expect(result.meta).toBeDefined(); expect(result.meta!.triggerReason).toBe('size'); expect(result.meta!.toolResultCharsBefore).toBe(530_000); - expect(result.meta!.toolResultCharsAfter).toBe(360_000); + // Clears down to the low watermark (threshold / 2), not just below + // the threshold: 530K → clear 3 × 120K → 170K virtual, 120K committed. + expect(result.meta!.toolResultCharsAfter).toBe(120_000); expect(result.meta!.pendingToolResultChars).toBe(50_000); - expect(result.meta!.toolsCleared).toBe(1); + expect(result.meta!.toolResultsLowWatermark).toBe(250_000); + expect(result.meta!.toolsCleared).toBe(3); expect(result.history).toHaveLength(history.length); expect( result.history[1]!.parts![0]!.functionResponse!.response!['output'], @@ -930,7 +933,9 @@ describe('microcompactHistory', () => { expect(result.meta).toBeDefined(); expect(result.meta!.triggerReason).toBe('size'); - expect(result.meta!.toolsCleared).toBe(1); + // A and B cleared to reach the 250K watermark; the error result is + // not counted, the pre-cleared result is not re-cleared. + expect(result.meta!.toolsCleared).toBe(2); expect( result.history[1]!.parts![0]!.functionResponse!.response!['output'], ).toBe('E'.repeat(500_000)); @@ -940,11 +945,303 @@ describe('microcompactHistory', () => { expect( result.history[5]!.parts![0]!.functionResponse!.response!['output'], ).toBe(MICROCOMPACT_CLEARED_MESSAGE); + expect( + result.history[7]!.parts![0]!.functionResponse!.response!['output'], + ).toBe(MICROCOMPACT_CLEARED_MESSAGE); expect( result.history.at(-1)!.parts![0]!.functionResponse!.response!['output'], ).toBe('C'.repeat(200_000)); }); + it('does not trigger at exactly the threshold and clears toward the watermark above it', () => { + const history: Content[] = [ + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'A'.repeat(250_000)), + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'B'.repeat(250_000)), + ]; + const settings = { + toolResultsThresholdMinutes: 60, + toolResultsNumToKeep: 1, + toolResultsTotalCharsThreshold: 500_000, + }; + + const atThreshold = microcompactHistory(history, Date.now(), settings); + expect(atThreshold.meta).toBeUndefined(); + expect(atThreshold.history).toBe(history); + + // One char over the threshold: clearing runs past "just below the + // threshold" (A alone would suffice for that) down to the watermark. + const overHistory: Content[] = [ + ...history, + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'C'), + ]; + const over = microcompactHistory(overHistory, Date.now(), settings); + expect(over.meta).toBeDefined(); + expect(over.meta!.triggerReason).toBe('size'); + expect(over.meta!.toolResultsLowWatermark).toBe(250_000); + expect(over.meta!.toolsCleared).toBe(2); + expect(over.meta!.toolResultCharsAfter).toBe(1); + }); + + it('amortizes rewrites: 167 sequential 25.5K results trigger exactly 14 size compactions', () => { + const settings = { + toolResultsThresholdMinutes: 60, + toolResultsNumToKeep: 5, + toolResultsTotalCharsThreshold: 500_000, + }; + let history: Content[] = []; + let compactions = 0; + for (let i = 0; i < 167; i++) { + history = [...history, makeToolCall('run_shell_command')]; + const pending = makeToolResult('run_shell_command', 'Y'.repeat(25_500)); + const result = microcompactHistory(history, Date.now(), settings, { + sizeOnly: true, + pendingContent: pending, + }); + if (result.meta) { + compactions++; + history = result.history; + } + history = [...history, pending]; + } + // Riding the threshold would rewrite on nearly every turn once past + // it (~148 times); the watermark batches this into 14 rewrites. + expect(compactions).toBe(14); + }); + + it('does not let pending results consume the keepRecent protection for committed history', () => { + const history: Content[] = []; + for (let i = 0; i < 12; i++) { + history.push( + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'Y'.repeat(25_500)), + ); + } + const pending: Content[] = []; + for (let i = 0; i < 5; i++) { + pending.push(makeToolResult('run_shell_command', 'P'.repeat(50_000))); + } + + const result = microcompactHistory( + history, + Date.now(), + { + toolResultsThresholdMinutes: 60, + toolResultsNumToKeep: 5, + toolResultsTotalCharsThreshold: 500_000, + }, + { sizeOnly: true, pendingContent: pending }, + ); + + expect(result.meta).toBeDefined(); + expect(result.meta!.triggerReason).toBe('size'); + // A pending batch of keepRecent results must not leave the committed + // history unprotected: only the 7 oldest results are cleared and the + // 5 most recent committed ones survive. + expect(result.meta!.toolsCleared).toBe(7); + expect(result.meta!.toolsKept).toBe(5); + expect(result.meta!.pendingToolResultChars).toBe(250_000); + expect(result.meta!.toolResultCharsAfter).toBe(127_500); + expect( + result.history[1]!.parts![0]!.functionResponse!.response!['output'], + ).toBe(MICROCOMPACT_CLEARED_MESSAGE); + expect( + result.history[15]!.parts![0]!.functionResponse!.response!['output'], + ).toBe('Y'.repeat(25_500)); + }); + + it('stops at best effort when protected results keep the total above the watermark', () => { + const history: Content[] = [ + makeFileToolCall('mem', '/memory/project/context.md'), + makeFileToolResult('mem', 'M'.repeat(200_000)), + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'O'.repeat(200_000)), + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'R'.repeat(200_000)), + ]; + + const result = microcompactHistory( + history, + Date.now(), + { + toolResultsThresholdMinutes: 60, + toolResultsNumToKeep: 1, + toolResultsTotalCharsThreshold: 500_000, + }, + { + sizeOnly: true, + preserveReadFileResult: (filePath) => filePath.startsWith('/memory/'), + }, + ); + + expect(result.meta!.triggerReason).toBe('size'); + // Only the old shell result is clearable; the preserved memory read + // and the keepRecent-protected result soft-exceed the watermark. + expect(result.meta!.toolsCleared).toBe(1); + expect(result.meta!.toolResultCharsAfter).toBe(400_000); + expect(result.meta!.toolResultsLowWatermark).toBe(250_000); + expect( + result.history[1]!.parts![0]!.functionResponse!.response!['output'], + ).toBe('M'.repeat(200_000)); + expect( + result.history[5]!.parts![0]!.functionResponse!.response!['output'], + ).toBe('R'.repeat(200_000)); + }); + + it('derives the watermark from a custom threshold as floor(threshold / 2)', () => { + const history: Content[] = [ + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'A'.repeat(40)), + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'B'.repeat(40)), + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'C'.repeat(30)), + ]; + + const result = microcompactHistory(history, Date.now(), { + toolResultsThresholdMinutes: 60, + toolResultsNumToKeep: 1, + toolResultsTotalCharsThreshold: 101, + }); + + expect(result.meta!.toolResultsLowWatermark).toBe(50); + // 110 > 101 triggers; clearing A alone (70) would satisfy the old + // threshold bound but not the 50-char watermark, so B goes too. + expect(result.meta!.toolsCleared).toBe(2); + expect(result.meta!.toolResultCharsAfter).toBe(30); + }); + + it('does not rewrite again until the total climbs back over the threshold', () => { + const history: Content[] = []; + for (let i = 0; i < 21; i++) { + history.push( + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'Y'.repeat(25_500)), + ); + } + const settings = { + toolResultsThresholdMinutes: 60, + toolResultsNumToKeep: 5, + toolResultsTotalCharsThreshold: 500_000, + }; + + const first = microcompactHistory(history, Date.now(), settings, { + sizeOnly: true, + }); + expect(first.meta).toBeDefined(); + expect(first.meta!.toolsCleared).toBe(12); + + // Next checkpoint stays under the threshold: the history must be + // returned untouched so the provider cache prefix stays stable. + const second = microcompactHistory(first.history, Date.now(), settings, { + sizeOnly: true, + pendingContent: makeToolResult('run_shell_command', 'Y'.repeat(25_500)), + }); + expect(second.meta).toBeUndefined(); + expect(second.history).toBe(first.history); + }); + + it('does not let trailing zero-char results consume keepRecent slots', () => { + // Errors, prior placeholders, and empty outputs can never be cleared, + // so they must not absorb protection slots — otherwise the real + // recent outputs go unprotected and deep clearing strands them. + const history: Content[] = []; + for (let i = 0; i < 10; i++) { + history.push( + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'Y'.repeat(60_000)), + ); + } + history.push( + makeToolCall('run_shell_command'), + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'run_shell_command', + response: { error: 'boom' }, + }, + }, + ], + }, + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', MICROCOMPACT_CLEARED_MESSAGE), + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', ''), + ); + + const result = microcompactHistory(history, Date.now(), { + toolResultsThresholdMinutes: 60, + toolResultsNumToKeep: 5, + toolResultsTotalCharsThreshold: 500_000, + }); + + expect(result.meta!.triggerReason).toBe('size'); + // The 5 oldest 60K outputs are cleared; the 5 most recent 60K + // outputs stay protected even though 3 zero-char refs trail them. + expect(result.meta!.toolsCleared).toBe(5); + expect(result.meta!.toolsKept).toBe(5); + expect(result.meta!.toolResultCharsAfter).toBe(300_000); + for (const idx of [11, 13, 15, 17, 19]) { + expect( + result.history[idx]!.parts![0]!.functionResponse!.response!['output'], + ).toBe('Y'.repeat(60_000)); + } + expect( + result.history[21]!.parts![0]!.functionResponse!.response!['error'], + ).toBe('boom'); + }); + + it('can re-trigger on consecutive checkpoints when protections pin the total above the threshold', () => { + // Narrowed guarantee: when keepRecent-protected results alone exceed + // the threshold, the watermark is unreachable and the size trigger + // fires again on the next checkpoint (matching the pre-watermark + // rolling regime) until the total drops below the threshold. + const settings = { + toolResultsThresholdMinutes: 60, + toolResultsNumToKeep: 5, + toolResultsTotalCharsThreshold: 500_000, + }; + let history: Content[] = [ + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'a'), + ]; + for (let i = 0; i < 5; i++) { + history.push( + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'Y'.repeat(100_000)), + ); + } + + const checkpoint = (h: Content[], pendingText: string) => { + const pending = makeToolResult('run_shell_command', pendingText); + const result = microcompactHistory(h, Date.now(), settings, { + sizeOnly: true, + pendingContent: pending, + }); + return { result, committed: [...result.history, pending] }; + }; + + // Checkpoint 1: 500_002 > H; only the 1-char result is clearable — + // the five protected 100K results keep the total above H. + const first = checkpoint(history, 'b'); + expect(first.result.meta!.toolsCleared).toBe(1); + history = first.committed; + + // Checkpoint 2: still over H, fires again — the oldest 100K result + // rotated out of the protection window and is cleared now. + const second = checkpoint(history, 'c'); + expect(second.result.meta!.toolsCleared).toBe(1); + history = second.committed; + + // Checkpoint 3: total is back under H — stable again. + const third = checkpoint(history, 'd'); + expect(third.result.meta).toBeUndefined(); + }); + it('treats a negative legacy idle threshold as disabling the size trigger when unset', () => { const history: Content[] = []; for (let i = 0; i < 20; i++) { @@ -1289,6 +1586,97 @@ describe('microcompactHistory', () => { expect(cleared.response.output).toBe(MICROCOMPACT_CLEARED_MESSAGE); expect(cleared.parts).toBeUndefined(); }); + + it('keeps a media-only tool result in the recent-result budget (idle path)', () => { + // An image/PDF read_file result carries empty text output with its + // bytes on functionResponse.parts. Empty output must not evict it + // from the keepRecent candidates — unlike errors or placeholders it + // IS clearable on this path, and it is the newest result here. + const mediaOnlyResult: Content = { + role: 'user', + parts: [ + { + functionResponse: { + id: 'img', + name: 'read_file', + response: { output: '' }, + parts: [ + { inlineData: { mimeType: 'image/png', data: 'BASE64IMAGE' } }, + ], + } as unknown as NonNullable< + Content['parts'] + >[number]['functionResponse'], + }, + ], + }; + const history: Content[] = [makeToolCall('read_file'), mediaOnlyResult]; + + const result = microcompactHistory(history, twoHoursAgo, DEFAULT_SETTINGS); + + expect(result.meta).toBeUndefined(); + expect(result.history).toBe(history); + const kept = result.history[1]!.parts![0]!.functionResponse as { + response: { output: string }; + parts?: Array<{ inlineData?: { data?: string } }>; + }; + expect(kept.parts?.[0]?.inlineData?.data).toBe('BASE64IMAGE'); + }); + + it('does not blank zero-char tool refs on the idle path', () => { + // Zero-char refs (errors, prior placeholders, empty outputs) must not + // be blanked by an idle/force clear even though they are excluded from + // keepRecent protection slots. This mirrors the size-path guard. + const history: Content[] = []; + for (let i = 0; i < 7; i++) { + history.push( + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'Y'.repeat(60_000)), + ); + } + history.push( + makeToolCall('run_shell_command'), + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'run_shell_command', + response: { error: 'boom' }, + }, + }, + ], + }, + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', MICROCOMPACT_CLEARED_MESSAGE), + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', ''), + ); + + const result = microcompactHistory(history, twoHoursAgo, { + ...DEFAULT_SETTINGS, + toolResultsNumToKeep: 5, + }); + + expect(result.meta!.triggerReason).toBe('idle'); + // The 5 newest real outputs are protected; trailing zero-char refs are + // not cleared, so only the 2 oldest real outputs are blanked. + expect(result.meta!.toolsCleared).toBe(2); + expect(result.meta!.toolsKept).toBe(5); + for (const idx of [5, 7, 9, 11, 13]) { + expect( + result.history[idx]!.parts![0]!.functionResponse!.response!['output'], + ).toBe('Y'.repeat(60_000)); + } + expect( + result.history[15]!.parts![0]!.functionResponse!.response!['error'], + ).toBe('boom'); + expect( + result.history[17]!.parts![0]!.functionResponse!.response!['output'], + ).toBe(MICROCOMPACT_CLEARED_MESSAGE); + expect( + result.history[19]!.parts![0]!.functionResponse!.response!['output'], + ).toBe(''); + }); }); describe('microcompactHistory evictedReadPaths (issue #4239)', () => { @@ -1328,7 +1716,10 @@ describe('microcompactHistory evictedReadPaths (issue #4239)', () => { expect(result.meta!.unresolvedEvictedReads).toBe(0); }); - it('does not report a path when a kept read_file result for the same file remains', () => { + it('does not let a kept read_file result vouch for residency (issue #4239)', () => { + // A kept read_file result may be a cache-hit placeholder or partial + // slice, so it cannot prove the file's bytes stay resident. The path + // must be reported so the caller disarms the fast path. const history: Content[] = [ fileCall('old', 'read_file', '/proj/same.ts'), fileResult('old', 'read_file', 'old long content '.repeat(50)), @@ -1341,15 +1732,66 @@ describe('microcompactHistory evictedReadPaths (issue #4239)', () => { toolResultsNumToKeep: 1, }); + expect(result.meta!.toolsCleared).toBe(1); + expect(result.meta!.evictedReadPaths).toEqual(['/proj/same.ts']); + expect(result.meta!.unresolvedEvictedReads).toBe(0); + }); + + it('lets a kept write_file result vouch for residency', () => { + // A kept write_file result proves the file's complete current bytes + // are in history — the functionCall carries the full content — so + // the path stays resident when the older read_file result for the + // same file is blanked. + const history: Content[] = [ + fileCall('old', 'read_file', '/proj/a.ts'), + fileResult('old', 'read_file', 'old long content '.repeat(50)), + fileCall('keep', 'write_file', '/proj/a.ts'), + fileResult('keep', 'write_file', 'newer full content'), + ]; + + const result = microcompactHistory(history, TWO_HOURS_AGO, { + toolResultsThresholdMinutes: 5, + toolResultsNumToKeep: 1, + }); + + expect(result.meta).toBeDefined(); expect(result.meta!.toolsCleared).toBe(1); expect(result.meta!.evictedReadPaths).toEqual([]); expect(result.meta!.unresolvedEvictedReads).toBe(0); }); - it('does not report a path when a pending kept result for the same file remains', () => { + it('does not let a kept edit result vouch for residency', () => { + // An edit call carries only old/new snippets — the complete bytes + // lived in the older full read being blanked — yet it sets the + // cache's sticky full-read flags. Only write_file proves residency. + const history: Content[] = [ + fileCall('old', 'read_file', '/proj/a.ts'), + fileResult('old', 'read_file', 'old long content '.repeat(50)), + fileCall('keep', 'edit', '/proj/a.ts'), + fileResult('keep', 'edit', 'edit success snippet'), + ]; + + const result = microcompactHistory(history, TWO_HOURS_AGO, { + toolResultsThresholdMinutes: 5, + toolResultsNumToKeep: 1, + }); + + expect(result.meta!.toolsCleared).toBe(1); + expect(result.meta!.evictedReadPaths).toEqual(['/proj/a.ts']); + expect(result.meta!.unresolvedEvictedReads).toBe(0); + }); + + it('reports the path even when a pending same-path read exists (conservative disarm)', () => { + // A pending read_file result may be the file_unchanged cache-hit + // placeholder rather than file bytes, and pending content is not + // committed yet — it cannot prove the file's bytes stay resident. + // The eviction must be reported; over-disarming only costs a + // redundant re-read (issue #4239). const history: Content[] = [ fileCall('old', 'read_file', '/proj/same.ts'), fileResult('old', 'read_file', 'old long content '.repeat(50)), + fileCall('c1', 'read_file', '/proj/other.ts'), + fileResult('c1', 'read_file', 'other recent'), fileCall('keep', 'read_file', '/proj/same.ts'), ]; @@ -1369,7 +1811,49 @@ describe('microcompactHistory evictedReadPaths (issue #4239)', () => { expect(result.meta!.triggerReason).toBe('size'); expect(result.meta!.toolsCleared).toBe(1); - expect(result.meta!.evictedReadPaths).toEqual([]); + expect(result.meta!.evictedReadPaths).toEqual(['/proj/same.ts']); + expect(result.meta!.unresolvedEvictedReads).toBe(0); + }); + + it('does not let a pending cache-hit placeholder suppress eviction of the full read', () => { + // The pending same-file read is the file_unchanged placeholder — it + // points AT the old full read, so once that full read is blanked the + // path must be disarmed or the next Read serves a dangling + // placeholder. + const history: Content[] = [ + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'S'.repeat(200_000)), + fileCall('rf1', 'read_file', '/proj/big.ts'), + fileResult('rf1', 'read_file', 'F'.repeat(200_000)), + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'R'.repeat(120_000)), + fileCall('rf2', 'read_file', '/proj/big.ts'), + ]; + + const result = microcompactHistory( + history, + Date.now(), + { + toolResultsThresholdMinutes: 60, + toolResultsNumToKeep: 1, + toolResultsTotalCharsThreshold: 500_000, + }, + { + sizeOnly: true, + pendingContent: fileResult( + 'rf2', + 'read_file', + '[File big.ts unchanged since last read in this session]', + ), + }, + ); + + expect(result.meta!.triggerReason).toBe('size'); + expect(result.meta!.toolsCleared).toBe(2); + expect( + result.history[3]!.parts![0]!.functionResponse!.response!['output'], + ).toBe(MICROCOMPACT_CLEARED_MESSAGE); + expect(result.meta!.evictedReadPaths).toEqual(['/proj/big.ts']); expect(result.meta!.unresolvedEvictedReads).toBe(0); }); diff --git a/packages/core/src/services/microcompaction/microcompact.ts b/packages/core/src/services/microcompaction/microcompact.ts index b71c2556f98..ed0bd6e7e64 100644 --- a/packages/core/src/services/microcompaction/microcompact.ts +++ b/packages/core/src/services/microcompaction/microcompact.ts @@ -368,6 +368,15 @@ function buildKeptFilePaths( if (!keepRefs.has(refKey(ref))) continue; const part = getPart(history, ref); if (!part || isErrorResponse(part) || isAlreadyCleared(part)) continue; + // Only write_file results anchor the file's complete current bytes: + // the functionCall carries the full `content` on a model-role part + // microcompaction never blanks. read_file results can be cache-hit + // placeholders or partial slices, and edit results carry only an + // old/new snippet while still setting the cache's sticky full-read + // flags — neither proves the file stays resident (issue #4239). + if (part.functionResponse?.name !== ToolNames.WRITE_FILE) { + continue; + } const paths = getFilePathsForResponse(part, callIdToFilePath); // If an id maps to multiple possible paths, a kept result cannot prove // which file is still resident. Keep the #4239-safe behavior and do not @@ -387,6 +396,7 @@ interface SizeClearPlan { toolResultCharsAfter: number; pendingToolResultChars: number; toolResultsTotalCharsThreshold: number; + toolResultsLowWatermark: number; } function planSizeBasedClearing( @@ -400,6 +410,12 @@ function planSizeBasedClearing( if (!Number.isFinite(threshold) || threshold < 0) { return null; } + // Clear down to half the threshold, not just below it: stopping at the + // threshold leaves the total riding the limit, so every subsequent turn + // re-triggers and rewrites one more old result, breaking the provider + // prompt-cache prefix on every request. The watermark is a best-effort + // target — protected results may keep the total above it. + const lowWatermark = Math.floor(threshold / 2); const pending = normalizePendingContent(pendingContent); const virtualHistory = @@ -427,11 +443,26 @@ function planSizeBasedClearing( const compactableToolRefs = tool.filter( (ref) => !preservedToolRefs.has(refKey(ref)), ); - const keepToolRefs = buildKeepRefs(compactableToolRefs, keepRecent); + // keepRecent protects the most-recent committed results that are + // actually at risk of clearing — refs present in charsByRef (positive, + // successful, uncleared output). Zero-char refs (errors, prior + // placeholders, empty output) are never cleared, so letting them absorb + // protection slots would strand real recent outputs unprotected. + // Pending refs are excluded entirely: they are uncleared by + // construction (contentIndex guard below), and a pending read_file + // result may be a cache-hit placeholder rather than file bytes, so it + // must not vouch for path residency either — an over-disarm only costs + // a redundant re-read (issue #4239). + const keepToolRefs = buildKeepRefs( + compactableToolRefs.filter( + (ref) => ref.contentIndex < history.length && charsByRef.has(refKey(ref)), + ), + keepRecent, + ); const clearRefs: PartRef[] = []; let remainingChars = totalChars; for (const ref of compactableToolRefs) { - if (remainingChars <= threshold) break; + if (remainingChars <= lowWatermark) break; const key = refKey(ref); const chars = charsByRef.get(key) ?? 0; @@ -455,6 +486,7 @@ function planSizeBasedClearing( toolResultCharsAfter: remainingChars - pendingChars, pendingToolResultChars: pendingChars, toolResultsTotalCharsThreshold: threshold, + toolResultsLowWatermark: lowWatermark, }; } @@ -477,6 +509,7 @@ export interface MicrocompactMeta { toolResultCharsAfter?: number; pendingToolResultChars?: number; toolResultsTotalCharsThreshold?: number; + toolResultsLowWatermark?: number; /** Count of `tool`-kind results cleared (compactable tool outputs). */ toolsCleared: number; /** Count of media parts cleared (`media` top-level + `nested-media` under non-compactable tools). */ @@ -532,6 +565,7 @@ export function microcompactHistory( let toolResultCharsAfter: number | undefined; let pendingToolResultChars: number | undefined; let toolResultsTotalCharsThreshold: number | undefined; + let toolResultsLowWatermark: number | undefined; let keptPathHistory = history; let keptPathRefs: PartRef[] = []; @@ -557,13 +591,41 @@ export function microcompactHistory( // `toolResultsNumToKeep: 1` keeps 1 of each, not 1 total. This // matches what users typically expect when they configure the // threshold for "tool results". + // Zero-char tool refs (errors, already-cleared placeholders, empty + // output) are never clearable, so letting them absorb protection + // slots would strand real recent outputs unprotected. Media-carrying + // results (image/PDF reads) have empty text output but ARE clearable + // on this path, so they must stay protection candidates. Media uses + // the same budget by count but is always clearable. + const keepToolRefs = buildKeepRefs( + tool.filter((ref) => { + const part = getPart(history, ref); + return getToolOutputChars(part) > 0 || (!!part && hasNestedMedia(part)); + }), + keepRecent, + ); keepRefs = new Set([ - ...tool.slice(-keepRecent).map(refKey), + ...keepToolRefs, ...media.slice(-keepRecent).map(refKey), ...nestedMedia.slice(-keepRecent).map(refKey), ]); const allRefs: PartRef[] = [...tool, ...media, ...nestedMedia]; - clearRefs = allRefs.filter((r) => !keepRefs.has(refKey(r))); + const toolKeys = new Set(tool.map(refKey)); + clearRefs = allRefs.filter((r) => { + if (keepRefs.has(refKey(r))) return false; + const part = getPart(history, r); + // Zero-character non-media tool refs are never clearable (mirrors the + // size path's `chars <= 0` skip). They are excluded from keepRecent + // candidates above, so without this guard they would be blanked here. + if ( + toolKeys.has(refKey(r)) && + getToolOutputChars(part) === 0 && + !(part && hasNestedMedia(part)) + ) { + return false; + } + return true; + }); keptPathRefs = tool; } else { const pending = normalizePendingContent(opts?.pendingContent); @@ -588,6 +650,7 @@ export function microcompactHistory( toolResultCharsAfter = sizePlan.toolResultCharsAfter; pendingToolResultChars = sizePlan.pendingToolResultChars; toolResultsTotalCharsThreshold = sizePlan.toolResultsTotalCharsThreshold; + toolResultsLowWatermark = sizePlan.toolResultsLowWatermark; } if (clearRefs.length === 0 && triggerReason !== 'size') { @@ -714,6 +777,7 @@ export function microcompactHistory( toolResultCharsAfter, pendingToolResultChars, toolResultsTotalCharsThreshold, + toolResultsLowWatermark, toolsCleared, mediaCleared, toolsKept, diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index f761e303baf..0c358ee9497 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -857,7 +857,7 @@ "description": "Integer number of most-recent compactable tool results to preserve when clearing. Values below 1 are floored to 1." }, "toolResultsTotalCharsThreshold": { - "description": "Total compactable tool result output characters allowed in history before clearing oldest results. Use -1 to disable. This is a soft threshold: protected recent tool results may keep the total above it.", + "description": "Total compactable tool result output characters allowed in history before clearing oldest results. When exceeded, oldest results are cleared down to half this threshold (best effort) to preserve the provider prompt cache on later turns. Use -1 to disable. This is a soft threshold: protected recent tool results may keep the total above it.", "type": "number", "default": 500000 }