Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
31 changes: 16 additions & 15 deletions docs/users/configuration/settings.md

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion docs/users/features/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -566,10 +566,15 @@ Hook output supports three categories of fields:
```json
{
"stop_hook_active": "boolean indicating if stop hook is active",
Comment thread
ZijianZhang989 marked this conversation as resolved.
"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"
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
firePreToolUseHook,
firePostToolUseHook,
firePostToolUseFailureHook,
buildContextUsage,
injectPermissionRulesIfMissing,
NotificationType,
persistPermissionOutcome,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand All @@ -1684,6 +1692,7 @@ export class Session implements SessionContext {
input: {
stop_hook_active: true,
last_assistant_message: responseText,
...contextUsage,
},
signal: pendingSend.signal,
},
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1968,6 +1968,7 @@ export async function loadCliConfig(
cliVersion: await getCliVersion(),
ideMode,
chatCompression: settings.model?.chatCompression,
autoCompactThreshold: settings.context?.autoCompactThreshold,
folderTrust,
interactive,
trustedFolder,
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1667,6 +1667,21 @@ const SETTINGS_SCHEMA = {
},
},
},
autoCompactThreshold: {
Comment thread
ZijianZhang989 marked this conversation as resolved.
Comment thread
ZijianZhang989 marked this conversation as resolved.
Comment thread
ZijianZhang989 marked this conversation as resolved.
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,
},
},
},
},

Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/ui/commands/contextCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ function makeMockConfig(contextWindowSize = 32_000): Config {
listSkills: vi.fn().mockResolvedValue([]),
}),
getChatCompression: vi.fn().mockReturnValue(undefined),
getAutoCompactThreshold: vi.fn(),
Comment thread
ZijianZhang989 marked this conversation as resolved.
} as unknown as Config;
}

Expand All @@ -74,6 +75,7 @@ describe('collectContextData (contextCommand)', () => {
listSkills: vi.fn().mockResolvedValue([]),
}),
getChatCompression: vi.fn().mockReturnValue(undefined),
getAutoCompactThreshold: vi.fn(),
} as unknown as Config;
});

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
});
});
5 changes: 4 additions & 1 deletion packages/cli/src/ui/commands/contextCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 -
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/ui/hooks/useContextualTips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
39 changes: 39 additions & 0 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
19 changes: 19 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -886,6 +887,7 @@ export interface ConfigParameters {
importFormat?: 'tree' | 'flat';
chatRecording?: boolean;
chatCompression?: ChatCompressionSettings;
autoCompactThreshold?: number;
interactive?: boolean;
trustedFolder?: boolean;
defaultFileEncoding?: FileEncodingType;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1745,9 +1749,16 @@ export class Config {
);
break;
case 'Stop': {
// Extract context usage data from input with runtime validation
const contextUsageData = buildContextUsage(
Comment thread
ZijianZhang989 marked this conversation as resolved.
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
Expand Down Expand Up @@ -4570,6 +4581,14 @@ export class Config {
return this.chatCompression;
}

getAutoCompactThreshold(): number | undefined {
Comment thread
ZijianZhang989 marked this conversation as resolved.
Comment thread
ZijianZhang989 marked this conversation as resolved.
const threshold = this.autoCompactThreshold;
if (typeof threshold === 'number' && threshold > 0 && threshold <= 1) {
Comment thread
ZijianZhang989 marked this conversation as resolved.
return threshold;
}
return undefined;
}

isInteractive(): boolean {
return this.interactive;
}
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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
Expand All @@ -2291,6 +2299,7 @@ export class GeminiClient {
input: {
stop_hook_active: true,
last_assistant_message: responseText,
...contextUsage,
},
signal,
},
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/core/geminiChat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/core/geminiChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Comment thread
ZijianZhang989 marked this conversation as resolved.
);
const imageTokenEstimate = resolveSlimmingConfig(
this.config.getChatCompression(),
).imageTokenEstimate;
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/hooks/context-usage.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
21 changes: 21 additions & 0 deletions packages/core/src/hooks/context-usage.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading
Loading