fix(core): compact on the window ceiling, not the max of the threshold ladder (#6583)

* fix(core): compact on the window ceiling (min), not the max of the ladder

computeThresholds combined the proportional term (pct*window) and the
absolute term (effectiveWindow - AUTOCOMPACT_BUFFER) with Math.max, which
pushed the auto-compaction trigger toward the top of the window on large
windows — a 1M-token window compacted at ~97%, leaving ~33K headroom.

The absolute term is structurally a ceiling ("compact before the prompt
leaves too little room for the summarization side-query, which needs up
to SUMMARY_RESERVE of output"), so it composes with Math.min, matching
the claude-code reference (services/compact/autoCompact.ts, which uses
Math.min and whose default trigger is the absolute term alone).

  auto = absoluteCeiling > 0 ? min(pct*window, absoluteCeiling) : pct*window
  warn = max(0, auto - WARN_BUFFER)   // WARN_PCT_OFFSET retired
  hard = unchanged

Effect: large windows compact at ~85% (the DEFAULT_PCT ceiling) instead
of ~97%; small/mid windows keep room to run compaction (a 128K window's
summary now provably fits); sub-33K windows are unchanged. A lower
context.autoCompactThreshold now pulls compaction earlier on large
windows, matching the reference's Math.min override semantics.

Updates the threshold unit tests, the settings schema description, and
the user docs to describe the setting as a ceiling on the trigger.

* refactor(core): trim threshold doc comments; name the hard-edge term

Post-review cleanup (no behavior change):
- Collapse the duplicated regime explanation shared between the DEFAULT_PCT
  and computeThresholds doc comments into one canonical block; point the
  constant's doc at computeThresholds.
- Rename rawHard -> hardEdge and note it is the window-edge ceiling, so the
  two roles of the hard tier (window edge vs. auto + HARD_BUFFER) are legible.
- Shorten the context.autoCompactThreshold description in settings.md to the
  concise schema wording (also un-widens the docs table).
This commit is contained in:
tanzhenxin 2026-07-09 19:25:21 +08:00 committed by GitHub
parent 154322a077
commit 2786afb2b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 131 additions and 119 deletions

View file

@ -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

View file

@ -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',

View file

@ -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);

View file

@ -43,8 +43,8 @@ function createContext(overrides: Partial<TipContext> = {}): 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,
});

View file

@ -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: '<state_snapshot>summary</state_snapshot>',
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: '<state_snapshot>summary</state_snapshot>',

View file

@ -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 };
}

View file

@ -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%)."
}
}
},