Skip to content
Closed
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
428 changes: 428 additions & 0 deletions docs/design/auto-compaction-threshold-redesign.md

Large diffs are not rendered by default.

1,752 changes: 1,752 additions & 0 deletions docs/plans/2026-05-14-auto-compaction-threshold-redesign.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,9 @@ Settings are organized into categories. Most settings should be placed within th
| `model.name` | string | The Qwen model to use for conversations. | `undefined` |
| `model.maxSessionTurns` | number | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `enableCacheControl`, `splitToolMedia` (set `true` for strict OpenAI-compatible servers like LM Studio that reject non-text content on `role: "tool"` messages — splits media into a follow-up user message), `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 | Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. Use `0` to disable compression entirely. | `0.7` |
| `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, and a one-line deprecation warning is emitted to stderr at startup. To disable auto-compaction entirely, use `model.chatCompression.disabled: true` (manual `/compress` and reactive overflow recovery still work). (See PR #4168 for the redesign rationale.) | `N/A` |
| `model.chatCompression.disabled` | boolean | When `true`, suppresses the proactive cheap-gate and hard-tier rescue paths so the chat retains full uncompressed history. Manual `/compress` (user-initiated) and reactive overflow recovery (API-layer last-ditch safety net) still run. Replaces the removed `contextPercentageThreshold: 0` escape hatch for compliance / debugging / audit-trail sessions. The first NOOP from this gate emits a once-per-process warn so operators can distinguish "user disabled" from "system broken". | `false` |
| `model.chatCompression.imageTokenEstimate` | number | Estimated tokens for a single inline image / document part when apportioning chars across history in `findCompressSplitPoint` and as the placeholder budget when stripping inline media out of the side-query compaction prompt. Env override: `QWEN_IMAGE_TOKEN_ESTIMATE`. Setting to `0` makes images invisible to the token estimator — only do so if you understand that compression decisions in image-heavy sessions will then ignore image weight. | `1600` |
| `model.skipNextSpeakerCheck` | boolean | Skip the next speaker check. | `false` |
| `model.skipLoopDetection` | boolean | Disables loop detection checks. Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions. | `false` |
| `model.skipStartupContext` | boolean | Skips sending the startup workspace context (environment summary and acknowledgement) at the beginning of each session. Enable this if you prefer to provide context manually or want to save tokens on startup. | `false` |
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/i18n/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -1777,6 +1777,20 @@ export default {
Used: 'Used',
Free: 'Free',
'Autocompact buffer': 'Autocompact buffer',
// Compaction-threshold section in /context output. Added in PR #4168
// alongside the three-tier ladder; ensure these keys appear in every
// locale to avoid mixed-language renders.
'Compaction thresholds': 'Compaction thresholds',
'Effective window': 'Effective window',
'Warn threshold': 'Warn threshold',
'Auto threshold': 'Auto threshold',
'Hard threshold': 'Hard threshold',
'window − {{reserve}} reserve': 'window − {{reserve}} reserve',
'Current tier': 'Current tier',
safe: 'safe',
warn: 'warn',
auto: 'auto',
hard: 'hard',
'Usage by category': 'Usage by category',
'System prompt': 'System prompt',
'Built-in tools': 'Built-in tools',
Expand Down
1 change: 0 additions & 1 deletion packages/cli/src/services/tips/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ export { TipHistory } from './tipHistory.js';
export { selectTip } from './tipScheduler.js';
export {
tipRegistry,
getContextUsagePercent,
type ContextualTip,
type TipContext,
type TipTrigger,
Expand Down
135 changes: 135 additions & 0 deletions packages/cli/src/services/tips/tipRegistry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect } from 'vitest';
import { tipRegistry, type TipContext } from './tipRegistry.js';

const baseCtx: TipContext = {
lastPromptTokenCount: 0,
contextWindowSize: 200_000,
sessionPromptCount: 10,
sessionCount: 1,
platform: 'darwin',
thresholds: {
warn: 147_000,
auto: 167_000,
hard: 177_000,
effectiveWindow: 180_000,
},
};

function tipById(id: string) {
return tipRegistry.find((t) => t.id === id)!;
}

describe('context-* tip thresholds align with computeThresholds', () => {
it('compress-intro fires between warn and auto', () => {
const t = tipById('compress-intro');
expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 100_000 })).toBe(
false,
);
expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 150_000 })).toBe(
true,
);
expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 168_000 })).toBe(
false,
);
});

