Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,9 @@ The `extra_body` field allows you to add custom parameters to the request body s
| `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` |
| `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. | `500000` |

#### Troubleshooting File Search Performance

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/acp-integration/acpAgent.worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/config/settingsSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
13 changes: 12 additions & 1 deletion packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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: {
Expand All @@ -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.',
showInDialog: false,
},
},
},
fileFiltering: {
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/config/clearContextDefaults.ts
Original file line number Diff line number Diff line change
@@ -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;
55 changes: 55 additions & 0 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2865,6 +2865,61 @@ 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,
});
});
});

// PR 14b fix (codex round 4 — wenshao gpt-5.5 review): the
// `Config.setMcpBudgetEventCallback → pendingMcpBudgetCallback →
// createToolRegistry → registry.getMcpClientManager().setOnBudgetEvent`
Expand Down
25 changes: 20 additions & 5 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 === -1
? -1
: DEFAULT_TOOL_RESULTS_TOTAL_CHARS_THRESHOLD),
};
this.sessionTokenLimit = params.sessionTokenLimit ?? -1;
this.experimentalZedIntegration =
Expand Down
59 changes: 55 additions & 4 deletions packages/core/src/core/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}> {
Expand Down Expand Up @@ -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),
},
},
},
],
Expand Down Expand Up @@ -2588,7 +2596,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();
Expand All @@ -2609,12 +2617,55 @@ 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);
});

it('runs microcompaction on SendMessageType.Cron', async () => {
const { markReadEvictedFromHistory } = mockFileReadCacheStub();
const { history } = await makeReadFileResponses(6);
Expand Down
44 changes: 31 additions & 13 deletions packages/core/src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

// External dependencies
import { createUserContent } from '@google/genai';
import type {
Content,
GenerateContentConfig,
Expand All @@ -23,6 +24,7 @@ import { recordStartupEvent } from '../utils/startupEventSink.js';
import {
microcompactHistory,
type MicrocompactMeta,
type MicrocompactOptions,
} from '../services/microcompaction/microcompact.js';
import {
activeGoalEquals,
Expand Down Expand Up @@ -1567,14 +1569,16 @@ export class GeminiClient {
}
}

private async microcompactIdleHistory(
private async microcompactHistoryBeforeSend(
lastCompletionTimestamp: number | null,
opts?: MicrocompactOptions,
): Promise<boolean> {
try {
const mcResult = microcompactHistory(
this.getHistoryShallow(),
lastCompletionTimestamp,
this.config.getClearContextOnIdle(),
opts,
);
if (!mcResult.meta) {
return false;
Expand All @@ -1583,15 +1587,24 @@ export class GeminiClient {
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`,
);
if (m.triggerReason === 'size') {
debugLogger.debug(
Comment thread
doudouOUC marked this conversation as resolved.
Outdated
Comment thread
doudouOUC marked this conversation as resolved.
Outdated
`[TOOL-RESULT MC] tool result chars ${m.toolResultCharsBefore} > ` +
`${m.toolResultsTotalCharsThreshold}, cleared ${m.toolsCleared} ` +
`tool result(s) (~${m.tokensSaved} tokens), now ` +
`${m.toolResultCharsAfter}, kept ${m.toolsKept} tool result(s)`,
);
} else {
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;
} 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;
}
Expand Down Expand Up @@ -1815,10 +1828,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) {
Expand All @@ -1828,7 +1842,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();
}
}
Expand Down Expand Up @@ -2061,6 +2075,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());
Expand Down Expand Up @@ -2604,7 +2622,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,
Expand Down
Loading
Loading