diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index bdb6ee1a27b..d7b4b56dd8a 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -149,7 +149,7 @@ Settings are organized into categories. Most settings should be placed within th | `model.maxWallTimeSeconds` | number | Wall-clock budget for headless / unattended runs, in seconds. `-1` means unlimited. Overridable per-invocation via `--max-wall-time`, which requires a positive duration (`90`, `30s`, `5m`, `1h`, `1.5h`); the minimum is 1 second — sub-second values (`500ms`, `0.5`) are rejected as typos. Omit the flag to fall back to this setting. Aborts with exit code 55 when exceeded. | `-1` | | `model.maxToolCalls` | number | Cumulative tool-call budget for a run (counts every executed tool, success or failure; `structured_output` under `--json-schema` is exempt). `-1` means unlimited; `0` means "no tool calls allowed". Capped at 1,000,000 to catch typos. Overridable via `--max-tool-calls`. Aborts with exit code 55 when exceeded. | `-1` | | `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `enableCacheControl`, `splitToolMedia` (default `true`; splits tool-returned media — including images read by the built-in read_file — into a follow-up user message instead of the spec-violating `role: "tool"` message, so strict OpenAI-compatible servers like doubao / new-api / LM Studio can see it; set `false` to restore the legacy embed-in-tool behavior), `toolResultContentFormat` (default `"parts"`; set `"string"` only for legacy OpenAI-compatible runtimes whose tool templates ignore text content parts), `contextWindowSize` (override model's context window size), `modalities` (override auto-detected input modalities), `customHeaders` (custom HTTP headers for API requests), and `extra_body` (additional body parameters for OpenAI-compatible API requests only), along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` | -| `model.chatCompression.contextPercentageThreshold` | number | **REMOVED.** Auto-compaction now uses a three-tier threshold ladder (warn / auto / hard) computed internally from the model's context window via the `computeThresholds()` function — no longer user-configurable. Setting this field in `settings.json` is silently ignored (no startup warning). There is currently no replacement for "disable compression entirely" — reactive overflow recovery remains the safety net at the API layer if compression itself fails. (See PR #4345 / `docs/design/auto-compaction-threshold-redesign.md` for the redesign rationale.) | `N/A` | +| `model.chatCompression.contextPercentageThreshold` | number | **REMOVED.** Replaced by `context.autoCompactThreshold` (see `#### context` section below). Auto-compaction now uses a three-tier threshold ladder (warn / auto / hard) computed internally from the model's context window via the `computeThresholds()` function. The old setting is silently ignored (no startup warning). See PR #4345 / `docs/design/auto-compaction-threshold-redesign.md` for the redesign rationale. | `N/A` | | `model.chatCompression.maxRecentFilesToRetain` | number | Number of most-recently-touched files whose current content is restored (embedded if small, otherwise referenced by path) into history after auto-compaction. `0` restores none. Env override: `QWEN_COMPACT_MAX_RECENT_FILES`. | `5` | | `model.chatCompression.maxRecentImagesToRetain` | number | Number of most-recent images (tool screenshots / user pastes) restored into history after auto-compaction. `0` restores none. Env override: `QWEN_COMPACT_MAX_RECENT_IMAGES`. | `3` | | `model.chatCompression.enableScreenshotTrigger` | boolean | When `true`, auto-compaction also fires once the number of tool-returned images accumulated in history reaches `screenshotTriggerThreshold`, independent of token usage — aimed at computer-use sessions where frequent screenshots dilute model attention. Counts only images returned inside tool results, not user-pasted images. Env override: `QWEN_COMPACT_SCREENSHOT_TRIGGER` (`1`/`true`/`0`/`false`). | `true` | @@ -238,20 +238,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.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 | Fraction of the context window at which auto-compaction triggers. Must be greater than 0 and at most 1. Default is `0.7` (70%). For large context windows (>110K tokens), the absolute branch of the three-tier threshold system dominates, so values below ~0.7 may have no visible effect. Custom thresholds primarily affect small-window models (≤128K). Replaces the old `model.chatCompression.contextPercentageThreshold`. | `undefined` (uses internal 0.7) | +| `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` | #### Troubleshooting File Search Performance diff --git a/docs/users/features/hooks.md b/docs/users/features/hooks.md index bbed092bfca..3c78115a722 100644 --- a/docs/users/features/hooks.md +++ b/docs/users/features/hooks.md @@ -566,10 +566,15 @@ Hook output supports three categories of fields: ```json { "stop_hook_active": "boolean indicating if stop hook is active", - "last_assistant_message": "the last message from the assistant" + "last_assistant_message": "the last message from the assistant", + "context_usage": "ratio of context window used (may exceed 1 when tokens exceed window; optional)", + "context_limit": "context window size in tokens (optional)", + "input_tokens": "prompt token count (may include output tokens depending on provider; optional)" } ``` +The `context_usage`, `context_limit`, and `input_tokens` fields allow hook scripts to observe context usage and implement custom compact strategies — for example, a script that prints a reminder to run `/compact` when usage exceeds a custom threshold. + **Output Options**: - `decision`: "allow", "deny", "block", or "ask" diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 74bfc0a3666..bd9fb374ee9 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -57,6 +57,7 @@ import { firePreToolUseHook, firePostToolUseHook, firePostToolUseFailureHook, + buildContextUsage, injectPermissionRulesIfMissing, NotificationType, persistPermissionOutcome, @@ -102,6 +103,7 @@ import { dedupeToolCallsById, getProviderToolCallId, parsePositiveIntegerEnv, + DEFAULT_TOKEN_LIMIT, } from '@qwen-code/qwen-code-core'; import { NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE } from '@qwen-code/acp-bridge/bridgeErrors'; // Single source of truth shared with the daemon-side answerer (BridgeClient), @@ -1674,6 +1676,12 @@ export class Session implements SessionContext { this.#getCurrentChat().getLastModelMessageText?.() || '[no response text]'; + const contextUsage = buildContextUsage( + this.config.getContentGeneratorConfig()?.contextWindowSize ?? + DEFAULT_TOKEN_LIMIT, + this.lastPromptTokenCount, + ); + const response = await messageBus.request< HookExecutionRequest, HookExecutionResponse @@ -1684,6 +1692,7 @@ export class Session implements SessionContext { input: { stop_hook_active: true, last_assistant_message: responseText, + ...contextUsage, }, signal: pendingSend.signal, }, diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 3228e3d7ff4..21cd1eeba95 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -1968,6 +1968,7 @@ export async function loadCliConfig( cliVersion: await getCliVersion(), ideMode, chatCompression: settings.model?.chatCompression, + autoCompactThreshold: settings.context?.autoCompactThreshold, folderTrust, interactive, trustedFolder, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index fc5ce7cc57c..77e2f921ed1 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1667,6 +1667,21 @@ const SETTINGS_SCHEMA = { }, }, }, + autoCompactThreshold: { + type: 'number', + label: 'Auto-Compact Threshold', + category: 'Context', + requiresRestart: false, + default: undefined as number | undefined, + description: + 'Fraction of context window at which auto-compaction triggers (greater than 0, up to 1). Default is 0.7 (70%).', + showInDialog: false, + jsonSchemaOverride: { + type: 'number', + minimum: 0.01, + maximum: 1, + }, + }, }, }, diff --git a/packages/cli/src/ui/commands/contextCommand.test.ts b/packages/cli/src/ui/commands/contextCommand.test.ts index 951ca87a7f9..98a1b602568 100644 --- a/packages/cli/src/ui/commands/contextCommand.test.ts +++ b/packages/cli/src/ui/commands/contextCommand.test.ts @@ -49,6 +49,7 @@ function makeMockConfig(contextWindowSize = 32_000): Config { listSkills: vi.fn().mockResolvedValue([]), }), getChatCompression: vi.fn().mockReturnValue(undefined), + getAutoCompactThreshold: vi.fn(), } as unknown as Config; } @@ -74,6 +75,7 @@ describe('collectContextData (contextCommand)', () => { listSkills: vi.fn().mockResolvedValue([]), }), getChatCompression: vi.fn().mockReturnValue(undefined), + getAutoCompactThreshold: vi.fn(), } as unknown as Config; }); @@ -165,6 +167,7 @@ describe('collectContextData (contextCommand)', () => { listSkills: vi.fn().mockResolvedValue([]), }), getChatCompression: vi.fn().mockReturnValue(undefined), + getAutoCompactThreshold: vi.fn(), } as unknown as Config; const data = await collectContextData(config, true); @@ -247,4 +250,15 @@ describe('/context shows three-tier thresholds', () => { const text = formatContextUsageText(data); expect(text).not.toMatch(/Compaction thresholds/); }); + + it('propagates custom autoCompactThreshold through to /context thresholds', async () => { + // config.getAutoCompactThreshold() returns 0.5 → computeThresholds(32000, 0.5) + // = { warn: 16,000, auto: 16,000, hard: 19,000, effectiveWindow: 12,000 } + const config = makeMockConfig(32_000); + vi.mocked(config.getAutoCompactThreshold).mockReturnValue(0.5); + const data = await collectContextData(config, false); + + expect(data.breakdown.thresholds).toBeDefined(); + expect(data.breakdown.thresholds!.auto).toBe(16_000); + }); }); diff --git a/packages/cli/src/ui/commands/contextCommand.ts b/packages/cli/src/ui/commands/contextCommand.ts index 9f875aa019e..3e9df2ff4af 100644 --- a/packages/cli/src/ui/commands/contextCommand.ts +++ b/packages/cli/src/ui/commands/contextCommand.ts @@ -207,7 +207,10 @@ export async function collectContextData( const skillsTokens = skillToolDefinitionTokens + loadedBodiesTokens; - const thresholds = computeThresholds(contextWindowSize); + const thresholds = computeThresholds( + contextWindowSize, + config.getAutoCompactThreshold(), + ); // Keep the `(window - auto)` buffer for the legacy three-segment progress // bar in ContextUsage.tsx — it visualizes the headroom between the auto // threshold and the window edge, which is exactly `contextWindowSize - diff --git a/packages/cli/src/ui/hooks/useContextualTips.ts b/packages/cli/src/ui/hooks/useContextualTips.ts index 743d6f4945c..2ec7beb4a11 100644 --- a/packages/cli/src/ui/hooks/useContextualTips.ts +++ b/packages/cli/src/ui/hooks/useContextualTips.ts @@ -85,7 +85,10 @@ export function useContextualTips({ sessionPromptCount, sessionCount: tipHistory.sessionCount, platform: process.platform, - thresholds: computeThresholds(contextWindowSize), + thresholds: computeThresholds( + contextWindowSize, + config.getAutoCompactThreshold(), + ), }; const tip = selectTip('post-response', tipContext, tipRegistry, tipHistory); diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index d989188c084..a50a1d7c27c 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -4966,4 +4966,43 @@ describe('Model Switching and Config Updates', () => { expect(config.getAutoSkillConfirmEnabled()).toBe(false); }); }); + + describe('MCP Stop dispatch with context usage data', () => { + it('buildContextUsage handles MCP input patterns with runtime validation', async () => { + // Test the buildContextUsage function that's used in MCP Stop dispatch + // This validates the runtime type coercion and edge cases + const { buildContextUsage } = await import('../hooks/context-usage.js'); + + // Normal case: valid numbers + expect(buildContextUsage(128000, 64000)).toEqual({ + context_usage: 0.5, + context_limit: 128000, + input_tokens: 64000, + }); + + // Missing context_limit: returns undefined + expect(buildContextUsage(undefined, 64000)).toBeUndefined(); + + // Missing input_tokens (defaults to 0): returns undefined + expect(buildContextUsage(128000, 0)).toBeUndefined(); + + // Both missing: returns undefined + expect(buildContextUsage(undefined, 0)).toBeUndefined(); + + // String values (MCP might send strings): Number.isFinite rejects strings + // @ts-expect-error - testing runtime validation + expect(buildContextUsage('128000', 64000)).toBeUndefined(); + + // Invalid string values: returns undefined + // @ts-expect-error - testing runtime validation + expect(buildContextUsage('invalid', 64000)).toBeUndefined(); + + // Negative values: returns undefined + expect(buildContextUsage(-128000, 64000)).toBeUndefined(); + expect(buildContextUsage(128000, -64000)).toBeUndefined(); + + // Zero context_limit: returns undefined + expect(buildContextUsage(0, 64000)).toBeUndefined(); + }); + }); }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 642cc61d3fb..78650b4f364 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -101,6 +101,7 @@ import { BackgroundShellRegistry } from '../services/backgroundShellRegistry.js' import { WorkflowRunRegistry } from '../agents/workflow-run-registry.js'; import { FileReadCache } from '../services/fileReadCache.js'; import { resolveStopHookBlockingCap } from '../hooks/stopHookCap.js'; +import { buildContextUsage } from '../hooks/context-usage.js'; import { DEFAULT_OTLP_ENDPOINT, DEFAULT_SENSITIVE_SPAN_ATTRIBUTE_MAX_LENGTH, @@ -886,6 +887,7 @@ export interface ConfigParameters { importFormat?: 'tree' | 'flat'; chatRecording?: boolean; chatCompression?: ChatCompressionSettings; + autoCompactThreshold?: number; interactive?: boolean; trustedFolder?: boolean; defaultFileEncoding?: FileEncodingType; @@ -1332,6 +1334,7 @@ export class Config { private readonly loadMemoryFromIncludeDirectories: boolean = false; private readonly importFormat: 'tree' | 'flat'; private readonly chatCompression: ChatCompressionSettings | undefined; + private readonly autoCompactThreshold: number | undefined; private readonly interactive: boolean; private readonly trustedFolder: boolean | undefined; private readonly useRipgrep: boolean; @@ -1560,6 +1563,7 @@ export class Config { params.loadMemoryFromIncludeDirectories ?? false; this.importFormat = params.importFormat ?? 'tree'; this.chatCompression = params.chatCompression; + this.autoCompactThreshold = params.autoCompactThreshold; this.interactive = params.interactive ?? false; this.trustedFolder = params.trustedFolder; this.skipLoopDetection = params.skipLoopDetection ?? false; @@ -1745,9 +1749,16 @@ export class Config { ); break; case 'Stop': { + // Extract context usage data from input with runtime validation + const contextUsageData = buildContextUsage( + input['context_limit'] as number | undefined, + (input['input_tokens'] as number | undefined) ?? 0, + ); + const stopResult = await hookSystem.fireStopEvent( (input['stop_hook_active'] as boolean) || false, (input['last_assistant_message'] as string) || '', + contextUsageData, signal, ); result = stopResult.finalOutput @@ -4570,6 +4581,14 @@ export class Config { return this.chatCompression; } + getAutoCompactThreshold(): number | undefined { + const threshold = this.autoCompactThreshold; + if (typeof threshold === 'number' && threshold > 0 && threshold <= 1) { + return threshold; + } + return undefined; + } + isInteractive(): boolean { return this.interactive; } diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index b57446b734c..9dc60c13909 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -33,6 +33,8 @@ import { } from '../goals/activeGoalStore.js'; import { abortGoalForStopHookCap } from '../goals/goalHook.js'; import { formatStopHookBlockingCapWarning } from '../hooks/stopHookCap.js'; +import { buildContextUsage } from '../hooks/context-usage.js'; +import { DEFAULT_TOKEN_LIMIT } from './tokenLimits.js'; const debugLogger = createDebugLogger('CLIENT'); @@ -2281,6 +2283,12 @@ export class GeminiClient { const responseText = this.getLastModelMessageText() || '[no response text]'; + const contextUsage = buildContextUsage( + this.config.getContentGeneratorConfig()?.contextWindowSize ?? + DEFAULT_TOKEN_LIMIT, + uiTelemetryService.getLastPromptTokenCount(), + ); + const response = await messageBus.request< HookExecutionRequest, HookExecutionResponse @@ -2291,6 +2299,7 @@ export class GeminiClient { input: { stop_hook_active: true, last_assistant_message: responseText, + ...contextUsage, }, signal, }, diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 7befde96a2b..9c33a98ead1 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -173,6 +173,7 @@ describe('GeminiChat', async () => { getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator), getBaseLlmClient: vi.fn().mockReturnValue(undefined), getChatCompression: vi.fn().mockReturnValue(undefined), + getAutoCompactThreshold: vi.fn().mockReturnValue(undefined), getHookSystem: vi.fn().mockReturnValue(undefined), getDebugLogger: vi .fn() diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index ae969640367..ca67c5f1a2a 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -1766,7 +1766,10 @@ export class GeminiChat { const contextLimit = this.config.getContentGeneratorConfig()?.contextWindowSize ?? DEFAULT_TOKEN_LIMIT; - const { hard } = computeThresholds(contextLimit); + const { hard } = computeThresholds( + contextLimit, + this.config.getAutoCompactThreshold(), + ); const imageTokenEstimate = resolveSlimmingConfig( this.config.getChatCompression(), ).imageTokenEstimate; diff --git a/packages/core/src/hooks/context-usage.test.ts b/packages/core/src/hooks/context-usage.test.ts new file mode 100644 index 00000000000..9b135888beb --- /dev/null +++ b/packages/core/src/hooks/context-usage.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { buildContextUsage } from './context-usage.js'; + +describe('buildContextUsage', () => { + it('returns context usage data when both values are valid', () => { + const result = buildContextUsage(200_000, 140_000); + expect(result).toEqual({ + context_usage: 0.7, + context_limit: 200_000, + input_tokens: 140_000, + }); + }); + + it('returns undefined when contextWindowSize is undefined', () => { + expect(buildContextUsage(undefined, 140_000)).toBeUndefined(); + }); + + it('returns undefined when contextWindowSize is 0', () => { + expect(buildContextUsage(0, 140_000)).toBeUndefined(); + }); + + it('returns undefined when inputTokens is 0', () => { + expect(buildContextUsage(200_000, 0)).toBeUndefined(); + }); + + it('returns undefined when inputTokens is negative', () => { + expect(buildContextUsage(200_000, -5)).toBeUndefined(); + }); + + it('returns undefined when inputTokens is NaN', () => { + expect(buildContextUsage(200_000, NaN)).toBeUndefined(); + }); + + it('returns undefined when contextWindowSize is negative', () => { + expect(buildContextUsage(-1, 140_000)).toBeUndefined(); + }); + + it('handles ratio > 1 (tokens exceed window)', () => { + const result = buildContextUsage(100_000, 120_000); + expect(result?.context_usage).toBe(1.2); + }); +}); diff --git a/packages/core/src/hooks/context-usage.ts b/packages/core/src/hooks/context-usage.ts new file mode 100644 index 00000000000..2c6afeccd47 --- /dev/null +++ b/packages/core/src/hooks/context-usage.ts @@ -0,0 +1,21 @@ +import type { ContextUsageData } from './types.js'; + +export function buildContextUsage( + contextWindowSize: number | undefined, + inputTokens: number, +): ContextUsageData | undefined { + if ( + !contextWindowSize || + !Number.isFinite(contextWindowSize) || + contextWindowSize <= 0 || + !Number.isFinite(inputTokens) || + inputTokens <= 0 + ) { + return undefined; + } + return { + context_usage: inputTokens / contextWindowSize, + context_limit: contextWindowSize, + input_tokens: inputTokens, + }; +} diff --git a/packages/core/src/hooks/hookEventHandler.test.ts b/packages/core/src/hooks/hookEventHandler.test.ts index 568922e2b38..9f86b8ecb66 100644 --- a/packages/core/src/hooks/hookEventHandler.test.ts +++ b/packages/core/src/hooks/hookEventHandler.test.ts @@ -317,6 +317,70 @@ describe('HookEventHandler', () => { expect(input.last_assistant_message).toBe('last assistant message'); }); + it('should include context usage fields when provided', async () => { + const mockPlan = createMockExecutionPlan([ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ]); + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + createMockAggregatedResult(true), + ); + + const contextUsage = { + context_usage: 0.75, + context_limit: 200000, + input_tokens: 150000, + }; + await hookEventHandler.fireStopEvent(true, 'msg', contextUsage); + + const mockCalls = (mockHookRunner.executeHooksParallel as Mock).mock + .calls; + const input = mockCalls[0][2] as { + stop_hook_active: boolean; + last_assistant_message: string; + context_usage?: number; + context_limit?: number; + input_tokens?: number; + }; + expect(input.stop_hook_active).toBe(true); + expect(input.context_usage).toBe(0.75); + expect(input.context_limit).toBe(200000); + expect(input.input_tokens).toBe(150000); + }); + + it('should omit context usage fields when not provided', async () => { + const mockPlan = createMockExecutionPlan([ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ]); + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + createMockAggregatedResult(true), + ); + + await hookEventHandler.fireStopEvent(true, 'msg'); + + const mockCalls = (mockHookRunner.executeHooksParallel as Mock).mock + .calls; + const input = mockCalls[0][2] as { + context_usage?: number; + context_limit?: number; + input_tokens?: number; + }; + expect(input.context_usage).toBeUndefined(); + expect(input.context_limit).toBeUndefined(); + expect(input.input_tokens).toBeUndefined(); + }); + it('should handle continue=false in final output', async () => { const mockPlan = createMockExecutionPlan([]); vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); diff --git a/packages/core/src/hooks/hookEventHandler.ts b/packages/core/src/hooks/hookEventHandler.ts index 2385af12d61..17f7c097f4c 100644 --- a/packages/core/src/hooks/hookEventHandler.ts +++ b/packages/core/src/hooks/hookEventHandler.ts @@ -18,6 +18,7 @@ import type { UserPromptSubmitInput, UserPromptExpansionInput, StopInput, + ContextUsageData, SessionStartInput, SessionEndInput, SessionStartSource, @@ -188,12 +189,14 @@ export class HookEventHandler { async fireStopEvent( stopHookActive: boolean = false, lastAssistantMessage: string = '', + contextUsage?: ContextUsageData, signal?: AbortSignal, ): Promise { const input: StopInput = { ...this.createBaseInput(HookEventName.Stop), stop_hook_active: stopHookActive, last_assistant_message: lastAssistantMessage, + ...contextUsage, }; return this.executeHooks(HookEventName.Stop, input, undefined, signal); diff --git a/packages/core/src/hooks/hookSystem.test.ts b/packages/core/src/hooks/hookSystem.test.ts index 2ff10561782..0ccfb09e82c 100644 --- a/packages/core/src/hooks/hookSystem.test.ts +++ b/packages/core/src/hooks/hookSystem.test.ts @@ -295,6 +295,7 @@ describe('HookSystem', () => { true, 'last message', undefined, + undefined, ); expect(result).toEqual(mockResult); }); @@ -317,7 +318,40 @@ describe('HookSystem', () => { false, '', undefined, + undefined, + ); + }); + + it('should forward context usage to hookEventHandler', async () => { + const mockResult = { + success: true, + allOutputs: [], + errors: [], + totalDuration: 50, + finalOutput: undefined, + }; + vi.mocked(mockHookEventHandler.fireStopEvent).mockResolvedValue( + mockResult, + ); + + const contextUsage = { + context_usage: 0.75, + context_limit: 200000, + input_tokens: 150000, + }; + const result = await hookSystem.fireStopEvent( + true, + 'last message', + contextUsage, + ); + + expect(mockHookEventHandler.fireStopEvent).toHaveBeenCalledWith( + true, + 'last message', + contextUsage, + undefined, ); + expect(result).toEqual(mockResult); }); it('should return AggregatedHookResult even when no final output', async () => { diff --git a/packages/core/src/hooks/hookSystem.ts b/packages/core/src/hooks/hookSystem.ts index 5bbd2273854..dbf4e5940c8 100644 --- a/packages/core/src/hooks/hookSystem.ts +++ b/packages/core/src/hooks/hookSystem.ts @@ -12,7 +12,11 @@ import { HookPlanner } from './hookPlanner.js'; import { HookEventHandler } from './hookEventHandler.js'; import type { HookRegistryEntry } from './hookRegistry.js'; import { createDebugLogger } from '../utils/debugLogger.js'; -import type { DefaultHookOutput, HookPhase } from './types.js'; +import type { + ContextUsageData, + DefaultHookOutput, + HookPhase, +} from './types.js'; import { createHookOutput, PermissionMode } from './types.js'; import type { SessionStartSource, @@ -200,11 +204,13 @@ export class HookSystem { async fireStopEvent( stopHookActive: boolean = false, lastAssistantMessage: string = '', + contextUsage?: ContextUsageData, signal?: AbortSignal, ): Promise { return this.hookEventHandler.fireStopEvent( stopHookActive, lastAssistantMessage, + contextUsage, signal, ); } diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts index ffe60dfa283..55dc33d4dcb 100644 --- a/packages/core/src/hooks/types.ts +++ b/packages/core/src/hooks/types.ts @@ -859,10 +859,19 @@ export interface NotificationOutput extends HookOutput { }; } +/** + * Context usage data included in Stop hook stdin payload + */ +export interface ContextUsageData { + context_usage: number; + context_limit: number; + input_tokens: number; +} + /** * Stop hook input */ -export interface StopInput extends HookInput { +export interface StopInput extends HookInput, Partial { stop_hook_active: boolean; last_assistant_message: string; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0b5bcad30a9..81110a73a5d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -510,6 +510,7 @@ export { formatStopHookBlockingCapWarning, } from './hooks/stopHookCap.js'; export { type StopFailureErrorType } from './hooks/types.js'; +export { buildContextUsage } from './hooks/context-usage.js'; // ============================================================================ // Goals (/goal command runtime) diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index 789f0ebea72..942dd042405 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -48,6 +48,7 @@ describe('ChatCompressionService', () => { mockGetHookSystem = vi.fn().mockReturnValue({}); mockConfig = { getChatCompression: vi.fn(), + getAutoCompactThreshold: vi.fn(), getBaseLlmClient: vi.fn(), getContentGeneratorConfig: vi.fn().mockReturnValue({}), getHookSystem: mockGetHookSystem, @@ -1863,6 +1864,7 @@ describe('ChatCompressionService.compress sideQuery config', () => { } as unknown as GeminiChat; const mockConfig = { getChatCompression: vi.fn(), + getAutoCompactThreshold: vi.fn(), getBaseLlmClient: vi.fn(), getContentGeneratorConfig: vi .fn() @@ -1928,6 +1930,7 @@ describe('ChatCompressionService.compress sideQuery config', () => { const warn = vi.fn(); const mockConfig = { getChatCompression: vi.fn(), + getAutoCompactThreshold: vi.fn(), getBaseLlmClient: vi.fn(), getContentGeneratorConfig: vi .fn() @@ -1986,6 +1989,7 @@ describe('ChatCompressionService.compress cheap-gate uses estimated tokens', () function makeFakeConfig(opts: { contextWindowSize: number }): Config { return { getChatCompression: vi.fn(), + getAutoCompactThreshold: vi.fn(), getBaseLlmClient: vi.fn(), getContentGeneratorConfig: vi .fn() @@ -2127,6 +2131,87 @@ describe('computeThresholds', () => { expect(t.auto).toBeLessThan(t.hard); } }); + + describe('custom pct parameter', () => { + it('uses DEFAULT_PCT when pct is not provided', () => { + const defaultResult = computeThresholds(32_000); + const explicitDefault = computeThresholds(32_000, 0.7); + expect(explicitDefault).toEqual(defaultResult); + }); + + it('custom pct=0.5 lowers proportional auto threshold for small windows', () => { + const t = computeThresholds(32_000, 0.5); + expect(t.auto).toBe(16_000); // 0.5 * 32K + expect(t.warn).toBe(12_800); // (0.5 - 0.1) * 32K + }); + + it('custom pct=0.9 raises proportional auto threshold for small windows', () => { + const t = computeThresholds(32_000, 0.9); + expect(t.auto).toBe(28_800); // 0.9 * 32K + expect(t.warn).toBe(25_600); // (0.9 - 0.1) * 32K + }); + + it('custom pct does not affect absolute-branch-dominated large windows', () => { + const defaultResult = computeThresholds(1_000_000); + const customPct = computeThresholds(1_000_000, 0.5); + // For 1M window, absolute branch dominates regardless of pct + expect(customPct.auto).toBe(defaultResult.auto); + expect(customPct.hard).toBe(defaultResult.hard); + }); + + it('custom pct preserves warn <= auto < hard invariant', () => { + for (const pct of [0.3, 0.5, 0.6, 0.8, 0.9]) { + for (const w of [10_000, 32_000, 128_000, 200_000]) { + const t = computeThresholds(w, pct); + expect(t.warn).toBeLessThanOrEqual(t.auto); + expect(t.auto).toBeLessThan(t.hard); + } + } + }); + + it('pct=0 produces auto=0 for small windows (proportional branch is 0)', () => { + const t = computeThresholds(32_000, 0); + // 0 * 32000 = 0, absolute branch is negative → auto = 0 + expect(t.auto).toBe(0); + // warn = max((0 - 0.1) * 32000, absWarn) = -3200 + expect(t.warn).toBeLessThanOrEqual(t.auto); + // hard is clamped to max(rawHard, auto + HARD_BUFFER) + expect(t.hard).toBeGreaterThan(t.auto); + }); + + it('pct=1 sets proportional auto to full window; hard may equal auto for small windows', () => { + const t = computeThresholds(32_000, 1); + expect(t.auto).toBe(32_000); + expect(t.warn).toBeLessThanOrEqual(t.auto); + // For 32K window: hard = min(32000, max(effectiveWindow - HARD_BUFFER, 32000 + HARD_BUFFER)) = 32000 + expect(t.hard).toBeLessThanOrEqual(t.auto); + }); + + it('pct=1 with large window: auto and hard both equal window', () => { + const t = computeThresholds(200_000, 1); + expect(t.auto).toBe(200_000); + expect(t.hard).toBe(200_000); + expect(t.warn).toBeLessThanOrEqual(t.auto); + }); + + it('clamps negative pct to 0', () => { + expect(computeThresholds(32_000, -0.5)).toEqual( + computeThresholds(32_000, 0), + ); + }); + + it('clamps pct > 1 to 1', () => { + expect(computeThresholds(32_000, 1.5)).toEqual( + computeThresholds(32_000, 1), + ); + }); + + it('NaN pct falls back to DEFAULT_PCT (via Number.isFinite check)', () => { + expect(computeThresholds(32_000, NaN)).toEqual( + computeThresholds(32_000), // no pct arg = DEFAULT_PCT + ); + }); + }); }); describe('ChatCompressionService.compress — claude-code-style full-history compression', () => { @@ -2145,6 +2230,7 @@ describe('ChatCompressionService.compress — claude-code-style full-history com function makeFakeConfig(): Config { return { getChatCompression: vi.fn(), + getAutoCompactThreshold: vi.fn(), getBaseLlmClient: vi.fn(), getContentGeneratorConfig: vi .fn() @@ -2255,6 +2341,7 @@ describe('ChatCompressionService.compress cheap-gate uses computeThresholds.auto function makeFakeConfig(opts: { contextWindowSize: number }): Config { return { getChatCompression: vi.fn(), + getAutoCompactThreshold: vi.fn(), getBaseLlmClient: vi.fn(), getContentGeneratorConfig: vi .fn() @@ -2312,6 +2399,51 @@ describe('ChatCompressionService.compress cheap-gate uses computeThresholds.auto expect(spy).toHaveBeenCalled(); expect(result.info.compressionStatus).not.toBe(CompressionStatus.NOOP); }); + + it('with custom threshold 0.5, triggers compression at lower token count (32K window)', async () => { + const spy = vi + .spyOn(sideQueryModule, 'runSideQuery') + .mockResolvedValue({ text: 's', usage: {} } as never); + + const config = makeFakeConfig({ contextWindowSize: 32_000 }); + vi.mocked(config.getAutoCompactThreshold).mockReturnValue(0.5); + + // computeThresholds(32000, 0.5).auto = max(0.5*32000, 12000-13000) = 16000 + // 20K > 16K → falls through cheap-gate + const result = await new ChatCompressionService().compress(makeFakeChat(), { + promptId: 'p', + force: false, + model: 'qwen-test', + config, + consecutiveFailures: 0, + originalTokenCount: 20_000, + }); + + expect(spy).toHaveBeenCalled(); + expect(result.info.compressionStatus).not.toBe(CompressionStatus.NOOP); + }); + + it('with default threshold, NOOPs at same token count (32K window, 20K tokens)', async () => { + const spy = vi + .spyOn(sideQueryModule, 'runSideQuery') + .mockResolvedValue({ text: 's', usage: {} } as never); + + const config = makeFakeConfig({ contextWindowSize: 32_000 }); + // getAutoCompactThreshold returns undefined → default 0.7 + // computeThresholds(32000).auto = max(0.7*32000, 12000-13000) = 22400 + // 20K < 22.4K → NOOP + const result = await new ChatCompressionService().compress(makeFakeChat(), { + promptId: 'p', + force: false, + model: 'qwen-test', + config, + consecutiveFailures: 0, + originalTokenCount: 20_000, + }); + + expect(spy).not.toHaveBeenCalled(); + expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP); + }); }); describe('ChatCompressionService.compress — single-turn computer-use regression', () => { @@ -2330,6 +2462,7 @@ describe('ChatCompressionService.compress — single-turn computer-use regressio function makeFakeConfig(): Config { return { getChatCompression: vi.fn(), + getAutoCompactThreshold: vi.fn(), getBaseLlmClient: vi.fn(), getContentGeneratorConfig: vi .fn() @@ -2490,6 +2623,7 @@ describe('ChatCompressionService.compress — customInstructions plumbing', () = }; const mockConfig = { getChatCompression: vi.fn(), + getAutoCompactThreshold: vi.fn(), getBaseLlmClient: vi.fn(), getContentGeneratorConfig: vi .fn() @@ -2547,6 +2681,7 @@ describe('ChatCompressionService.compress — customInstructions plumbing', () = } as unknown as GeminiChat; const mockConfig = { getChatCompression: vi.fn(), + getAutoCompactThreshold: vi.fn(), getBaseLlmClient: vi.fn(), getContentGeneratorConfig: vi .fn() @@ -2775,6 +2910,7 @@ describe('ChatCompressionService.compress — plan-mode + subagent attachment wi } as unknown as GeminiChat; const mockConfig = { getChatCompression: vi.fn(), + getAutoCompactThreshold: vi.fn(), getBaseLlmClient: vi.fn(), getContentGeneratorConfig: vi .fn() @@ -2970,6 +3106,7 @@ describe('ChatCompressionService.compress — plan-mode + subagent attachment wi } as unknown as GeminiChat; const mockConfig = { getChatCompression: vi.fn(), + getAutoCompactThreshold: vi.fn(), getBaseLlmClient: vi.fn(), getContentGeneratorConfig: vi .fn() diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 3a84726f503..2cf254b55cb 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -146,18 +146,25 @@ export interface CompactionThresholds { * Compute the three-tier threshold ladder for a given context window. * * Each tier is `max(proportional, absolute)`: - * auto = max(DEFAULT_PCT * window, effectiveWindow - AUTOCOMPACT_BUFFER) - * warn = max((DEFAULT_PCT - WARN_PCT_OFFSET) * window, auto - WARN_BUFFER) + * auto = max(pct * window, effectiveWindow - AUTOCOMPACT_BUFFER) + * warn = max(0, max((pct - WARN_PCT_OFFSET) * window, auto - WARN_BUFFER)) * hard = min(window, max(effectiveWindow - HARD_BUFFER, auto + HARD_BUFFER)) * - * Small windows (where the absolute branch goes negative) automatically - * fall back to the proportional branch. Large windows are dominated by - * the absolute branch, capping wasted reservation to ~33K instead of 30% - * of the window. + * `pct` defaults to DEFAULT_PCT when not provided. Small windows (where + * the absolute branch goes negative) automatically fall back to the + * proportional branch. Large windows are dominated by the absolute branch, + * capping wasted reservation to ~33K instead of 30% of the window. * * Pure function — no I/O, no shared state — safe to call repeatedly. */ -export function computeThresholds(window: number): CompactionThresholds { +export function computeThresholds( + window: number, + pct?: number, +): CompactionThresholds { + const effectivePct = Math.min( + 1, + Math.max(0, pct !== undefined && Number.isFinite(pct) ? pct : DEFAULT_PCT), + ); // Clamp to 0 for tiny windows (window < SUMMARY_RESERVE) so the surfaced // value in `/context` stays meaningful. The Math.max guards on auto/warn/hard // below absorb the floor — clamping does not shift those outputs because @@ -166,13 +173,18 @@ export function computeThresholds(window: number): CompactionThresholds { const effectiveWindow = Math.max(0, window - SUMMARY_RESERVE); const absAuto = effectiveWindow - AUTOCOMPACT_BUFFER; - const auto = Math.max(DEFAULT_PCT * window, absAuto); + const auto = Math.max(effectivePct * window, absAuto); const absWarn = auto - WARN_BUFFER; - const warn = Math.max((DEFAULT_PCT - WARN_PCT_OFFSET) * window, absWarn); + const warn = Math.max( + 0, + Math.max((effectivePct - WARN_PCT_OFFSET) * window, absWarn), + ); const rawHard = effectiveWindow - HARD_BUFFER; - // Guarantee hard > auto so compaction doesn't wait until the last moment. + // Guarantee hard >= auto so compaction doesn't wait until the last moment. + // When pct=1, auto equals the full window and hard collapses to auto + // (degenerate case: both thresholds trigger simultaneously). // For tiny/zero windows where auto is already at the proportional floor, // clamp hard to the window itself so it never exceeds the actual limit. const hard = Math.min(window, Math.max(rawHard, auto + HARD_BUFFER)); @@ -340,7 +352,10 @@ export class ChatCompressionService { const contextLimit = config.getContentGeneratorConfig()?.contextWindowSize ?? DEFAULT_TOKEN_LIMIT; - const { auto } = computeThresholds(contextLimit); + const { auto } = computeThresholds( + contextLimit, + config.getAutoCompactThreshold(), + ); // Order of preference for the effective-token estimate: // 1. Caller already computed it (sendMessageStream hard-tier rescue) // 2. Compute it here from history + pending user message diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index bb474fcc5fe..e5598894b14 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -753,6 +753,12 @@ "default": true } } + }, + "autoCompactThreshold": { + "type": "number", + "minimum": 0.01, + "maximum": 1, + "description": "Fraction of context window at which auto-compaction triggers (greater than 0, up to 1). Default is 0.7 (70%)." } } },