it('context-high fires between auto and hard', () => {
const t = tipById('context-high');
expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 150_000 })).toBe(
false,
);
expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 170_000 })).toBe(
true,
);
expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 178_000 })).toBe(
false,
);
});

it('context-critical fires at or above hard', () => {
const t = tipById('context-critical');
expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 170_000 })).toBe(
false,
);
expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 178_000 })).toBe(
true,
);
});

it('context-high covers the small-window collapse case (R11.2: hard === auto)', () => {
// R9.5 gated context-critical on `hard > auto` to avoid claiming
// "near hard limit" when there's no distinct hard tier. That
// created a coverage gap on small windows: context-high's band
// `[auto, hard)` is the empty set when hard === auto, so users at
// the auto threshold got no tip at all. R11.2: context-high must
// fire on `>= auto` when hard === auto (treating it as "everything
// above auto" — there's no distinct hard tier to delimit).
const t = tipById('context-high');
const collapsedCtx = {
...baseCtx,
thresholds: {
effectiveWindow: 32_000,
warn: 18_000,
auto: 22_400,
hard: 22_400, // collapsed
},
lastPromptTokenCount: 25_000, // above the collapsed threshold
};
expect(t.isRelevant(collapsedCtx)).toBe(true);
});

it('context-critical suppresses when hard === auto (R9.5 small-window collapse)', () => {
// On small windows (e.g. 32K) computeThresholds collapses
// hard to equal auto. The critical band [hard, ∞) starts at the
// auto threshold; firing the tip there would claim "near hard
// limit" when there is no distinct hard limit. R9.5: gate on
// `hard > auto` like `currentTier` does. The `context-high` tip
// in band `[auto, hard)` already covers small windows.
const t = tipById('context-critical');
const collapsedCtx = {
...baseCtx,
thresholds: {
effectiveWindow: 32_000,
warn: 18_000,
auto: 22_400,
hard: 22_400, // collapsed to equal auto
},
lastPromptTokenCount: 25_000, // above the collapsed threshold
};
expect(t.isRelevant(collapsedCtx)).toBe(false);
});

it('falls back gracefully when thresholds undefined (legacy callers)', () => {
const ctx = { ...baseCtx, thresholds: undefined };
// All three context-* tips return false when thresholds are missing
// (the comparison would be unsafe without them).
expect(tipById('compress-intro').isRelevant(ctx)).toBe(false);
expect(tipById('context-high').isRelevant(ctx)).toBe(false);
expect(tipById('context-critical').isRelevant(ctx)).toBe(false);
});

it('compress-intro additionally gates on sessionPromptCount > 5', () => {
const t = tipById('compress-intro');
// Above warn, below auto, but session is too new.
expect(
t.isRelevant({
...baseCtx,
lastPromptTokenCount: 150_000,
sessionPromptCount: 3,
}),
).toBe(false);
expect(
t.isRelevant({
...baseCtx,
lastPromptTokenCount: 150_000,
sessionPromptCount: 6,
}),
).toBe(true);
});
});
59 changes: 42 additions & 17 deletions packages/cli/src/services/tips/tipRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* Contextual tip registry — defines tips, their conditions, and display rules.
*/

import { DEFAULT_TOKEN_LIMIT } from '@qwen-code/qwen-code-core';
import { type CompactionThresholds } from '@qwen-code/qwen-code-core';

export type TipTrigger = 'startup' | 'post-response';

