diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index cb7a4892fb3..4dfb04cc3d9 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -228,18 +228,19 @@ 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.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 files when searching. | `true` | -| `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. | `60` | -| `context.clearContextOnIdle.toolResultsNumToKeep` | number | Number of most-recent compactable tool results to preserve when clearing. Floor at 1. | `5` | +| Setting | Type | Description | Default | +| ----------------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| `context.fileName` | string or array of strings | The name of the context file(s). | `undefined` | +| `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 files when searching. | `true` | +| `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` | number | Number of most-recent compactable tool results to preserve when clearing. Floor at 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` | #### Troubleshooting File Search Performance diff --git a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts index 563995131f3..20eda2a04a3 100644 --- a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts @@ -101,6 +101,7 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ APPROVAL_MODE_INFO: {}, APPROVAL_MODES: [], DEFAULT_STOP_HOOK_BLOCK_CAP: 8, + DEFAULT_TOOL_RESULTS_TOTAL_CHARS_THRESHOLD: 500_000, DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES: 1000, DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD: 25_000, ApprovalMode: { diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index c3fa844afd8..c1807af0e79 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -99,6 +99,17 @@ describe('SettingsSchema', () => { ).toBeDefined(); }); + it('should expose cumulative tool result threshold in clearContextOnIdle', () => { + const threshold = + getSettingsSchema().context.properties.clearContextOnIdle.properties + ?.toolResultsTotalCharsThreshold; + + expect(threshold).toBeDefined(); + expect(threshold?.type).toBe('number'); + expect(threshold?.default).toBe(500_000); + expect(threshold?.requiresRestart).toBe(false); + }); + it('should have sandboxImage setting under tools', () => { expect(getSettingsSchema().tools.properties.sandboxImage).toBeDefined(); expect(getSettingsSchema().tools.properties.sandboxImage.type).toBe( diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index b74b8ced02f..25d724341d3 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -17,6 +17,7 @@ import { ApprovalMode, DEFAULT_STOP_HOOK_BLOCK_CAP, DEFAULT_TOOL_OUTPUT_BATCH_BUDGET, + DEFAULT_TOOL_RESULTS_TOTAL_CHARS_THRESHOLD, DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, } from '@qwen-code/qwen-code-core'; @@ -1370,7 +1371,7 @@ const SETTINGS_SCHEMA = { requiresRestart: false, default: {}, description: - 'Settings for clearing stale context after idle periods. Use -1 to disable a threshold.', + 'Settings for clearing stale or oversized tool result context. Use -1 to disable a threshold.', showInDialog: false, properties: { toolResultsThresholdMinutes: { @@ -1393,6 +1394,16 @@ const SETTINGS_SCHEMA = { 'Number of most-recent compactable tool results to preserve when clearing. Floor at 1.', showInDialog: false, }, + toolResultsTotalCharsThreshold: { + type: 'number', + label: 'Tool Results Total Chars Threshold', + category: 'Context', + 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.', + showInDialog: false, + }, }, }, fileFiltering: { diff --git a/packages/core/src/config/clearContextDefaults.ts b/packages/core/src/config/clearContextDefaults.ts new file mode 100644 index 00000000000..f8662b27829 --- /dev/null +++ b/packages/core/src/config/clearContextDefaults.ts @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export const DEFAULT_TOOL_RESULTS_TOTAL_CHARS_THRESHOLD = 500_000; diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index c0894379b24..4fd498f5e10 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -2865,6 +2865,76 @@ describe('Server Config (config.ts)', () => { }); }); + describe('getClearContextOnIdle', () => { + it('should default the cumulative tool result threshold to 500000 chars', () => { + const config = new Config(baseParams); + + expect(config.getClearContextOnIdle()).toMatchObject({ + toolResultsThresholdMinutes: 60, + toolResultsNumToKeep: 5, + toolResultsTotalCharsThreshold: 500_000, + }); + }); + + it('should use a custom cumulative tool result threshold if provided', () => { + const config = new Config({ + ...baseParams, + clearContextOnIdle: { + toolResultsTotalCharsThreshold: 123_456, + }, + }); + + expect( + config.getClearContextOnIdle().toolResultsTotalCharsThreshold, + ).toBe(123_456); + }); + + it('should preserve an explicit disabled cumulative tool result threshold', () => { + const config = new Config({ + ...baseParams, + clearContextOnIdle: { + toolResultsTotalCharsThreshold: -1, + }, + }); + + expect(config.getClearContextOnIdle()).toMatchObject({ + toolResultsThresholdMinutes: 60, + toolResultsNumToKeep: 5, + toolResultsTotalCharsThreshold: -1, + }); + }); + + it('should keep legacy disabled idle cleanup disabled for the size trigger too', () => { + const config = new Config({ + ...baseParams, + clearContextOnIdle: { + toolResultsThresholdMinutes: -1, + }, + }); + + expect(config.getClearContextOnIdle()).toMatchObject({ + toolResultsThresholdMinutes: -1, + toolResultsNumToKeep: 5, + toolResultsTotalCharsThreshold: -1, + }); + }); + + it('should treat any negative legacy idle threshold as disabling the size trigger too', () => { + const config = new Config({ + ...baseParams, + clearContextOnIdle: { + toolResultsThresholdMinutes: -2, + }, + }); + + expect(config.getClearContextOnIdle()).toMatchObject({ + toolResultsThresholdMinutes: -2, + toolResultsNumToKeep: 5, + toolResultsTotalCharsThreshold: -1, + }); + }); + }); + // PR 14b fix (codex round 4 — wenshao gpt-5.5 review): the // `Config.setMcpBudgetEventCallback → pendingMcpBudgetCallback → // createToolRegistry → registry.getMcpClientManager().setOnBudgetEvent` diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 5865f0b4dd3..aef3c7c1e26 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -145,6 +145,7 @@ import { DEFAULT_FILE_FILTERING_OPTIONS, DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, } from './constants.js'; +import { DEFAULT_TOOL_RESULTS_TOTAL_CHARS_THRESHOLD } from './clearContextDefaults.js'; import { DEFAULT_QWEN_EMBEDDING_MODEL } from './models.js'; import { Storage } from './storage.js'; import { ChatRecordingService } from '../services/chatRecordingService.js'; @@ -357,15 +358,23 @@ export interface ChatCompressionSettings { screenshotTriggerThreshold?: number; } +export { DEFAULT_TOOL_RESULTS_TOTAL_CHARS_THRESHOLD } from './clearContextDefaults.js'; + /** - * Settings for clearing stale context after idle periods. + * Settings for clearing stale or oversized tool-result context. * Threshold values of -1 mean "never clear" (disabled). */ + export interface ClearContextOnIdleSettings { /** Minutes idle before clearing old tool results. Default 60. Use -1 to disable. */ toolResultsThresholdMinutes?: number; /** Number of most-recent tool results to preserve. Default 5. */ toolResultsNumToKeep?: number; + /** + * Total compactable tool result output chars before clearing old results. + * Default 500000. Use -1 to disable. + */ + toolResultsTotalCharsThreshold?: number; } export interface TelemetrySettings { @@ -1417,11 +1426,17 @@ export class Config { this.maxSessionTurns = params.maxSessionTurns ?? -1; this.maxWallTimeSeconds = params.maxWallTimeSeconds ?? -1; this.maxToolCalls = params.maxToolCalls ?? -1; + const clearContextOnIdle = params.clearContextOnIdle; + const toolResultsThresholdMinutes = + clearContextOnIdle?.toolResultsThresholdMinutes ?? 60; this.clearContextOnIdle = { - toolResultsThresholdMinutes: - params.clearContextOnIdle?.toolResultsThresholdMinutes ?? 60, - toolResultsNumToKeep: - params.clearContextOnIdle?.toolResultsNumToKeep ?? 5, + toolResultsThresholdMinutes, + toolResultsNumToKeep: clearContextOnIdle?.toolResultsNumToKeep ?? 5, + toolResultsTotalCharsThreshold: + clearContextOnIdle?.toolResultsTotalCharsThreshold ?? + ((clearContextOnIdle?.toolResultsThresholdMinutes ?? 0) < 0 + ? -1 + : DEFAULT_TOOL_RESULTS_TOTAL_CHARS_THRESHOLD), }; this.sessionTokenLimit = params.sessionTokenLimit ?? -1; this.experimentalZedIntegration = diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index d5049e9e99d..107c3c3cb01 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -1997,7 +1997,10 @@ describe('Gemini Client (client.ts)', () => { // Real on-disk files so client.ts's `fsPromises.stat(filePath)` (used // to resolve a blanked path to its inode) succeeds. `node:fs` is // mocked in this suite but `node:fs/promises` is not. - async function makeReadFileResponses(count: number): Promise<{ + async function makeReadFileResponses( + count: number, + outputLength?: number, + ): Promise<{ history: Content[]; paths: string[]; }> { @@ -2027,7 +2030,12 @@ describe('Gemini Client (client.ts)', () => { functionResponse: { id: callId, name: 'read_file', - response: { output: `content of ${i}` }, + response: { + output: + outputLength === undefined + ? `content of ${i}` + : String(i).repeat(outputLength), + }, }, }, ], @@ -2140,6 +2148,9 @@ describe('Gemini Client (client.ts)', () => { expect(setHistory).toHaveBeenCalled(); expect(clear).not.toHaveBeenCalled(); expect(markReadEvictedFromHistory).toHaveBeenCalledTimes(1); + expect(mockClientDebugLogger.info).toHaveBeenCalledWith( + expect.stringContaining('[TIME-BASED MC]'), + ); expect(client['lastHookMicrocompactionTimestamp']).toBeGreaterThan( Date.now() - 60_000, ); @@ -2588,7 +2599,7 @@ describe('Gemini Client (client.ts)', () => { expect(markReadEvictedFromHistory).toHaveBeenCalled(); }); - it('does not run microcompaction on SendMessageType.ToolResult', async () => { + it('does not run idle microcompaction on SendMessageType.ToolResult', async () => { const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub(); const { history } = await makeReadFileResponses(6); const setHistory = vi.fn(); @@ -2609,12 +2620,106 @@ describe('Gemini Client (client.ts)', () => { /* drain */ } - // Microcompaction did NOT run + // Idle gap alone does not trigger compaction on ToolResult turns. expect(setHistory).not.toHaveBeenCalled(); expect(clear).not.toHaveBeenCalled(); expect(markReadEvictedFromHistory).not.toHaveBeenCalled(); }); + it('runs size-only microcompaction on SendMessageType.ToolResult with pending content counted', async () => { + const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub(); + const { history } = await makeReadFileResponses(4, 120_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(); + + const stream = client.sendMessageStream( + [ + { + functionResponse: { + id: 'pending-shell', + name: 'run_shell_command', + response: { output: 'Y'.repeat(50_000) }, + }, + }, + ], + new AbortController().signal, + 'prompt-toolresult-size-budget', + { type: SendMessageType.ToolResult }, + ); + for await (const _ of stream) { + /* drain */ + } + + expect(setHistory).toHaveBeenCalled(); + const compacted = setHistory.mock.calls[0]![0] as Content[]; + expect( + compacted[1]!.parts![0]!.functionResponse!.response!['output'], + ).toBe('[Old tool result content cleared]'); + expect(clear).not.toHaveBeenCalled(); + expect(markReadEvictedFromHistory).toHaveBeenCalledTimes(1); + expect(mockClientDebugLogger.info).toHaveBeenCalledWith( + expect.stringContaining( + '[TOOL-RESULT MC] tool result chars 530000 > 500000', + ), + ); + expect(mockClientDebugLogger.info).toHaveBeenCalledWith( + expect.stringContaining('history now 360000 (+50000 pending)'), + ); + }); + + it('logs size overages when protected results leave nothing to clear', async () => { + const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub(); + const { history } = await makeReadFileResponses(2, 400_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: 2, + toolResultsTotalCharsThreshold: 500_000, + }); + client['lastApiCompletionTimestamp'] = Date.now(); + mockClientDebugLogger.info.mockClear(); + + const stream = client.sendMessageStream( + [{ text: 'hi' }], + new AbortController().signal, + 'prompt-size-overage-all-protected', + { type: SendMessageType.UserQuery }, + ); + for await (const _ of stream) { + /* drain */ + } + + expect(setHistory).not.toHaveBeenCalled(); + expect(clear).not.toHaveBeenCalled(); + expect(markReadEvictedFromHistory).not.toHaveBeenCalled(); + expect(mockClientDebugLogger.info).toHaveBeenCalledWith( + expect.stringContaining( + '[TOOL-RESULT MC] tool result chars 800000 > 500000', + ), + ); + expect(mockClientDebugLogger.info).toHaveBeenCalledWith( + expect.stringContaining('cleared 0 tool result(s)'), + ); + expect(mockClientDebugLogger.info).toHaveBeenCalledWith( + expect.stringContaining('history now 800000'), + ); + }); + it('runs microcompaction on SendMessageType.Cron', async () => { const { markReadEvictedFromHistory } = mockFileReadCacheStub(); const { history } = await makeReadFileResponses(6); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 83b5b2157fd..9f9cefb0310 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -5,6 +5,7 @@ */ // External dependencies +import { createUserContent } from '@google/genai'; import type { Content, GenerateContentConfig, @@ -23,6 +24,7 @@ import { recordStartupEvent } from '../utils/startupEventSink.js'; import { microcompactHistory, type MicrocompactMeta, + type MicrocompactOptions, } from '../services/microcompaction/microcompact.js'; import { activeGoalEquals, @@ -1567,31 +1569,50 @@ export class GeminiClient { } } - private async microcompactIdleHistory( + private async microcompactHistoryBeforeSend( lastCompletionTimestamp: number | null, + opts?: MicrocompactOptions, ): Promise { try { const mcResult = microcompactHistory( this.getHistoryShallow(), lastCompletionTimestamp, this.config.getClearContextOnIdle(), + opts, ); if (!mcResult.meta) { return false; } const m = mcResult.meta; - this.getChat().setHistory(mcResult.history); - await this.disarmFileReadCacheAfterEviction(m, 'microcompaction'); - debugLogger.debug( - `[TIME-BASED MC] gap ${m.gapMinutes}min > ${m.thresholdMinutes}min, ` + - `cleared ${m.toolsCleared} tool result(s) + ${m.mediaCleared} media (~${m.tokensSaved} tokens), ` + - `kept ${m.toolsKept} tool / ${m.mediaKept} media`, - ); - return true; + const changed = m.tokensSaved > 0; + if (changed) { + this.getChat().setHistory(mcResult.history); + await this.disarmFileReadCacheAfterEviction(m, 'microcompaction'); + } + if (m.triggerReason === 'size') { + const pendingNote = + m.pendingToolResultChars && m.pendingToolResultChars > 0 + ? ` (+${m.pendingToolResultChars} pending)` + : ''; + 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)`, + ); + } else { + debugLogger.info( + `[TIME-BASED MC] gap ${m.gapMinutes}min > ${m.thresholdMinutes}min, ` + + `cleared ${m.toolsCleared} tool result(s) + ${m.mediaCleared} media (~${m.tokensSaved} tokens), ` + + `kept ${m.toolsKept} tool / ${m.mediaKept} media`, + ); + } + return changed; } catch (err) { debugLogger.error( - `[TIME-BASED MC] microcompactHistory failed: ${err instanceof Error ? err.message : String(err)}`, + `[MICROCOMPACTION] microcompactHistory failed: ${err instanceof Error ? err.message : String(err)}`, ); return false; } @@ -1815,10 +1836,11 @@ export class GeminiClient { messageType === SendMessageType.UserQuery || messageType === SendMessageType.Cron ) { - // Idle cleanup: clear old tool results when idle > threshold. - // Runs on user and cron messages. ToolResult and Retry are - // excluded; Hook continuations use a separate checkpoint below. - const compacted = await this.microcompactIdleHistory( + // Pre-send microcompaction: user and cron turns can trigger both + // idle-based and cumulative-size cleanup. ToolResult and Retry are + // excluded here; ToolResult runs a size-only checkpoint after its + // pending content is assembled. + const compacted = await this.microcompactHistoryBeforeSend( this.lastApiCompletionTimestamp, ); if (messageType === SendMessageType.UserQuery || compacted) { @@ -1828,7 +1850,7 @@ export class GeminiClient { this.lastHookMicrocompactionTimestamp ??= this.lastApiCompletionTimestamp ?? Date.now(); const checkpoint = this.lastHookMicrocompactionTimestamp; - if (await this.microcompactIdleHistory(checkpoint)) { + if (await this.microcompactHistoryBeforeSend(checkpoint)) { this.lastHookMicrocompactionTimestamp = Date.now(); } } @@ -2061,6 +2083,10 @@ export class GeminiClient { // text as a separate user message after the tool messages. requestToSend = [...requestToSend, toolResultMemory.prompt]; } + await this.microcompactHistoryBeforeSend(null, { + sizeOnly: true, + pendingContent: createUserContent(requestToSend), + }); } const activeGoalAtTurnStart = getActiveGoal(this.config.getSessionId()); @@ -2604,7 +2630,7 @@ export class GeminiClient { * microcompaction. Falls back to a blanket clear() when any evicted * path can't be resolved. * - * Shared by the time-based microcompaction path and /compress-fast. + * Shared by pre-send microcompaction and /compress-fast. */ private async disarmFileReadCacheAfterEviction( meta: MicrocompactMeta, diff --git a/packages/core/src/services/microcompaction/microcompact.test.ts b/packages/core/src/services/microcompaction/microcompact.test.ts index 6d0c8884a44..7d29f99b887 100644 --- a/packages/core/src/services/microcompaction/microcompact.test.ts +++ b/packages/core/src/services/microcompaction/microcompact.test.ts @@ -354,6 +354,208 @@ describe('microcompactHistory', () => { ).toBe('batched-grep'); }); + it('size-compacts old tool results even when the idle trigger has not fired', () => { + const history: Content[] = []; + for (let i = 0; i < 167; i++) { + history.push( + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'Y'.repeat(25_500)), + ); + } + + const result = microcompactHistory(history, Date.now(), { + toolResultsThresholdMinutes: 60, + toolResultsNumToKeep: 5, + toolResultsTotalCharsThreshold: 500_000, + }); + + expect(result.meta).toBeDefined(); + expect(result.meta!.triggerReason).toBe('size'); + expect(result.meta!.toolsCleared).toBeGreaterThan(0); + expect(result.meta!.toolResultCharsBefore).toBe(4_258_500); + expect(result.meta!.toolResultCharsAfter).toBeLessThanOrEqual(500_000); + expect( + result.history[1]!.parts![0]!.functionResponse!.response!['output'], + ).toBe(MICROCOMPACT_CLEARED_MESSAGE); + expect( + result.history.at(-1)!.parts![0]!.functionResponse!.response!['output'], + ).toBe('Y'.repeat(25_500)); + }); + + it('counts pending content as a virtual tail for size-triggered compaction', () => { + const history: Content[] = []; + for (let i = 0; i < 4; i++) { + history.push( + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'Y'.repeat(120_000)), + ); + } + + const result = microcompactHistory( + history, + Date.now(), + { + toolResultsThresholdMinutes: 60, + toolResultsNumToKeep: 1, + toolResultsTotalCharsThreshold: 500_000, + }, + { + sizeOnly: true, + pendingContent: makeToolResult('run_shell_command', 'Y'.repeat(50_000)), + }, + ); + + expect(result.meta).toBeDefined(); + expect(result.meta!.triggerReason).toBe('size'); + expect(result.meta!.toolResultCharsBefore).toBe(530_000); + expect(result.meta!.toolResultCharsAfter).toBe(360_000); + expect(result.meta!.pendingToolResultChars).toBe(50_000); + expect(result.meta!.toolsCleared).toBe(1); + expect(result.history).toHaveLength(history.length); + expect( + result.history[1]!.parts![0]!.functionResponse!.response!['output'], + ).toBe(MICROCOMPACT_CLEARED_MESSAGE); + }); + + it('does not clear protected recent results even if they exceed the size threshold', () => { + const history: Content[] = [ + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'A'.repeat(400_000)), + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'B'.repeat(400_000)), + ]; + + const result = microcompactHistory(history, Date.now(), { + toolResultsThresholdMinutes: 60, + toolResultsNumToKeep: 2, + toolResultsTotalCharsThreshold: 500_000, + }); + + expect(result.history).toBe(history); + expect(result.meta).toMatchObject({ + triggerReason: 'size', + toolResultCharsBefore: 800_000, + toolResultCharsAfter: 800_000, + toolResultsTotalCharsThreshold: 500_000, + toolsCleared: 0, + toolsKept: 2, + tokensSaved: 0, + }); + }); + + it('does not clear media or non-compactable tool results for size overages', () => { + const history: Content[] = [ + makeInlineImage('image/png', 'A'.repeat(1000)), + makeToolCall('ask_user_question'), + makeToolResult('ask_user_question', 'answer'.repeat(50_000)), + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'old'.repeat(100_000)), + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'recent'), + ]; + + const result = microcompactHistory(history, Date.now(), { + toolResultsThresholdMinutes: 60, + toolResultsNumToKeep: 1, + toolResultsTotalCharsThreshold: 50_000, + }); + + expect(result.meta).toBeDefined(); + expect(result.meta!.triggerReason).toBe('size'); + expect(result.meta!.mediaCleared).toBe(0); + expect(result.history[0]).toBe(history[0]); + expect( + result.history[2]!.parts![0]!.functionResponse!.response!['output'], + ).toBe('answer'.repeat(50_000)); + expect( + result.history[4]!.parts![0]!.functionResponse!.response!['output'], + ).toBe(MICROCOMPACT_CLEARED_MESSAGE); + }); + + it('does not size-compact errors or already-cleared results', () => { + const history: Content[] = [ + makeToolCall('run_shell_command'), + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'run_shell_command', + response: { error: 'boom', output: 'E'.repeat(500_000) }, + }, + }, + ], + }, + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', MICROCOMPACT_CLEARED_MESSAGE), + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'A'.repeat(200_000)), + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'B'.repeat(200_000)), + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'C'.repeat(200_000)), + ]; + + const result = microcompactHistory(history, Date.now(), { + toolResultsThresholdMinutes: 60, + toolResultsNumToKeep: 1, + toolResultsTotalCharsThreshold: 500_000, + }); + + expect(result.meta).toBeDefined(); + expect(result.meta!.triggerReason).toBe('size'); + expect(result.meta!.toolsCleared).toBe(1); + expect( + result.history[1]!.parts![0]!.functionResponse!.response!['output'], + ).toBe('E'.repeat(500_000)); + expect( + result.history[3]!.parts![0]!.functionResponse!.response!['output'], + ).toBe(MICROCOMPACT_CLEARED_MESSAGE); + expect( + result.history[5]!.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('treats a negative legacy idle threshold as disabling the size trigger when unset', () => { + const history: Content[] = []; + for (let i = 0; i < 20; i++) { + history.push( + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'X'.repeat(30_000)), + ); + } + + const result = microcompactHistory(history, Date.now(), { + toolResultsThresholdMinutes: -2, + toolResultsNumToKeep: 1, + }); + + expect(result.meta).toBeUndefined(); + expect(result.history).toBe(history); + }); + + it('disables the size trigger when toolResultsTotalCharsThreshold is -1', () => { + const history: Content[] = []; + for (let i = 0; i < 20; i++) { + history.push( + makeToolCall('run_shell_command'), + makeToolResult('run_shell_command', 'Y'.repeat(25_500)), + ); + } + + const result = microcompactHistory(history, Date.now(), { + toolResultsThresholdMinutes: 60, + toolResultsNumToKeep: 5, + toolResultsTotalCharsThreshold: -1, + }); + + expect(result.meta).toBeUndefined(); + expect(result.history).toBe(history); + }); + it('should not clear tool error responses', () => { const history: Content[] = [ makeToolCall('read_file'), diff --git a/packages/core/src/services/microcompaction/microcompact.ts b/packages/core/src/services/microcompaction/microcompact.ts index fc12965670a..8b86a1d1fa1 100644 --- a/packages/core/src/services/microcompaction/microcompact.ts +++ b/packages/core/src/services/microcompaction/microcompact.ts @@ -7,6 +7,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 { sanitizeMimeForPlaceholder } from '../compactionInputSlimming.js'; import { ToolNames } from '../../tools/tool-names.js'; @@ -118,6 +119,10 @@ interface CollectedRefs { nestedMedia: PartRef[]; } +function refKey(r: PartRef): string { + return `${r.contentIndex}:${r.partIndex}`; +} + function hasNestedMedia(part: Part): boolean { const nested = (part.functionResponse as { parts?: unknown } | undefined) ?.parts; @@ -236,11 +241,152 @@ function stripNestedMedia( return rest; } +function getPart(history: Content[], ref: PartRef): Part | undefined { + return history[ref.contentIndex]?.parts?.[ref.partIndex]; +} + +function getToolOutputChars(part: Part | undefined): number { + if ( + !part || + !part.functionResponse?.name || + !COMPACTABLE_TOOLS.has(part.functionResponse.name) || + isErrorResponse(part) || + isAlreadyCleared(part) + ) { + return 0; + } + const output = part.functionResponse.response?.['output']; + return typeof output === 'string' ? output.length : 0; +} + +function normalizePendingContent( + pendingContent: Content | Content[] | undefined, +): Content[] { + if (!pendingContent) return []; + return Array.isArray(pendingContent) ? pendingContent : [pendingContent]; +} + +function getToolResultsTotalCharsThreshold( + settings: ClearContextOnIdleSettings, +): number { + if (settings.toolResultsTotalCharsThreshold !== undefined) { + return settings.toolResultsTotalCharsThreshold; + } + if ((settings.toolResultsThresholdMinutes ?? 0) < 0) { + return -1; + } + return DEFAULT_TOOL_RESULTS_TOTAL_CHARS_THRESHOLD; +} + +function buildKeepRefs(refs: PartRef[], keepRecent: number): Set { + return new Set(refs.slice(-keepRecent).map(refKey)); +} + +function buildClearMap( + clearRefs: PartRef[], +): Map> { + const clearMap = new Map>(); + for (const ref of clearRefs) { + let parts = clearMap.get(ref.contentIndex); + if (!parts) { + parts = new Map(); + clearMap.set(ref.contentIndex, parts); + } + parts.set(ref.partIndex, ref.kind); + } + return clearMap; +} + +interface SizeClearPlan { + clearRefs: PartRef[]; + toolRefs: PartRef[]; + keepToolRefs: Set; + toolResultCharsBefore: number; + toolResultCharsAfter: number; + pendingToolResultChars: number; + toolResultsTotalCharsThreshold: number; +} + +function planSizeBasedClearing( + history: Content[], + settings: ClearContextOnIdleSettings, + keepRecent: number, + pendingContent: Content | Content[] | undefined, +): SizeClearPlan | null { + const threshold = getToolResultsTotalCharsThreshold(settings); + if (!Number.isFinite(threshold) || threshold < 0) { + return null; + } + + const pending = normalizePendingContent(pendingContent); + const virtualHistory = + pending.length > 0 ? [...history, ...pending] : history; + const { tool } = collectCompactablePartRefs(virtualHistory); + const charsByRef = new Map(); + let totalChars = 0; + let pendingChars = 0; + for (const ref of tool) { + const chars = getToolOutputChars(getPart(virtualHistory, ref)); + if (chars <= 0) continue; + charsByRef.set(refKey(ref), chars); + totalChars += chars; + if (ref.contentIndex >= history.length) { + pendingChars += chars; + } + } + if (totalChars <= threshold) { + return null; + } + + const keepToolRefs = buildKeepRefs(tool, keepRecent); + const clearRefs: PartRef[] = []; + let remainingChars = totalChars; + for (const ref of tool) { + if (remainingChars <= threshold) break; + + const key = refKey(ref); + const chars = charsByRef.get(key) ?? 0; + if ( + chars <= 0 || + ref.contentIndex >= history.length || + keepToolRefs.has(key) + ) { + continue; + } + + clearRefs.push(ref); + remainingChars -= chars; + } + + return { + clearRefs, + toolRefs: tool, + keepToolRefs, + toolResultCharsBefore: totalChars, + toolResultCharsAfter: remainingChars - pendingChars, + pendingToolResultChars: pendingChars, + toolResultsTotalCharsThreshold: threshold, + }; +} + // --- Main entry point --- +export type MicrocompactTriggerReason = 'force' | 'idle' | 'size'; + +export interface MicrocompactOptions { + force?: boolean; + sizeOnly?: boolean; + pendingContent?: Content | Content[]; +} + export interface MicrocompactMeta { + triggerReason: MicrocompactTriggerReason; gapMinutes: number; thresholdMinutes: number; + toolResultCharsBefore?: number; + toolResultCharsAfter?: number; + pendingToolResultChars?: number; + toolResultsTotalCharsThreshold?: 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). */ @@ -263,11 +409,13 @@ export interface MicrocompactMeta { } /** - * Microcompact history: clear old compactable tool results when the - * time-based trigger fires. + * Microcompact history: clear old compactable tool results and media when the + * idle/force trigger fires, or clear old compactable tool results only when + * the cumulative tool-result size trigger fires. * - * Pass `opts.force: true` to skip the time-based trigger check and - * always run the clearing logic (used by `/compress-fast`). + * Pass `opts.force: true` to skip trigger checks and always run the full + * clearing logic (used by `/compress-fast`). Pass `opts.sizeOnly: true` with + * optional `pendingContent` for ToolResult turns. * * Returns the (potentially modified) history and optional metadata * about what was cleared (for logging by the caller). @@ -276,16 +424,8 @@ export function microcompactHistory( history: Content[], lastApiCompletionTimestamp: number | null, settings: ClearContextOnIdleSettings, - opts?: { force?: boolean }, + opts?: MicrocompactOptions, ): { history: Content[]; meta?: MicrocompactMeta } { - const trigger = opts?.force - ? { gapMs: 0 } - : evaluateTimeBasedTrigger(lastApiCompletionTimestamp, settings); - if (!trigger) { - return { history }; - } - const { gapMs } = trigger; - const envKeep = process.env['QWEN_MC_KEEP_RECENT']; const rawKeepRecent = envKeep !== undefined && Number.isFinite(Number(envKeep)) @@ -295,133 +435,179 @@ export function microcompactHistory( ? Math.max(1, rawKeepRecent) : 5; - const { tool, media, nestedMedia } = collectCompactablePartRefs(history); - // 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 - // threshold for "tool results". - const refKey = (r: PartRef) => `${r.contentIndex}:${r.partIndex}`; - const keepRefs = new Set([ - ...tool.slice(-keepRecent).map(refKey), - ...media.slice(-keepRecent).map(refKey), - ...nestedMedia.slice(-keepRecent).map(refKey), - ]); - const allRefs: PartRef[] = [...tool, ...media, ...nestedMedia]; - const clearRefs = allRefs.filter((r) => !keepRefs.has(refKey(r))); - - if (clearRefs.length === 0) { - return { history }; + let triggerReason: MicrocompactTriggerReason | undefined; + let gapMs = 0; + let tool: PartRef[] = []; + let media: PartRef[] = []; + let nestedMedia: PartRef[] = []; + let keepRefs = new Set(); + let clearRefs: PartRef[] = []; + let toolResultCharsBefore: number | undefined; + let toolResultCharsAfter: number | undefined; + let pendingToolResultChars: number | undefined; + let toolResultsTotalCharsThreshold: number | undefined; + + if (opts?.force) { + triggerReason = 'force'; + } else if (!opts?.sizeOnly) { + const timeTrigger = evaluateTimeBasedTrigger( + lastApiCompletionTimestamp, + settings, + ); + if (timeTrigger) { + triggerReason = 'idle'; + gapMs = timeTrigger.gapMs; + } } - // Build a lookup: contentIndex → Map of partIndex → kind - const clearMap = new Map>(); - for (const ref of clearRefs) { - let parts = clearMap.get(ref.contentIndex); - if (!parts) { - parts = new Map(); - clearMap.set(ref.contentIndex, parts); + if (triggerReason === 'force' || triggerReason === 'idle') { + ({ tool, media, nestedMedia } = collectCompactablePartRefs(history)); + // 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 + // threshold for "tool results". + keepRefs = new Set([ + ...tool.slice(-keepRecent).map(refKey), + ...media.slice(-keepRecent).map(refKey), + ...nestedMedia.slice(-keepRecent).map(refKey), + ]); + const allRefs: PartRef[] = [...tool, ...media, ...nestedMedia]; + clearRefs = allRefs.filter((r) => !keepRefs.has(refKey(r))); + } else { + const sizePlan = planSizeBasedClearing( + history, + settings, + keepRecent, + opts?.pendingContent, + ); + if (!sizePlan) { + return { history }; } - parts.set(ref.partIndex, ref.kind); + triggerReason = 'size'; + tool = sizePlan.toolRefs.filter((r) => r.contentIndex < history.length); + keepRefs = sizePlan.keepToolRefs; + clearRefs = sizePlan.clearRefs; + toolResultCharsBefore = sizePlan.toolResultCharsBefore; + toolResultCharsAfter = sizePlan.toolResultCharsAfter; + pendingToolResultChars = sizePlan.pendingToolResultChars; + toolResultsTotalCharsThreshold = sizePlan.toolResultsTotalCharsThreshold; + } + + if (clearRefs.length === 0 && triggerReason !== 'size') { + return { history }; } - const callIdToFilePath = buildCallIdToFilePath(history); const evictedReadPaths = new Set(); let unresolvedEvictedReads = 0; let tokensSaved = 0; let toolsCleared = 0; let mediaCleared = 0; - - const result: Content[] = history.map((content, ci) => { - const partsToClean = clearMap.get(ci); - if (!partsToClean || !content.parts) return content; - - let touched = false; - const newParts = content.parts.map((part, pi) => { - const kind = partsToClean.get(pi); - if (kind === undefined) return part; - if (isAlreadyCleared(part)) return part; - - if ( - kind === 'tool' && - part.functionResponse?.name && - COMPACTABLE_TOOLS.has(part.functionResponse.name) && - !isErrorResponse(part) - ) { - tokensSaved += estimatePartTokens(part); - toolsCleared++; - touched = true; - // Record the blanked file's path so the caller disarms its - // fast-path; if unrecoverable, count it so the caller falls - // back to the blanket wipe (issue #4239). - if (FILE_PATH_TOOLS.has(part.functionResponse.name)) { - const filePaths = part.functionResponse.id - ? callIdToFilePath.get(part.functionResponse.id) - : undefined; - if (filePaths && filePaths.length > 0) { - for (const p of filePaths) evictedReadPaths.add(p); - } else { - unresolvedEvictedReads++; + let result = history; + + if (clearRefs.length > 0) { + const clearMap = buildClearMap(clearRefs); + const callIdToFilePath = buildCallIdToFilePath(history); + + result = history.map((content, ci) => { + const partsToClean = clearMap.get(ci); + if (!partsToClean || !content.parts) return content; + + let touched = false; + const newParts = content.parts.map((part, pi) => { + const kind = partsToClean.get(pi); + if (kind === undefined) return part; + if (isAlreadyCleared(part)) return part; + + if ( + kind === 'tool' && + part.functionResponse?.name && + COMPACTABLE_TOOLS.has(part.functionResponse.name) && + !isErrorResponse(part) + ) { + tokensSaved += estimatePartTokens(part); + toolsCleared++; + touched = true; + // Record the blanked file's path so the caller disarms its + // fast-path; if unrecoverable, count it so the caller falls + // back to the blanket wipe (issue #4239). + if (FILE_PATH_TOOLS.has(part.functionResponse.name)) { + const filePaths = part.functionResponse.id + ? callIdToFilePath.get(part.functionResponse.id) + : undefined; + if (filePaths && filePaths.length > 0) { + for (const p of filePaths) evictedReadPaths.add(p); + } else { + unresolvedEvictedReads++; + } } + return { + functionResponse: { + ...stripNestedMedia(part.functionResponse), + response: { output: MICROCOMPACT_CLEARED_MESSAGE }, + }, + }; } - return { - functionResponse: { - ...stripNestedMedia(part.functionResponse), - response: { output: MICROCOMPACT_CLEARED_MESSAGE }, - }, - }; - } - if ( - kind === 'nested-media' && - part.functionResponse && - !isErrorResponse(part) - ) { - // Non-compactable tool result: keep response.output, drop only - // the nested media on functionResponse.parts. - tokensSaved += estimatePartTokens(part); - mediaCleared++; - touched = true; - return { - functionResponse: stripNestedMedia(part.functionResponse), - }; - } + if ( + kind === 'nested-media' && + part.functionResponse && + !isErrorResponse(part) + ) { + // Non-compactable tool result: keep response.output, drop only + // the nested media on functionResponse.parts. + tokensSaved += estimatePartTokens(part); + mediaCleared++; + touched = true; + return { + functionResponse: stripNestedMedia(part.functionResponse), + }; + } - if (kind === 'media' && (part.inlineData || part.fileData)) { - const mime = - part.inlineData?.mimeType ?? - part.fileData?.mimeType ?? - 'application/octet-stream'; - tokensSaved += estimatePartTokens(part); - mediaCleared++; - touched = true; - return { - text: `${MICROCOMPACT_CLEARED_IMAGE_PREFIX} ${sanitizeMimeForPlaceholder(mime)}]`, - }; - } + if (kind === 'media' && (part.inlineData || part.fileData)) { + const mime = + part.inlineData?.mimeType ?? + part.fileData?.mimeType ?? + 'application/octet-stream'; + tokensSaved += estimatePartTokens(part); + mediaCleared++; + touched = true; + return { + text: `${MICROCOMPACT_CLEARED_IMAGE_PREFIX} ${sanitizeMimeForPlaceholder(mime)}]`, + }; + } - return part; - }); + return part; + }); - if (!touched) return content; - return { ...content, parts: newParts }; - }); + if (!touched) return content; + return { ...content, parts: newParts }; + }); + } - if (tokensSaved === 0) { + if (tokensSaved === 0 && triggerReason !== 'size') { return { history }; } const thresholdMinutes = settings.toolResultsThresholdMinutes ?? 60; // Only count items that were actually protected by keepRecent, not // already-cleared items that were skipped during the clearing pass. - const toolsKept = Math.min(tool.length, keepRecent); - const mediaKept = Math.min(media.length + nestedMedia.length, keepRecent); + const toolsKept = tool.filter((r) => keepRefs.has(refKey(r))).length; + const mediaKept = + triggerReason === 'size' + ? 0 + : Math.min(media.length + nestedMedia.length, keepRecent); return { history: result, meta: { + triggerReason, gapMinutes: Math.round(gapMs / 60_000), thresholdMinutes, + toolResultCharsBefore, + toolResultCharsAfter, + pendingToolResultChars, + toolResultsTotalCharsThreshold, toolsCleared, mediaCleared, toolsKept, diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 25414687658..d241af09f48 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -591,7 +591,7 @@ "default": false }, "clearContextOnIdle": { - "description": "Settings for clearing stale context after idle periods. Use -1 to disable a threshold.", + "description": "Settings for clearing stale or oversized tool result context. Use -1 to disable a threshold.", "type": "object", "properties": { "toolResultsThresholdMinutes": { @@ -603,6 +603,11 @@ "description": "Number of most-recent compactable tool results to preserve when clearing. Floor at 1.", "type": "number", "default": 5 + }, + "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.", + "type": "number", + "default": 500000 } } },