fix(core): compact on the window ceiling, not the max of the threshold ladder - #6583
Conversation
…dder
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.
…o fix/compaction-threshold-min-semantics
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).
|
Thanks for the PR! Template looks good ✓ Problem: This is a real, observed issue. On large context windows (1M tokens), Direction: Aligned. The absolute reserve ( Size: 86 production lines across 3 files (chatCompressionService.ts: +39/-43, settingsSchema.ts: +1/-1, settings.schema.json: +1/-1). 138 test/doc lines excluded. Well within bounds — not applicable for size advisory. Approach: Scope feels tight and minimal. The change is a single semantic flip ( One question for the author: this PR stacks on Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ 问题:这是一个真实存在的问题。在大窗口(1M tokens)上, 方向:对齐。绝对预留( 规模:3 个生产文件共 86 行(chatCompressionService.ts: +39/-43, settingsSchema.ts: +1/-1, settings.schema.json: +1/-1)。138 行测试/文档行被排除。远在限制内——规模建议不适用。 方案:范围紧凑且最小化。改动是单一语义翻转( 一个问题:这个 PR 堆叠在 进入代码审查 🔍 — Qwen Code · qwen3.7-max |
Code ReviewIndependent proposal before reading the diff: the absolute reserve ( The PR matches this proposal exactly. The core change is a single semantic flip ( No correctness bugs, no security issues, no AGENTS.md violations. The code is minimal, well-commented, and follows project conventions. The degenerate case where Notable: the PR also fixes the latent issue where Unit TestsAll 108 tests pass across 3 test files:
Test expectations are updated with correct values derived from the new Real-Scenario Verification (tmux)Built the PR branch and ran Key observations from the output:
中文说明代码审查阅读 diff 前的独立方案:绝对预留( PR 完全匹配这个方案。核心改动是单一语义翻转( 无正确性 bug,无安全问题,无 AGENTS.md 违规。 代码最小化、注释良好、遵循项目规范。 值得关注:PR 还修复了 单元测试全部 108 个测试通过(3 个测试文件):
测试期望已更新为新的 真实场景验证(tmux)构建了 PR 分支并在 tmux 中运行
— Qwen Code · qwen3.7-max |
|
This is a well-reasoned, minimal fix that correctly identifies and resolves a real semantic inversion in the compaction threshold ladder. The core insight — that the absolute reserve is a ceiling, not a target — is sound. The The implementation matches what I would have done independently. Every change in the diff is necessary: the formula flip, the retired The tmux verification confirms the expected behavior across all standard window sizes, and the edge case at One thing worth noting for future maintainers: this PR stacks on Approving. ✅ 中文说明这是一个理由充分、最小化的修复,正确识别并解决了压缩阈值梯子中的语义反转问题。 核心洞察——绝对预留是一个上限而非目标——是合理的。 实现与我独立提出的方案一致。diff 中的每一处改动都是必要的:公式翻转、移除 tmux 验证确认了所有标准窗口大小下的预期行为,小窗口上 值得未来维护者注意:此 PR 堆叠在 批准。✅ — Qwen Code · qwen3.7-max |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No blockers found. The min-semantics change is mathematically sound and well-tested. A suggestion-level recommendation is in the Suggestion summary comment below.
— qwen3.7-max via Qwen Code /review
Suggestions — commit
|
| File | Issue | Suggested fix |
|---|---|---|
packages/core/src/services/chatCompressionService.ts:186 |
Discontinuity at absoluteCeiling > 0 boundary: window=33000 → auto=28050, but window=33001 → auto=1. No built-in model falls in 33K–46K, but user-configurable contextWindowSize could hit this range, causing near-immediate compaction. |
Replace absoluteCeiling > 0 ? Math.min(proportional, absoluteCeiling) : proportional with Math.min(proportional, Math.max(0, absoluteCeiling)) to make the function continuous. |
— qwen3.7-max via Qwen Code /review
doudouOUC
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestion-level recommendations are in the Suggestion summary comment below.
Suggestions — commit
|
| File | Issue | Suggested fix |
|---|---|---|
packages/vscode-ide-companion/schemas/settings.schema.json:802 |
Under min-semantics, autoCompactThreshold: 0.01 on a 1M window triggers compaction at 10K tokens (1% of window). The old max-semantics absorbed this harmlessly (auto = max(10K, 967K) = 967K). The setting is writable from project-level .qwen/settings.json without a trust gate specific to this key. Consider raising the schema minimum (e.g., 0.1) to prevent configurations that produce constant compaction. |
Raise "minimum" in the JSON schema, or add a runtime floor in getAutoCompactThreshold(). |
packages/core/src/services/chatCompressionService.ts:165 |
When compaction fires, the debug log shows effectiveTokens, auto, and contextLimit but not which formula branch governed (proportional vs. ceiling). Under the new conditional logic, this makes 3 AM debugging harder — the oncall engineer must re-derive ceiling = ew - 13K mentally. |
Add `proportional=X, ceiling=Y, branch=ceiling |
packages/core/src/services/chatCompressionService.ts:185 |
The warn-tier formula changed from proportional positioning ((pct - WARN_PCT_OFFSET) * window, ~75% of window) to purely absolute (auto - 20K). On medium windows (60K), warn collapses to 7K (12% of window). On windows where auto < 20K, warn is 0 — the three-tier ladder effectively becomes two-tier. The WARN_PCT_OFFSET constant was removed cleanly but this behavioral sub-change is undocumented. |
Document in the JSDoc that warn is now a fixed absolute distance below auto (matching claude-code), not a proportional fraction. Consider a proportional fallback for small/medium windows. |
— qwen3.7-max via Qwen Code /review
…reservation (QwenLM#6556) * fix(core): clamp max_tokens to the context window; retire the output reservation Auto-compaction was firing far too early — a 200K-window session compacted at roughly half the window. The cause was not the compaction engine but that every request manufactured a large max_tokens, which forced a defensive reservation of that output budget out of the window before computing compaction thresholds. The reservation shrank the effective window, pulled the trigger down, and spawned a chain of band-aids. Size max_tokens to the room actually left in the window instead — the smaller of the model's output ceiling and (window − prompt − margin) — so an oversized request can never exceed the context limit. Once output is guaranteed to fit, the reservation is unnecessary and is removed; compaction gates on the full window again. Raise the default proportional threshold from 0.70 to 0.85, and replace the temporary half-window reservation cap with a flat 64K output ceiling. This resolves early compaction, the 400 "maximum context length" error on request, the "hard limit: 0" pre-send NOOP for env-configured models, and retires the half-window reservation cap, while keeping max_tokens on the wire for both OpenAI- and Anthropic-shaped providers. Fixes QwenLM#5950 Fixes QwenLM#6384 Claude-Session: https://claude.ai/code/session_014DW2TynKHLjsbRqTBSyQue * test(cli): update /context threshold expectations for 85% default The auto-compaction default moved from 70% to 85% and the output reservation was removed, so computeThresholds(200K) now yields warn=150K / auto=170K (was 147K / 167K). Update the /context command tests that hard-coded the old ladder. * fix(core): apply window clamp to samplingParams users who omit max_tokens Previously a samplingParams config without a max_tokens key sent no max_tokens on the wire (OpenAI path), so those users bypassed the prompt + max_tokens <= window clamp — inconsistent with the Anthropic path, which always injects the clamped value. Mirror the Anthropic fallback (reconcile ?? config ?? request) so the clamped maxOutputTokens is injected when samplingParams omits max_tokens. Guard the injection: when samplingParams targets a provider-specific output-budget key (max_completion_tokens for GPT-5/o-series, max_new_tokens), leave it verbatim — adding max_tokens alongside double-specifies the budget and those endpoints reject the pair. * fix(core): clamp provider output-budget keys to the window in samplingParams A samplingParams config carrying a provider-specific output-budget key (max_completion_tokens for GPT-5/o-series, max_new_tokens) but no max_tokens previously passed the key through verbatim, so its value escaped the prompt + output <= window clamp — e.g. max_completion_tokens: 200000 on a 200K window with a 150K prompt. Clamp the key's value in place to the remaining window (min with the request maxOutputTokens) instead of injecting a separate max_tokens: sending both keys double-specifies the output budget and o-series rejects the pair. The value only shrinks when the window is tight; when there is room it passes through unchanged, matching how max_tokens is already treated. * fix(core): compact on the window ceiling, not the max of the threshold ladder (QwenLM#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). * fix(core): clamp provider output-budget keys on every samplingParams exit A config carrying both max_tokens and a provider-specific output-budget key (max_completion_tokens / max_new_tokens) took the max_tokens early return, spreading the provider key onto the wire unclamped — on backends honoring the larger key, prompt + output could exceed the window. Collapse the two returns into a single exit that always runs the provider-key clamp, so no output-budget key escapes the window clamp regardless of which combination of keys is present. --------- Co-authored-by: 易良 <1204183885@qq.com>
What this PR does
Auto-compaction decides when to compress a session from a threshold computed off the model's context window. That threshold combines two ingredients: a proportional preference ("compact around 85% of the window") and an absolute reserve near the window's edge ("keep enough room for the compaction summary to run"). Today the ladder takes the larger of the two, so on large windows the absolute reserve wins and pushes the trigger up against the ceiling — a 1M-token session only compacts at about 97% full, and a 128K session compacts so late that the summarization pass itself no longer fits inside the window.
This PR takes the smaller of the two instead. The absolute reserve is structurally a ceiling — "compact by here, or there is no room left to summarize" — so it belongs on the low side of the decision, not the high side. After the change: large windows compact at ~85% instead of crowding the ceiling; mid-size windows compact early enough that the summary always fits; very small windows are unchanged; and
context.autoCompactThresholdnow behaves intuitively on large windows — lowering it pulls compaction earlier, where before it was silently ignored on any window bigger than ~220K.Why it's needed
The three-tier ladder and its absolute reserve were introduced in #4345, which replaced a flat "70% of window" trigger and combined the two terms with a max, under a utilization argument: reclaim the fraction of a big window that a flat percentage "wastes."
That framing inverts what the absolute term is. It is not a target to rise toward — it is the point past which the summarization call no longer has room to run (the window minus the summary's output budget minus a small margin). Combined with max, the trigger sits at the thinnest-allowed headroom exactly when the window is largest, which is when a big tool result is most likely to overshoot the window before compaction fires; on a mid-size window it can even land where the summary provably cannot fit. Taking the min treats the absolute reserve as the ceiling it actually is.
The follow-up #5868 (which made the threshold configurable) surfaced the same inversion from the user's side: its notes observe that "custom threshold mainly affects small windows" — i.e. lowering the threshold does nothing on large windows, because
maxkeeps the absolute reserve in charge there. After this change a lower threshold correctly pulls compaction earlier on large windows too. The small-window ordering guarantee from #4949 is preserved.On the other tuning values: the absolute buffers are correct and stay; the only changes are the
max → minoperator and retiring a warn-tier offset that becomes redundant once the ladder is simplified. The 85% default is respected and unchanged.Reviewer Test Plan
How to verify
Configure a model with a specific context window and grow a session past the trigger, watching where auto-compaction fires:
context.autoCompactThresholdto0.5on a large-window model — compaction should now fire far earlier (around 50%); before, it was ignored on large windows.The regression this prevents: large-window sessions used to run to ~97% before compacting, leaving almost no headroom for the next turn, and mid-window sessions could trigger a summary that no longer fit the window.
Evidence (Before & After)
Verified end-to-end with a mock backend reporting a controlled prompt-token count, comparing the base-branch (
max) binary against this branch (min) at fixed window sizes. Each token count sits between the old and new trigger, so the outcome is unambiguous.max)min)All three discriminating cases flip from no-compaction to compaction; the explicit-threshold case confirms a low
autoCompactThresholdnow takes effect on a large window; the control confirms no over-eager firing.Tested on
Environment
Local unit suite plus an end-to-end run driven through a mock OpenAI-compatible backend at configured window sizes.
Risk & Scope
Linked Issues
Refines the auto-compaction ladder introduced in #4345. Related: #5868 (configurable threshold) and #4949 (force tier > auto tier). Stacks on #6556 and should merge after it.
中文说明
本 PR 做了什么
自动压缩(auto-compaction)根据模型的上下文窗口计算一个触发阈值来决定何时压缩会话。该阈值由两部分组成:一个按比例的偏好("在窗口约 85% 时压缩")和一个靠近窗口上沿的绝对预留("为压缩总结留出运行空间")。目前阈值梯子取两者中的较大值,因此在大窗口上绝对预留胜出,把触发点顶到接近上限——100 万 token 的会话要到约 97% 才压缩,而 128K 的会话压缩得太晚,以至于总结调用本身已经放不进窗口。
本 PR 改为取两者中的较小值。绝对预留在结构上是一个上限——"到这里必须压缩,否则没有空间做总结"——因此它应位于决策的低侧,而非高侧。改动后:大窗口在约 85% 处压缩,不再贴着上限;中等窗口足够早地压缩,总结总能放下;极小窗口保持不变;并且
context.autoCompactThreshold在大窗口上行为符合直觉——调低它会更早触发压缩,而此前在大于约 220K 的窗口上它会被静默忽略。为什么需要
三层阈值梯子和绝对预留由 #4345 引入,它取代了固定的"窗口 70%"触发,并用**取大值(max)**组合两项,理由是利用率:把固定百分比在大窗口上"浪费"的那部分空间收回来。
这个理由把绝对预留的本质弄反了。它不是一个要去够到的目标,而是"再往上总结调用就没有空间运行"的界线(窗口减去总结的输出预算再减去一个小余量)。用 max 组合,会让触发点在窗口最大时恰好停在允许的最薄余量处——而这正是一个大的工具结果最可能在压缩触发前撑破窗口的时候;在中等窗口上,触发点甚至可能落在总结放不下的位置。改用取小值(min),就把绝对预留当作它本该是的上限来对待。
后续的 #5868(让阈值可配置)从用户侧暴露了同样的问题:其说明指出"自定义阈值主要影响小窗口"——也就是在大窗口上调低阈值不起作用,因为 max 让绝对预留在那里说了算。本次改动后,调低阈值在大窗口上也能正确地更早触发压缩。#4949 关于小窗口的次序保证保持不变。
关于其它数值:各绝对缓冲区是正确的,予以保留;唯一的改动是 max→min 运算符,以及退休一个在梯子简化后变得多余的 warn 层偏移常量。85% 默认值被尊重且不变。
证据(前 & 后)
通过一个返回受控 prompt-token 计数的 mock 后端做端到端验证,在固定窗口大小下比较基线分支(
max)与本分支(min)。每个 token 计数都落在新旧触发点之间,结果无歧义。max)min)风险与范围