diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 7373b90a38..9cd9fb91a1 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -288,21 +288,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.autoCompactThreshold` | number | Fraction of the context window at which auto-compaction triggers. Must be greater than 0 and at most 1. Default is `0.85` (85%). For very large context windows (>220K tokens), the absolute branch of the three-tier threshold system (window minus ~33K) dominates, so higher values may have no visible effect there. Replaces the old `model.chatCompression.contextPercentageThreshold`. | `undefined` (uses internal 0.85) | -| `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 | Target fraction of the context window at which auto-compaction triggers. Must be greater than 0 and at most 1. Default is `0.85` (85%). Acts as a ceiling on the trigger: on large windows it is the effective trigger (~85%), while on smaller windows compaction may fire earlier to leave room to summarize. Replaces the old `model.chatCompression.contextPercentageThreshold`. | `undefined` (uses internal 0.85) | +| `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/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index e0ac87458b..0e23497fb3 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1771,7 +1771,7 @@ const SETTINGS_SCHEMA = { 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.85 (85%).', + 'Target fraction of the context window at which auto-compaction triggers (greater than 0, up to 1). Acts as a ceiling on the trigger: on large windows this is the effective trigger (~85%); on smaller windows compaction may fire earlier to leave room to summarize. Default is 0.85 (85%).', showInDialog: false, jsonSchemaOverride: { type: 'number', diff --git a/packages/cli/src/ui/commands/contextCommand.test.ts b/packages/cli/src/ui/commands/contextCommand.test.ts index 4dafa451b7..1daffad93b 100644 --- a/packages/cli/src/ui/commands/contextCommand.test.ts +++ b/packages/cli/src/ui/commands/contextCommand.test.ts @@ -232,7 +232,7 @@ describe('/context shows three-tier thresholds', () => { it('renders warn/auto/hard with the warn-tier marker when usage sits between warn and auto', async () => { // 200K window. computeThresholds(200K) = { - // warn: 150,000, auto: 170,000, hard: 177,000, effectiveWindow: 180,000 + // warn: 147,000, auto: 167,000, hard: 177,000, effectiveWindow: 180,000 // } // lastPromptTokenCount = 160K → between warn and auto → tier = warn. mockGetLastPromptTokenCount.mockReturnValue(160_000); @@ -240,15 +240,15 @@ describe('/context shows three-tier thresholds', () => { const text = formatContextUsageText(data); expect(text).toMatch(/Effective window:\s+180,000/); - expect(text).toMatch(/Warn threshold:\s+150,000/); - expect(text).toMatch(/Auto threshold:\s+170,000/); + expect(text).toMatch(/Warn threshold:\s+147,000/); + expect(text).toMatch(/Auto threshold:\s+167,000/); expect(text).toMatch(/Hard threshold:\s+177,000/); expect(text).toMatch(/Current tier:\s+warn/); expect(data.breakdown.currentTier).toBe('warn'); expect(data.breakdown.thresholds).toEqual({ effectiveWindow: 180_000, - warn: 150_000, - auto: 170_000, + warn: 147_000, + auto: 167_000, hard: 177_000, }); }); @@ -269,7 +269,7 @@ describe('/context shows three-tier thresholds', () => { }); it('classifies usage between auto and hard as the auto tier', async () => { - // 200K window — between 170K (auto) and 177K (hard) → tier = auto. + // 200K window — between 167K (auto) and 177K (hard) → tier = auto. mockGetLastPromptTokenCount.mockReturnValue(173_000); const data = await collectContextData(makeMockConfig(200_000), false); expect(data.breakdown.currentTier).toBe('auto'); @@ -291,14 +291,15 @@ describe('/context shows three-tier thresholds', () => { expect(data.breakdown.currentTier).toBe('safe'); // Thresholds are still computed and exposed on the breakdown for downstream // consumers, even though the text layout suppresses them. - expect(data.breakdown.thresholds.auto).toBe(170_000); + expect(data.breakdown.thresholds.auto).toBe(167_000); 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 } + // = { warn: 0, auto: 16,000, hard: 19,000, effectiveWindow: 12,000 } + // (32K ceiling degenerates, so auto = proportional floor = 0.5 * 32K) const config = makeMockConfig(32_000); vi.mocked(config.getAutoCompactThreshold).mockReturnValue(0.5); const data = await collectContextData(config, false); diff --git a/packages/cli/src/ui/components/Tips.test.ts b/packages/cli/src/ui/components/Tips.test.ts index 418b6ab901..fb5bb4cde1 100644 --- a/packages/cli/src/ui/components/Tips.test.ts +++ b/packages/cli/src/ui/components/Tips.test.ts @@ -43,8 +43,8 @@ function createContext(overrides: Partial = {}): TipContext { // Matches computeThresholds(1_000_000) — kept inline so this test stays // hermetic to the registry's tier logic rather than re-deriving constants. thresholds: { - warn: 947_000, - auto: 967_000, + warn: 830_000, + auto: 850_000, hard: 977_000, effectiveWindow: 980_000, }, @@ -67,7 +67,7 @@ describe('selectTip', () => { it('returns context-high tip when context usage is high', () => { const ctx = createContext({ - // Between auto (967K) and hard (977K) — context-high band. + // Between auto (850K) and hard (977K) — context-high band. lastPromptTokenCount: 970_000, contextWindowSize: 1_000_000, sessionPromptCount: 10, @@ -93,8 +93,8 @@ describe('selectTip', () => { it('returns compress-intro tip when context is moderate and session is long', () => { const ctx = createContext({ - // Between warn (947K) and auto (967K) — compress-intro band. - lastPromptTokenCount: 955_000, + // Between warn (830K) and auto (850K) — compress-intro band. + lastPromptTokenCount: 840_000, contextWindowSize: 1_000_000, sessionPromptCount: 10, }); diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index 50dc51821f..54b792a6a9 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -172,7 +172,7 @@ describe('ChatCompressionService', () => { ]); vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(600); vi.mocked(tokenLimit).mockReturnValue(1000); - // Threshold is 0.7 * 1000 = 700. 600 < 700, so NOOP. + // Default 0.85; window 1000 is degenerate → auto = 0.85 * 1000 = 850. 600 < 850, so NOOP. const result = await service.compress(mockChat, { promptId: mockPromptId, @@ -2069,54 +2069,63 @@ describe('ChatCompressionService.compress cheap-gate uses estimated tokens', () }); describe('computeThresholds', () => { - it('32K window — proportional fallback for all tiers, hard = auto + HARD_BUFFER', () => { + it('32K window — degenerate ceiling, proportional floor governs auto', () => { + // effectiveWindow 12K, ceiling = 12K - 13K = -1K (≤ 0) → auto falls back to + // the proportional floor (0.85 * 32K). const t = computeThresholds(32_000); - expect(t.warn).toBe(24_000); // 0.75 * 32K - expect(t.auto).toBe(27_200); // 0.85 * 32K + expect(t.warn).toBe(7_200); // auto - WARN_BUFFER = 27.2K - 20K + expect(t.auto).toBe(27_200); // proportional floor: 0.85 * 32K expect(t.hard).toBe(30_200); // auto + HARD_BUFFER = 27.2K + 3K expect(t.effectiveWindow).toBe(12_000); }); - it('60K window — hard no longer equals auto (issue #4945)', () => { + it('60K window — ceiling governs auto; hard stays above auto (issue #4945)', () => { + // ceiling = ew(40K) - 13K = 27K < proportional(51K) → auto = ceiling. const t = computeThresholds(60_000); - expect(t.warn).toBe(45_000); // 0.75 * 60K - expect(t.auto).toBe(51_000); // 0.85 * 60K (pct wins: 51K vs ew-13K=27K) - expect(t.hard).toBe(54_000); // auto + HARD_BUFFER = 51K + 3K + expect(t.warn).toBe(7_000); // auto - WARN_BUFFER = 27K - 20K + expect(t.auto).toBe(27_000); // min(0.85*60K=51K, ew-13K=27K) + expect(t.hard).toBe(37_000); // ew - HARD_BUFFER = 40K - 3K expect(t.hard).toBeGreaterThan(t.auto); expect(t.effectiveWindow).toBe(40_000); }); - it('128K window — proportional dominates at 0.85', () => { + it('128K window — ceiling governs auto (leaves room to compress)', () => { + // ceiling = ew(108K) - 13K = 95K < proportional(108.8K) → auto = ceiling. + // auto + SUMMARY_RESERVE = 95K + 20K = 115K ≤ 128K, so the summary fits. const t = computeThresholds(128_000); - expect(t.warn).toBe(96_000); // 0.75 * 128K (pct wins: 96K vs auto-20K=88.8K) - expect(t.auto).toBe(108_800); // 0.85 * 128K (pct wins: 108.8K vs ew-13K=95K) - expect(t.hard).toBe(111_800); // auto + HARD_BUFFER (wins over ew-3K=105K) + expect(t.warn).toBe(75_000); // auto - WARN_BUFFER = 95K - 20K + expect(t.auto).toBe(95_000); // min(0.85*128K=108.8K, ew-13K=95K) + expect(t.hard).toBe(105_000); // ew - HARD_BUFFER = 108K - 3K expect(t.effectiveWindow).toBe(108_000); }); - it('200K window — proportional auto (170K), absolute hard', () => { + it('200K window — ceiling governs auto (167K), just below proportional', () => { + // ceiling = ew(180K) - 13K = 167K < proportional(170K) → auto = 167K. const t = computeThresholds(200_000); - expect(t.warn).toBe(150_000); // 0.75 * 200K (ties abs: auto-20K=150K) - expect(t.auto).toBe(170_000); // 0.85 * 200K (pct wins: 170K vs ew-13K=167K) - expect(t.hard).toBe(177_000); // abs: effectiveWindow-3K = 180-3 = 177K + expect(t.warn).toBe(147_000); // auto - WARN_BUFFER = 167K - 20K + expect(t.auto).toBe(167_000); // min(0.85*200K=170K, ew-13K=167K) + expect(t.hard).toBe(177_000); // ew - HARD_BUFFER = 180K - 3K }); - it('1M window — fully absolute', () => { + it('1M window — proportional governs auto (85%), never crowds the ceiling', () => { + // proportional(850K) < ceiling(967K) → auto = 850K, not ~97% of the window. const t = computeThresholds(1_000_000); - expect(t.warn).toBe(947_000); - expect(t.auto).toBe(967_000); - expect(t.hard).toBe(977_000); + expect(t.warn).toBe(830_000); // auto - WARN_BUFFER = 850K - 20K + expect(t.auto).toBe(850_000); // min(0.85*1M=850K, ew-13K=967K) + expect(t.hard).toBe(977_000); // ew - HARD_BUFFER = 980K - 3K }); it('extreme small window (10K) does not crash; returns sane values', () => { const t = computeThresholds(10_000); - expect(t.warn).toBeGreaterThan(0); expect(t.auto).toBeGreaterThan(0); + expect(t.warn).toBeGreaterThanOrEqual(0); expect(t.warn).toBeLessThanOrEqual(t.auto); expect(t.auto).toBeLessThanOrEqual(t.hard); - // window < SUMMARY_RESERVE: effectiveWindow is clamped to 0, not negative. - // auto/warn/hard remain positive because each is `Math.max(proportional, absolute)` - // and the proportional branch dominates whenever the absolute branch goes ≤ 0. + // window < SUMMARY_RESERVE: effectiveWindow clamps to 0 and the ceiling is + // negative, so auto falls back to the proportional floor (0.85 * 10K = 8.5K); + // warn = max(0, 8.5K - 20K) = 0. + expect(t.auto).toBe(8_500); + expect(t.warn).toBe(0); expect(t.effectiveWindow).toBe(0); }); @@ -2145,24 +2154,27 @@ describe('computeThresholds', () => { expect(explicitDefault).toEqual(defaultResult); }); - it('custom pct=0.5 lowers proportional auto threshold for small windows', () => { + it('custom pct=0.5 lowers the proportional floor on a degenerate window', () => { + // 32K: ceiling ≤ 0 → auto = proportional floor = 0.5 * 32K. 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 + expect(t.warn).toBe(0); // max(0, 16K - 20K) }); - it('custom pct=0.9 raises proportional auto threshold for small windows', () => { + it('custom pct=0.9 raises the proportional floor on a degenerate window', () => { 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 + expect(t.warn).toBe(8_800); // 28.8K - 20K }); - it('custom pct does not affect absolute-branch-dominated large windows', () => { + it('custom pct DOES pull auto earlier on large windows (ceiling semantics)', () => { + // Under min-semantics the proportional term governs large windows, so a + // lower pct compacts earlier — matching claude-code's Math.min override. 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); + expect(defaultResult.auto).toBe(850_000); // 0.85 * 1M + expect(customPct.auto).toBe(500_000); // 0.5 * 1M < ceiling(967K) + expect(customPct.auto).toBeLessThan(defaultResult.auto); }); it('custom pct preserves warn <= auto < hard invariant', () => { @@ -2175,11 +2187,11 @@ describe('computeThresholds', () => { } }); - it('pct=0 produces auto=0 for small windows (proportional branch is 0)', () => { + it('pct=0 produces auto=0 for small windows (proportional floor is 0)', () => { const t = computeThresholds(32_000, 0); - // 0 * 32000 = 0, absolute branch is negative → auto = 0 + // 0 * 32000 = 0; ceiling is negative → auto = proportional floor = 0. expect(t.auto).toBe(0); - // warn = max((0 - 0.1) * 32000, absWarn) = -3200 + // warn = max(0, 0 - WARN_BUFFER) = 0 expect(t.warn).toBeLessThanOrEqual(t.auto); // hard is clamped to max(rawHard, auto + HARD_BUFFER) expect(t.hard).toBeGreaterThan(t.auto); @@ -2193,10 +2205,12 @@ describe('computeThresholds', () => { expect(t.hard).toBeLessThanOrEqual(t.auto); }); - it('pct=1 with large window: auto and hard both equal window', () => { + it('pct=1 on a large window: ceiling still caps auto below the window', () => { + // Even at pct=1 the absolute ceiling governs, so auto never reaches the + // full window — the key protection of the min-semantics. const t = computeThresholds(200_000, 1); - expect(t.auto).toBe(200_000); - expect(t.hard).toBe(200_000); + expect(t.auto).toBe(167_000); // min(200K, ew-13K=167K) + expect(t.hard).toBe(177_000); // ew - 3K expect(t.warn).toBeLessThanOrEqual(t.auto); }); @@ -2382,7 +2396,7 @@ describe('ChatCompressionService.compress cheap-gate uses computeThresholds.auto expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP); }); - it('on a 200K window with originalTokenCount=171K, falls through cheap-gate (above auto=170K)', async () => { + it('on a 200K window with originalTokenCount=171K, falls through cheap-gate (above auto=167K)', async () => { const spy = vi.spyOn(sideQueryModule, 'runSideQuery').mockResolvedValue({ text: 'summary', usage: { @@ -2401,7 +2415,7 @@ describe('ChatCompressionService.compress cheap-gate uses computeThresholds.auto originalTokenCount: 171_000, }); - // 171K > 170K (computeThresholds(200K).auto = 0.85 × 200K), cheap-gate lets through + // 171K > 167K (computeThresholds(200K).auto = min(0.85 × 200K, ew − 13K)), cheap-gate lets through expect(spy).toHaveBeenCalled(); expect(result.info.compressionStatus).not.toBe(CompressionStatus.NOOP); }); @@ -2414,7 +2428,8 @@ describe('ChatCompressionService.compress cheap-gate uses computeThresholds.auto 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 + // computeThresholds(32000, 0.5).auto = 16000 (degenerate window: ceiling + // ≤ 0, so auto falls back to the proportional floor 0.5 × 32K) // 20K > 16K → falls through cheap-gate const result = await new ChatCompressionService().compress(makeFakeChat(), { promptId: 'p', @@ -2435,9 +2450,9 @@ describe('ChatCompressionService.compress cheap-gate uses computeThresholds.auto .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 + // getAutoCompactThreshold returns undefined → default 0.85 + // computeThresholds(32000).auto = 27200 (degenerate → 0.85 × 32K) + // 20K < 27.2K → NOOP const result = await new ChatCompressionService().compress(makeFakeChat(), { promptId: 'p', force: false, @@ -2489,7 +2504,7 @@ describe('ChatCompressionService.compress cheap-gate runs against the full windo } as unknown as Config; } - it('131K window NOOPs at 90K (auto = 0.85 × 131072 ≈ 111K)', async () => { + it('131K window NOOPs at 90K (auto = min(0.85 × 131072, ew − 13K) ≈ 98K)', async () => { const spy = vi .spyOn(sideQueryModule, 'runSideQuery') .mockResolvedValue({ text: 's', usage: {} } as never); @@ -2510,7 +2525,7 @@ describe('ChatCompressionService.compress cheap-gate runs against the full windo it('does NOT fire early at 60K on a 131K window (no output reservation subtracted)', async () => { // Under the retired #5957 reservation, 64K was subtracted from the // window and 60K would have triggered (auto ≈ 47K). With full-window - // gating, auto ≈ 111K and 60K stays NOOP. + // gating, auto ≈ 98K and 60K stays NOOP. const spy = vi .spyOn(sideQueryModule, 'runSideQuery') .mockResolvedValue({ text: 's', usage: {} } as never); @@ -2528,7 +2543,7 @@ describe('ChatCompressionService.compress cheap-gate runs against the full windo expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP); }); - it('200K window NOOPs at 160K (auto = 0.85 × 200K = 170K)', async () => { + it('200K window NOOPs at 160K (auto = min(0.85 × 200K, ew − 13K) = 167K)', async () => { const spy = vi .spyOn(sideQueryModule, 'runSideQuery') .mockResolvedValue({ text: 's', usage: {} } as never); @@ -2547,7 +2562,7 @@ describe('ChatCompressionService.compress cheap-gate runs against the full windo }); it('triggers above the full-window threshold (120K on a 131K window)', async () => { - // auto = max(0.85 × 131072 ≈ 111.4K, 131072 − 33K ≈ 98K) = 111.4K; + // auto = min(0.85 × 131072 ≈ 111.4K, 131072 − 33K ≈ 98K) = 98K; // 120K crosses it. const spy = vi.spyOn(sideQueryModule, 'runSideQuery').mockResolvedValue({ text: 'summary', diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 17910c8f48..ef046f2481 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -50,24 +50,13 @@ const debugLogger = createDebugLogger('COMPRESSION'); export const COMPACT_MAX_OUTPUT_TOKENS = 20_000; /** - * Default proportional auto-compaction threshold: compact when the prompt - * crosses 85% of the window. For typical windows (≤ ~220K) the proportional - * branch dominates the ladder, so this is the effective default trigger; - * on larger windows the absolute branch (window − ~33K) takes over. Also - * acts as the small-window safety net inside computeThresholds — when the - * window is so small that the absolute branch becomes degenerate, the - * proportional branch keeps the trigger usable. + * Default proportional auto-compaction threshold — the preferred trigger and an + * upper bound on how high it can sit. See computeThresholds for how it combines + * with the absolute ceiling (it governs large windows; the ceiling governs + * smaller ones). */ export const DEFAULT_PCT = 0.85; -/** - * Offset from DEFAULT_PCT used to position the warn tier proportionally - * (warn-pct = 0.85 - 0.1 = 0.75). Three-tier ladder makes warn fire - * meaningfully before auto on small windows where the absolute formula - * would otherwise compress warn flush against auto. - */ -export const WARN_PCT_OFFSET = 0.1; - /** * Token budget reserved from the window for compression output. Matches * COMPACT_MAX_OUTPUT_TOKENS because thinking is disabled (see Task 1) and @@ -151,15 +140,19 @@ export interface CompactionThresholds { /** * Compute the three-tier threshold ladder for a given context window. * - * Each tier is `max(proportional, absolute)`: - * 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)) + * The absolute term (effectiveWindow - AUTOCOMPACT_BUFFER) is a *ceiling* — + * "compact by here, or the summarization side-query has no room to run" — so + * it is combined with the proportional preference via `min`, not `max`: + * auto = absoluteCeiling > 0 ? min(pct * window, absoluteCeiling) : pct * window + * warn = max(0, auto - WARN_BUFFER) + * hard = min(window, max(effectiveWindow - HARD_BUFFER, auto + HARD_BUFFER)) * - * `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 15% of the window. + * So large windows compact at ~pct (never crowding the ceiling), smaller + * windows compact at the ceiling (leaving room for the summary), and a window + * too small for even the ceiling (≤ SUMMARY_RESERVE + AUTOCOMPACT_BUFFER) falls + * back to the proportional value as a floor. This mirrors claude-code + * (autoCompact.ts), which combines its percentage override with the absolute + * ceiling via Math.min. `pct` defaults to DEFAULT_PCT. * * Pure function — no I/O, no shared state — safe to call repeatedly. */ @@ -172,28 +165,31 @@ export function computeThresholds( 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 - // each is `max(proportional, absolute)` and the proportional branch - // dominates whenever the absolute branch goes negative. + // value in `/context` stays meaningful. const effectiveWindow = Math.max(0, window - SUMMARY_RESERVE); - const absAuto = effectiveWindow - AUTOCOMPACT_BUFFER; - const auto = Math.max(effectivePct * window, absAuto); + // The absolute term is a ceiling: compact before the prompt leaves too little + // room for the summarization side-query (which needs up to SUMMARY_RESERVE of + // output). Combine it with the proportional preference via `min`. When the + // window is so small the ceiling is non-positive, fall back to the + // proportional value as a floor so the trigger stays usable. + const proportional = effectivePct * window; + const absoluteCeiling = effectiveWindow - AUTOCOMPACT_BUFFER; + const auto = + absoluteCeiling > 0 + ? Math.min(proportional, absoluteCeiling) + : proportional; - const absWarn = auto - WARN_BUFFER; - const warn = Math.max( - 0, - Math.max((effectivePct - WARN_PCT_OFFSET) * window, absWarn), - ); + // Warn fires WARN_BUFFER below auto (claude-code positions its warning tier + // the same way, relative to the auto threshold). + const warn = Math.max(0, auto - WARN_BUFFER); - const rawHard = effectiveWindow - HARD_BUFFER; - // 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)); + // hard is the last-ditch force-compaction point: the window edge (hardEdge), + // but never below auto + HARD_BUFFER so it stays a distinct tier above auto on + // degenerate small windows (where auto is the proportional floor and can + // exceed hardEdge). Clamp to the window so hard never exceeds the actual limit. + const hardEdge = effectiveWindow - HARD_BUFFER; + const hard = Math.min(window, Math.max(hardEdge, auto + HARD_BUFFER)); return { warn, auto, hard, effectiveWindow }; } diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 82e279b804..4b2a975b18 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -801,7 +801,7 @@ "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.85 (85%)." + "description": "Target fraction of the context window at which auto-compaction triggers (greater than 0, up to 1). Acts as a ceiling on the trigger: on large windows this is the effective trigger (~85%); on smaller windows compaction may fire earlier to leave room to summarize. Default is 0.85 (85%)." } } },