Expand All @@ -18,6 +18,12 @@ export interface TipContext {
sessionPromptCount: number;
sessionCount: number;
platform: string;
/**
* Three-tier auto-compaction thresholds, computed by callers via
* `computeThresholds(contextWindowSize)`. Optional for backward compat;
* context-* tip checks return false when missing.
*/
thresholds?: CompactionThresholds;
}

export interface ContextualTip {
Expand All @@ -29,41 +35,60 @@ export interface ContextualTip {
priority: number;
}

export function getContextUsagePercent(ctx: TipContext): number {
const windowSize = ctx.contextWindowSize || DEFAULT_TOKEN_LIMIT;
return (ctx.lastPromptTokenCount / windowSize) * 100;
}

export const tipRegistry: ContextualTip[] = [
// --- Post-response contextual tips (priority: higher = more urgent) ---
{
id: 'context-critical',
content:
'Context is almost full! Run /compress now or start /new to continue.',
// R6.8 / R7.10: tip fires post-response. We don't know from this
// call site whether (a) hard-tier rescue ran successfully and
// shrank the context, (b) it ran but failed/NOOP'd, or (c) it was
// suppressed because `hardRescueFailureCount` hit
// `MAX_CONSECUTIVE_FAILURES`. The earlier wording ("auto-compact
// was forced on this turn") was wrong in case (c); the still
// earlier ("will force on next send") was wrong in case (a).
// Neutral, actionable wording is correct across all three.
content: 'Context near hard limit. Run /compress or /clear to free space.',
trigger: 'post-response',
isRelevant: (ctx) => getContextUsagePercent(ctx) >= 95,
// R9.5: gate on `hard > auto` mirroring `currentTier` in
// contextCommand.ts. On small windows (e.g. 32K) `computeThresholds`
// collapses `hard` to equal `auto`, leaving the critical band
// degenerate — without this guard the tip fires at the auto
// threshold while claiming "near hard limit" when there is no
// distinct hard limit. The `context-high` tip in the band
// `[auto, hard)` already covers small windows.
isRelevant: (ctx) =>
ctx.thresholds !== undefined &&
ctx.thresholds.hard > ctx.thresholds.auto &&
ctx.lastPromptTokenCount >= ctx.thresholds.hard,
cooldownPrompts: 3,
Comment thread
LaZzyMan marked this conversation as resolved.
priority: 100,
},
{
id: 'context-high',
content: 'Context is getting full. Use /compress to free up space.',
trigger: 'post-response',
Comment thread
LaZzyMan marked this conversation as resolved.
isRelevant: (ctx) => {
const pct = getContextUsagePercent(ctx);
return pct >= 80 && pct < 95;
},
// R11.2: when `hard === auto` (small windows ≤ ~77K, including
// 32K / 64K), the `[auto, hard)` band collapses to empty —
// context-critical is suppressed by R9.5's guard and the user
// would otherwise get no tip at all. Accept everything `>= auto`
// in that case: there's no distinct hard tier above to delimit.
isRelevant: (ctx) =>
ctx.thresholds !== undefined &&
ctx.lastPromptTokenCount >= ctx.thresholds.auto &&
(ctx.thresholds.hard === ctx.thresholds.auto ||
ctx.lastPromptTokenCount < ctx.thresholds.hard),
cooldownPrompts: 5,
priority: 90,
},
{
id: 'compress-intro',
content: 'Long conversation? /compress summarizes history to free context.',
trigger: 'post-response',
isRelevant: (ctx) => {
const pct = getContextUsagePercent(ctx);
return pct >= 50 && pct < 80 && ctx.sessionPromptCount > 5;
},
isRelevant: (ctx) =>
ctx.thresholds !== undefined &&
ctx.lastPromptTokenCount >= ctx.thresholds.warn &&
ctx.lastPromptTokenCount < ctx.thresholds.auto &&
ctx.sessionPromptCount > 5,
cooldownPrompts: 10,
priority: 50,
},
Expand Down
Loading
Loading