Uh oh!
There was an error while loading. Please reload this page.
fix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdict - #4486
Conversation
600338c to
2895104Compare2895104 to
5a4c7baCompare725cfc2 to
0d4528bCompare0d4528b to
0e504b6CompareAn image Tool Result serializes to a one-line reference on the ledger and to real bytes in the provider request, so every sizing site that measured the durable projection priced a screenshot at ~0 tokens. Compaction was never triggered by images, the prune never selected them, and the request went over the window with the budget reporting room to spare. model sees" and "how large the request is" only coincide for text. This adds the missing half: `effectiveToolResultMedia` is the one answer to what a Tool Result rehydrates into, covering both artifact parts and the pre-artifact image results the decoder still hands to materialization raw. Media stays in tokens rather than folding into the char count, because `charsPerToken` calibrates text and would otherwise make an image cheaper on a session with a low text ratio. Reactive overflow recovery reads that same decode instead of the raw execution fact — the fifth consumer #4348 did not reach — and, when the provider reported the rejected request's size, drops the largest images only until that overshoot is covered rather than every image at once. Refs #4458, #4283 Generated-by: Claude Code
The mid-turn payload measure is `JSON.stringify(messages).length`, and materialization has already turned every artifact reference into real bytes by the time it runs. A 200 KB screenshot reaches the request as base64, so the measure priced it at ~67,000 tokens against a provider that charges a few thousand. Under the 48,384-token fallback capacity — which `policy_fallback` enforces from step 0 — one image was enough to end the turn before a single provider call (#4458). The policy was never wrong: an estimate anchored on the provider's own input count should stop a request that cannot fit. The ruler was. This substitutes the same per-modality constant the ledger's ruler uses for a media part's serialized bytes, so both measures answer the same question and the capacity contract keeps working — no test in that reviewed contract changes. Refs #4458, #4283 Generated-by: Claude Code
`resolveContextBudgetCapacity` answered "what is this model's context window?" by adding the policy's 32,000-token history budget to its 16,384-token compaction reserve and calling the sum 48,384. Both inputs are choices about how much history to keep. Neither is a fact about the model, and the sum is a fact about nothing. It then cost twice. The fabricated number got step-0 enforcement that a declared window does not, because `source === 'policy_fallback'` was threaded into the verdict — one consumer, existing only to compensate for the fabrication. And where nothing could be fabricated at all (DeepSeek publishes no window and its policy sets no history budget), the capacity came back undefined, which skipped mid-turn state entirely — leaving the one provider with no proactive threshold ALSO without reactive overflow recovery, which needs no window because it runs off a real rejection. Capacity is now the declared window or nothing. An undeclared window is a mode, not a number: no proactive threshold, no verdict, no summarizer input ceiling — and recovery all the same. `ContextBudgetCapacity` and its `source` discriminator are gone with the fabrication that needed them. Refs #4458, #4283 Generated-by: Claude Code
A local estimate could end a live turn with zero provider calls, through two gates: the pre-turn history budget and the mid-turn capacity verdict. Both answered a question only the provider can answer, and both answered it from a number nobody measured. Delete both. The estimate keeps its one legitimate job — deciding when to compact early — and a rejection is recovered from by compacting and retrying once. The bounded capacity re-entry stays: it is reversible. `context_budget_exhausted` survives as a CompleteStopReason so persisted sessions still decode and present, but nothing produces it any more. This also dissolves the reason media sizing needed a trustworthy number: every consumer of MATERIALIZED_IMAGE_TOKENS is now reversible, so a flat constant that errs high can only ever buy a compaction. Deleted with the verdict: `exhaustedDetail` and its four branches, `ActiveRequestCompactionOutcome`'s terminal detail and its eleven producers, and the shape-failure record's detail. Removed alongside, all unreachable in production: targeted image omission (its overshoot came from a request the provider ACCEPTED, so the target was never positive), the duplicate inline-image predicate, the media pricing in active-tool-result-prune (extractPayload returns early for the content shape every image result has), and two dead imports. Losing those consumers leaves the media sizing wrappers with one caller each, so `estimateProjectionMediaTokens`, `estimateEffectiveMediaTokens` and `toolResultProjectionEstimatedTokens` fold into the two call sites that remain. Test coverage lost, stated rather than hidden: the cold-start estimate's system-prompt term had the verdict as its only observable, and its fixture suppresses usage so no diagnostic exists to read instead. Refs #4458, #4283
The mid-turn capacity estimate anchors on the last request's real input tokens paired with the payload chars measured for that same request, but both halves lived only inside one send. Every turn therefore started with no anchor at all, and the only sizing left was chars/4 over the whole payload — roughly half the real count for CJK text. Persist the pair on the token_usage record. `input` there is the reconciled per-send sum (#996) and anchors nothing; `lastRequestAnchor` is the last request alone, so the next turn can read it back off the runtime context it already loads. The two numbers are one nested object because only the pair means anything: an anchor from one request with a baseline from another is off by a whole step's growth, and the schema should say so rather than a runtime branch. Seed the mid-turn state from it, gated on the anchoring run using the same model over the same connection — a token count is only transferable within one tokenizer. The reverse scan takes the newest anchor-bearing record and stops: a rejected anchor means cold start, never a fallback to an older, worse-paired one. Overflow recovery now clears both halves for the same reason. The estimate sites also drop a pairing whose signed delta is wider than the whole payload. Within a send that cannot happen without a restructuring that already resets the baseline; across a turn boundary it means the prior tail was re-materialized down a different path than the request the anchor was reported for, and a pairing that far off estimates worse than none. This commit only makes the anchor available; nothing consumes it at step 0 yet. Reading a session written by this version on an older binary rejects the token_usage record, as with every closed-allowlist field before it.
…d anchor The mid-turn trigger and the final-request rescue both stood down on step 0, because the only sizing available there was chars/4 over the whole payload — too crude to start a summarizer on, and already the gate the pre-turn path spends. So the request most likely to be the largest one of the whole turn was the one nothing measured. With a previous turn's anchor seeded into the state, step 0 is no longer a guess: it is the same real-usage anchor plus signed char delta every later step is judged by. Open both gates exactly that far — an anchored step 0 is measured, an unanchored one still stands down, so a fresh session, a model switch and old sessions all behave as before. The fold itself reuses the pre_turn boundary the reactive step-0 recovery already picks: at step 0 the head anchor is pinned into the verbatim tail rather than covered, since folding the turn's only new event would save nothing. Expected behavior change: a long session in a language the chars/4 ruler under-counts (CJK especially) will now start compacting at the top of a turn where it previously waited for step 1. That is the estimate getting honest, not a regression — the pre-turn ruler that let those turns through measures neither the system prompt nor the tool schemas.
Two authorities answered the same question at turn start: a pre-turn gate weighing prior history events at chars/4 against a shaping threshold, and the step-0 anchored estimate weighing the whole outgoing payload against the real window. Demote the gate to what it actually is now — the fallback for the cases the anchored estimate cannot reach (no persisted anchor, no mid-turn seam, or a model that declares no window). The anchor's central invariant was also broken: the payload was measured from the base system prompt and the pre-dispatch tool set, while dispatch appends step-specific prompt fragments and clears the tool set entirely on a finalization step. A persisted provider input count could therefore be paired with a payload that describes a different request. One `resolveDispatch` seam on the request-projection context now resolves what the step really sends, and both the capacity trigger and the final-request rescue measure that. Removed along the way, all consumer-free or derivable: - `exceedsContextWindow`, left behind by the deleted local termination verdict - two dead imports in ai-sdk-backend - `EstimateNextRequestTokensInput.coldStartChars` and its branch: unanchored, the whole payload is the delta against a zero baseline, so one formula stands - the `midTurn.reserveTailEvents` policy knob no producer ever wrote - `MalformedHistoryCompactSummaryReason`'s derivation from the retired `ContextBudgetExhaustedDetail` enum - a duplicate run-header argument and one export that only served a test
Nothing has produced this outcome since the runtime stopped issuing local termination verdicts: whether a request fits is the provider's answer, and a rejection is recovered from by compacting and retrying. What remained was a read-only chain nine files long — a `CompleteEvent.stopReason` member no backend can emit, a six-value detail enum with no writer, its predicate, the mapper's stateDelta pass-through, the Host protocol allowlist and decoder, the canonical snapshot field, the session projector's `details`, and two desktop presentation branches with their locale copy. Old sessions still carry the name, so the durable ledger's own read boundary folds it to `context_overflow` — the outcome every downstream consumer already treated it as. That is the only place that now knows two names for it. The summarizer's malformed-summary taxonomy, which derived its type from the retired enum, was already moved into the history-compaction domain. The host fixture's provider stub now reports input tokens that grow with the request. Its flat 11 made the anchored turn-start estimate meaningless, which is exactly the number that test's compaction assertions depend on.
0e504b6 to
eacbcceCompare
Joob1n
left a comment
There was a problem hiding this comment.
English
Reviewed against the plan on #4458; this covers PR-1, PR-2 and PR-4 of it, and I am happy to build PR-3 / PR-5 / PR-6 on top. LGTM with three non-blocking notes.
Checked
- CI is green on both jobs, including the epoch guard, lint, format, renderer architecture, typecheck and build.
- After the PR the only remaining references to the retired contract are the decode-boundary fold (
runtime-event-read-model.ts,context_budget_exhausted → context_overflow) and its tests; no desktop copy keys are left dangling. persistedRequestAnchorscans in reverse and lets the first anchor-bearing record decide; a run header that fails the model/connection match cold-starts rather than falling back to an older anchor.requestEstimateAnchorcold-starts when|delta| > payload. Both are the conservative direction.- The pre-turn history gate now stands in only without an anchor, a mid-turn seam, or a declared window, and with an undeclared window
maxHistoryEstimatedTokensis itself absent, so the whole chain is inert there. - On the design difference you named: having read the implementation I accept keeping the signed delta. It prices only the change at char/4, so its error is bounded by the change, and a CJK under-estimate costs at most one proactive fold that did not happen, which the rejection path then covers.
Non-blocking
- Docs still describe the deleted contract.
docs/architecture/llm-compaction-events-log-projection-draft.mdlines 183, 396 and 407 (and the zh-CN twin) still state the 32,000-token fallback and "terminates withcontext_budget_exhaustedif still over budget". The PR touches no docs. - Downgrade note for the release.
decodeRuntimeEventthrowsInvalid RuntimeEvent schemaon an unknown key, so a session written by this build (withlastRequestAnchor) fails to load on an older build rather than skipping the record. That is the existing closed-schema policy, not a defect here, but it is worth a line in the release notes since this PR adds the persisted key. - Two constants meet by accident.
MATERIALIZED_IMAGE_TOKENS = 2_000andmaxResultEstimatedTokens: 2_048live in different files and are compared with<=, so whether a single-image result is archivable depends on its reference text exceeding ~48 tokens. You flagged the effect under behaviour change 2; I would either derive the prune threshold from the image constant or state the intent ("a single-image result is / is not archivable") explicitly so the next edit to either number cannot flip it silently.
Minor
I count 21 test( removals rather than fourteen. The two usage-accounting ones ("an aborted multi-step send records the accumulated usage…", "an unusable completed-step usage sample fails the whole record closed…") both relied on rollingOverflow producing the deleted verdict to create the abort, so removing them with it is right; only the number in the description is off.
简体中文
对照 #4458 上的计划审阅;本 PR 覆盖了其中的 PR-1、PR-2、PR-4,我后续的 PR-3 / PR-5 / PR-6 会基于它。LGTM,附三条非阻塞意见。
已核对
- CI 两个 job 全绿,包括 epoch 守卫、lint、format、renderer 架构检查、typecheck 与 build。
- PR 之后对已退役契约的引用只剩解码边界的折叠(
runtime-event-read-model.ts,context_budget_exhausted → context_overflow)及其测试;桌面端没有悬空的 copy key。 persistedRequestAnchor反向扫描,首个带 anchor 的记录决定结果;run header 的 model/connection 不匹配时直接冷启动,不回退到更旧的 anchor。requestEstimateAnchor在|delta| > payload时冷启动。两处都是保守方向。- turn 前的历史闸门现在只在无 anchor、无 mid-turn seam 或无声明窗口时顶上;而窗口未声明时
maxHistoryEstimatedTokens本身就缺失,整条链路在那里是惰性的。 - 关于你点出的设计差异:看过实现后我接受保留带符号增量。它只对「变化量」按 char/4 计价,误差以变化量为界,CJK 低估最多少一次本该发生的主动折叠,随后由拒绝路径兜底。
非阻塞
- 文档仍描述已删除的契约。
docs/architecture/llm-compaction-events-log-projection-draft.md第 183、396、407 行(及 zh-CN 版本)仍写着 32,000-token 兜底与「仍超预算则以context_budget_exhausted终止」。PR 未改任何文档。 - 发布时的降级说明。
decodeRuntimeEvent遇到未知键会抛Invalid RuntimeEvent schema,所以本版本写过的会话(含lastRequestAnchor)在旧版本上会加载失败而不是跳过该记录。这是既有的闭合 schema 策略,不是本 PR 的缺陷,但因为是本 PR 新增了持久化键,值得在 release note 里写一句。 - 两个常量意外相遇。
MATERIALIZED_IMAGE_TOKENS = 2_000与maxResultEstimatedTokens: 2_048分别定义在两个文件,用<=比较,所以单图结果能否归档取决于其引用文本是否超过约 48 token。你在行为变更 2 里已点出这个效果;我建议要么让裁剪阈值从图片常量推导,要么把意图(「单图结果可 / 不可归档」)写明,避免下次改动任一数字时静默翻转。
小问题
我数出 21 处 test( 删除而非 14。其中两个 usage 记账测试(「aborted 多步 send 记录累计 usage…」「不可用 usage 样本整体 fail closed…」)都依赖 rollingOverflow 触发已删除的判定来制造 abort,随判定一起删是对的;只是描述里的数字不对。
…ext size Stale-result collection gated every candidate on one comparison: does the priced result exceed maxResultEstimatedTokens? With MATERIALIZED_IMAGE_TOKENS at 2,000 and the default gate at 2,048, whether a single screenshot could be archived came down to whether the reference text around it happened to weigh more than about 48 tokens. The gate exists to spare small text results, so it now decides only those: a result carrying media is always a candidate, because archiving it drops whole images from the request whatever its text weighs. The same comparison in active-tool-result-prune is left alone. That path never sees a type:'content' image result to begin with, which is a separate gap. Also brings two documents back to the behavior on this branch. The compaction draft still described a fabricated 32,000+16,384 capacity and termination via context_budget_exhausted; capacity is now the declared window or nothing, an estimate only asks for compaction, and a request the provider rejects is compacted, retried once, and then reported as context_overflow. And the changelog now carries the downgrade note the token_usage anchor earns. Refs #4458, #4283
jackwener
left a comment
There was a problem hiding this comment.
I reviewed this at 5b339b2ca53d79d425cab63292a6c941de3f6704. No P0, P1, P2, or P3.
Two independent defects lined up: a materialized image was priced as a one-line placeholder on the ledger and as tens of thousands of JSON chars on the wire, and a local estimate could end a turn with zero provider calls. Both are gone. Images bill MATERIALIZED_IMAGE_TOKENS (2,000) on the ledger, the mid-turn payload, and archive selection. A media-bearing stale result is always an archive candidate, so a single screenshot no longer depends on ~48 tokens of surrounding text to clear the 2,048 gate. Whether a request fits is the provider's answer; an estimate only asks for compaction, fails open, and a rejection is compacted and retried once. context_budget_exhausted is folded to context_overflow at the ledger read boundary. Epoch 94 against current main 93 covers the failed-Turn snapshot no longer carrying contextBudgetExhaustedDetail.
The next turn's first request is estimated from the persisted lastRequestAnchor pair (last request's real input tokens and the payload chars measured for that same request), gated on the same model and connection. Half a pair is rejected at decode. The chars/4 history gate remains only when that anchor cannot run.
This is a bugfix that also changes the protocol; I am not merging it. If another open PR is also sitting on 94, the first to merge is fine and the other must re-bump after main moves.
简体中文
我审的是 5b339b2ca53d79d425cab63292a6c941de3f6704。没有 P0/P1/P2/P3。
图片在账本和线上都按 2,000 token 计。带媒体的旧结果一律可归档。本地估计不再结束回合,只提前压缩;能不能放下由供应商回答,拒绝则压缩并重试一次。context_budget_exhausted 在账本读边界折成 context_overflow。epoch 94(main 93)。下一回合第一步用持久化的 lastRequestAnchor 对估计。这是修 bug 但也改协议,我不合入。若还有 PR 占着 94,后合的那个要再加。
Automated review notice: This comment was posted by an automated review agent operated by WAWQAQ. It is not an independent human review and does not replace one.
Astro-Han
commented
Sep 2, 2026
Thank you for such a careful read, and for taking the delta on faith after seeing the implementation. All three notes and the count are in
One small thing worth flagging so it does not trip up PR-3: with an undeclared window Looking forward to PR-3. |
hqhq1025
left a comment
There was a problem hiding this comment.
Reviewed exact head 5b339b2ca53d79d425cab63292a6c941de3f6704. No P0 or P1 findings remain; I left one non-blocking P3 inline because the exported stale-prune policy documentation still describes the old all-payload threshold semantics.
The change removes the local terminal context verdict, prices materialized images consistently, persists a same-request usage/payload anchor across turns, and folds the retired stop reason at the protocol boundary. The current-head follow-up correctly makes every stale media-bearing Tool Result an archive candidate, so a screenshot no longer depends on incidental reference-text length to cross the text threshold.
Verification passed on the exact head: clean install, build:test, full workspace typecheck, Runtime 3,139 passed / 13 skipped, 127 focused compaction/archive/overflow tests, changed-file Biome, and the protocol epoch guard from current main 93 to 94. A clean synthetic merge with current main a57d5df250cc4314552427fd4424fe0acbdc0f83 also passed install, build, full typecheck, and the 127 focused tests. Hosted test and package were still running when this review was submitted, so this approval is a code-review result rather than a statement that the merge gate is complete. I did not run a real paid-provider conversation, and local validation used Linux with Node 22.22.1.
Review notice: This review was prepared by an automated review agent operated by hqhq1025 and is published at the direction of AstroHan, who has read these findings and is the human accountable for them.
| // whole images from the request, which is worth doing whatever the | ||
| // reference text around them happens to weigh. The size gate is there to | ||
| // spare small text results, so it only decides those. | ||
| if (media.length === 0 && originalEstimatedTokens <= maxResultEstimatedTokens) continue; |
There was a problem hiding this comment.
P3 — Update the exported policy contract for the new threshold semantics. This condition deliberately makes every media-bearing result eligible regardless of maxResultEstimatedTokens, but StaleToolResultPrunePolicy.maxResultEstimatedTokens still says that “Tool result payloads above this estimate are replaced” (tool-result-archive.ts:30). A caller reading the exported policy can still expect a small image result below the threshold to stay full. Please document that the threshold applies only to text-only results and that media is always eligible after minRecentTurnsFull.
Uh oh!
There was an error while loading. Please reload this page.
The runtime no longer estimates whether a request fits a context window.
Every "does it fit" question is answered by a provider: the conversation
model's own context-length rejection is recovered by one compact-and-retry,
and the summarizer's provider answers for compaction input (input_too_large
retreats the fold by half). The chars/4 payload ruler, the signed delta
estimate, the 32,000-token fallback history budget, the quarter-window
reserve, the replacement-not-smaller and prefix-over-budget replay gates,
and the final-request rescue re-entry are removed.
Proactive compaction keeps one trigger: the previous accepted request's
real input plus output tokens, as the provider counted them, compared with
the context window the user declared for the model (a model-facts pin or a
relay profile). A provider's /models report and generated metadata are no
longer a threshold on their own. With no declaration there is no proactive
fold; the provider decides. A reply the provider cut at its output limit
(finishReason length) folds once before the next request.
The persisted last-request anchor becomes { inputTokens, outputTokens };
the retired payloadChars key still decodes so 0.2.0 sessions keep loading.
Summaries are capped at 8,000 output tokens with one shorter retry, and the
too-small-for-fold floor reads the summarizer call's real usage instead of
a chars/4 estimate. Two user-visible notes explain provider-side context
changes: context_provider_dropping (an append-only step whose usage did not
grow) and context_window_suggestion (a rejection at a proven-fit total,
with the number the user can declare).
Closesapache#4559
Refs apache#4458, apache#4486
Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5JThe two user-visible compaction notes keyed on the `priorReplay` stage only. Since apache#4486 every new fold happens in the request-projection hook (`activeStep`), so the turn that was actually compacted showed nothing and the note arrived one turn later, when the checkpoint was replayed; a fold that failed open in the hook was never surfaced at all. Live against Ollama a fold succeeded and a fold failed in consecutive turns with no note either time. Both predicates now accept a history-compaction decision from either stage; the once-per-send flags in the backend are unchanged. Refs apache#4559 Generated-by: Claude Code Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
The runtime no longer estimates whether a request fits a context window.
Every "does it fit" question is answered by a provider: the conversation
model's own context-length rejection is recovered by one compact-and-retry,
and the summarizer's provider answers for compaction input (input_too_large
retreats the fold by half). The chars/4 payload ruler, the signed delta
estimate, the 32,000-token fallback history budget, the quarter-window
reserve, the replacement-not-smaller and prefix-over-budget replay gates,
and the final-request rescue re-entry are removed.
Proactive compaction keeps one trigger: the previous accepted request's
real input plus output tokens, as the provider counted them, compared with
the context window the user declared for the model (a model-facts pin or a
relay profile). A provider's /models report and generated metadata are no
longer a threshold on their own. With no declaration there is no proactive
fold; the provider decides. A reply the provider cut at its output limit
(finishReason length) folds once before the next request.
The persisted last-request anchor becomes { inputTokens, outputTokens };
the retired payloadChars key still decodes so 0.2.0 sessions keep loading.
Summaries are capped at 8,000 output tokens with one shorter retry, and the
too-small-for-fold floor reads the summarizer call's real usage instead of
a chars/4 estimate. Two user-visible notes explain provider-side context
changes: context_provider_dropping (an append-only step whose usage did not
grow) and context_window_suggestion (a rejection at a proven-fit total,
with the number the user can declare).
Closesapache#4559
Refs apache#4458, apache#4486
Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5JThe two user-visible compaction notes keyed on the `priorReplay` stage only. Since apache#4486 every new fold happens in the request-projection hook (`activeStep`), so the turn that was actually compacted showed nothing and the note arrived one turn later, when the checkpoint was replayed; a fold that failed open in the hook was never surfaced at all. Live against Ollama a fold succeeded and a fold failed in consecutive turns with no note either time. Both predicates now accept a history-compaction decision from either stage; the once-per-send flags in the backend are unchanged. Refs apache#4559 Generated-by: Claude Code Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
The runtime no longer estimates whether a request fits a context window.
Every "does it fit" question is answered by a provider: the conversation
model's own context-length rejection is recovered by one compact-and-retry,
and the summarizer's provider answers for compaction input (input_too_large
retreats the fold by half). The chars/4 payload ruler, the signed delta
estimate, the 32,000-token fallback history budget, the quarter-window
reserve, the replacement-not-smaller and prefix-over-budget replay gates,
and the final-request rescue re-entry are removed.
Proactive compaction keeps one trigger: the previous accepted request's
real input plus output tokens, as the provider counted them, compared with
the context window the user declared for the model (a model-facts pin or a
relay profile). A provider's /models report and generated metadata are no
longer a threshold on their own. With no declaration there is no proactive
fold; the provider decides. A reply the provider cut at its output limit
(finishReason length) folds once before the next request.
The persisted last-request anchor becomes { inputTokens, outputTokens };
the retired payloadChars key still decodes so 0.2.0 sessions keep loading.
Summaries are capped at 8,000 output tokens with one shorter retry, and the
too-small-for-fold floor reads the summarizer call's real usage instead of
a chars/4 estimate. Two user-visible notes explain provider-side context
changes: context_provider_dropping (an append-only step whose usage did not
grow) and context_window_suggestion (a rejection at a proven-fit total,
with the number the user can declare).
Closesapache#4559
Refs apache#4458, apache#4486
Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5JThe two user-visible compaction notes keyed on the `priorReplay` stage only. Since apache#4486 every new fold happens in the request-projection hook (`activeStep`), so the turn that was actually compacted showed nothing and the note arrived one turn later, when the checkpoint was replayed; a fold that failed open in the hook was never surfaced at all. Live against Ollama a fold succeeded and a fold failed in consecutive turns with no note either time. Both predicates now accept a history-compaction decision from either stage; the once-per-send flags in the backend are unchanged. Refs apache#4559 Generated-by: Claude Code Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
The runtime no longer estimates whether a request fits a context window.
Every "does it fit" question is answered by a provider: the conversation
model's own context-length rejection is recovered by one compact-and-retry,
and the summarizer's provider answers for compaction input (input_too_large
retreats the fold by half). The chars/4 payload ruler, the signed delta
estimate, the 32,000-token fallback history budget, the quarter-window
reserve, the replacement-not-smaller and prefix-over-budget replay gates,
and the final-request rescue re-entry are removed.
Proactive compaction keeps one trigger: the previous accepted request's
real input plus output tokens, as the provider counted them, compared with
the context window the user declared for the model (a model-facts pin or a
relay profile). A provider's /models report and generated metadata are no
longer a threshold on their own. With no declaration there is no proactive
fold; the provider decides. A reply the provider cut at its output limit
(finishReason length) folds once before the next request.
The persisted last-request anchor becomes { inputTokens, outputTokens };
the retired payloadChars key still decodes so 0.2.0 sessions keep loading.
Summaries are capped at 8,000 output tokens with one shorter retry, and the
too-small-for-fold floor reads the summarizer call's real usage instead of
a chars/4 estimate. Two user-visible notes explain provider-side context
changes: context_provider_dropping (an append-only step whose usage did not
grow) and context_window_suggestion (a rejection at a proven-fit total,
with the number the user can declare).
Closesapache#4559
Refs apache#4458, apache#4486
Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5JThe two user-visible compaction notes keyed on the `priorReplay` stage only. Since apache#4486 every new fold happens in the request-projection hook (`activeStep`), so the turn that was actually compacted showed nothing and the note arrived one turn later, when the checkpoint was replayed; a fold that failed open in the hook was never surfaced at all. Live against Ollama a fold succeeded and a fold failed in consecutive turns with no note either time. Both predicates now accept a history-compaction decision from either stage; the once-per-send flags in the backend are unchanged. Refs apache#4559 Generated-by: Claude Code Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
Summary
Refs #4458: a turn ending in
context_budget_exhaustedwith zero provider calls, because of an image.Two independent defects had to line up. Both are fixed.
An image's cost was wrong on both rulers. An image is a one-line reference on the ledger and real bytes in the provider request.
estimateRuntimeEventsTokensmeasured the durable projection, where an artifact flattens to a placeholder string — about 276 tokens for a 500 KB screenshot.midTurnRequestPayloadCharsisJSON.stringify(messages).lengthover already-rehydrated bytes — the same screenshot measured ~167,000. The provider charges about 1,500.Both now bill a materialized image at one constant,
MATERIALIZED_IMAGE_TOKENS = 2_000, defined once in@maka/core/attachments.effectiveToolResultMediais the single answer to what a Tool Result rehydrates into — artifact parts plus the pre-artifact image results the decoder still hands to materialization raw — and it feeds the budget, the archive prune, and overflow recovery. No dimensions are read, recorded, or carried: every consumer of the number is a reversible heuristic, so precision buys nothing and the constant's error direction (high) costs at most one compaction that was not needed. opencode bills 1,500 per image, Codex 1,844 on its common path, pi about 1,200; none of them persist image dimensions for this purpose.A local estimate could end a live turn. Two gates did this with zero provider calls: the pre-turn history budget and the mid-turn capacity verdict. Both answered a question only the provider can answer. One of them ran against a capacity
resolveContextBudgetCapacitysynthesized as32_000 + 16_384 = 48_384— two policy choices added together and called a context window.Both gates are deleted. The estimate keeps its one job, deciding when to compact early, and the bounded capacity re-entry stays because it is reversible. Whether a request fits is the provider's answer; a rejection is recovered from by compacting and retrying once.
ContextBudgetCapacityand itssourcediscriminator go with the fabrication that needed them.Also deleted, each unreachable in production
overshootTokenscame fromlastRequestInputTokens, only ever assigned from a step the provider accepted, so the target was never positive and the selection never ran. Recovery keeps the all-or-nothing behaviour it always actually performed.isMaterializedMediaPartmatched anyfilepart with an objectdata, contradicting its own doc comment; folded onto the stricterisInlineImageFilePart.active-tool-result-prune.extractPayloadreturns early foroutput.type === 'content', the shape every materialized image Tool Result has, so the term never executed.width/heightonvalidateImageBytes,MaterializedToolResultMedia.mediaType, and two dead imports.The first request of a turn is now estimated from real usage
With the terminal gates gone, the estimate's one remaining job is deciding when to compact early. Its weakest input was the turn's first request: steps ≥ 1 were already anchored on the previous step's real
inputTokensplus a signed char delta, but step 0 had no anchor at all, and the only pre-turn ruler (estimateRuntimeEventsTokensat char/4) counts neither the system prompt nor the tool schemas — on CJK content it reads under half the real value. That is how a long conversation reaches the provider oversized without the runtime ever noticing.The anchor now survives the turn boundary.
token_usagerecords gainlastRequestAnchor: { inputTokens, payloadChars }— the send's last provider request as the provider counted it, paired with the wire payload chars the runtime measured for that same request (inputon the record is the send's sum across steps and anchors nothing). The next turn reads the newest anchor back from the prior context it already loads, gates it on the samemodelIdandllmConnectionIdvia the run header, and estimates step 0 exactly like every later step: anchor plus signed delta over char/4. The char/4 guess now only ever prices a change, so its error is bounded by the size of the change instead of the size of the context.The pair is one object on purpose: an anchor from one request and a baseline from another is off by a whole step's growth, so half a pair is rejected at decode and the estimate cold-starts. The measurement uses the system prompt and tool set dispatch actually sends, including the child-finalization and sandbox-boundary fragments and the emptied tool set of a finalization step. Overflow recovery and image omission clear both halves — an older anchor pairs worse, not better. A delta wider than the whole payload means the prior tail was re-materialized down a different path than the anchored request; that pairing is dropped too.
No new read authority: the model-call ledger is metering, not a runtime input, and
contextRemainingis a derived value that clamps to zero above the window.One trigger at turn start, one less retired contract
A three-way simplification audit of the branch (two external models plus one in-house) agreed on the same residue, and the last two commits remove it.
Turn start has one trigger. The pre-turn
maxHistoryEstimatedTokensgate and the step-0 anchored estimate answered the same question with different rulers. The gate now stands in only when the anchored estimate cannot run: no persisted anchor, no mid-turn seam, or a model with no declared window. Its five other consumers (replay prefix admission, checkpoint fit, context-budget prune and diagnostics, summarizer input bound) are untouched. One number from before survives in that fallback: with no declared window,defaultHistoryBudgetTokensstill hands the gate a 32,000-token shaping threshold for providers other than DeepSeek. It can only ask for a compaction, never end a turn, so it is left for a follow-up rather than deleted here.context_budget_exhaustedis retired at the decode boundary. Nothing produced it after the gate deletion, and nothing downstream distinguished it fromcontext_overflow(the graph coordinator put both in one branch; the desktop only split off the malformed-summary detail). The durable ledger's read boundary folds it tocontext_overflow; theCompleteEventmember, the six-value detail enum, the protocol allowlist, snapshot reader, projector, mapper delta, and two desktop branches go with it. The live malformed-summary classification that had been derived from that enum now owns its three literals locally. Removing the field from the failed Turn snapshot is a protocol change, soRUNTIME_HOST_COMPATIBILITY_EPOCHmoves to 94.Also removed as consumer-free:
exceedsContextWindow, thecoldStartCharsestimate parameter (the whole payload against a zero anchor is the same formula), the never-producedmidTurn.reserveTailEventspolicy knob, and two single-caller wrappers folded into their call sites.Refs #4458, #4283
Behaviour changes to review
reason: context_overflow, the class messageContext window exceeded, and the provider's code when it sent one. The provider's own response text is still replaced by the class message at the runtime boundary — that is Failed turns hide the provider's own response; show it collapsed, expandable, for every failure class #4502, not this PR. One round trip where there used to be an immediate local failure.minRecentTurnsFullturns it becomes a re-readable placeholder whatever its reference text weighs. ThemaxResultEstimatedTokensgate (2,048) now decides only text-only results, so the coincidence ofMATERIALIZED_IMAGE_TOKENS(2,000) sitting just under it cannot flip the outcome.pre_turnfold with the head anchor pinned into the verbatim tail. Previously it went out unmeasured and was only caught at step 1, or by the provider. Intended, and user-visible as a summarizer call at the start of a long CJK session where there was none before. With an anchor present this estimate is the only turn-start trigger, so automatic memory-extraction boundaries at the history-budget cadence now come from it too: a provider that under-reports input tokens relative to chars/4 compacts, and extracts memory, later than before. One Host integration test moved for this reason — its provider stub reported a flat 11 input tokens for every request, which anchored the estimate at zero; the stub now reports usage proportional to the payload, as a real provider does.token_usagerecords gain the optionallastRequestAnchorunder the same closed-allowlist validator. Older builds reading a session this build wrote reject those records as malformed — the pre-existing cost ofhasExactShape, not a new one, but it applies here too.stopReason: context_budget_exhaustedload ascontext_overflow. The desktop's malformed-summary-specific copy for those historical turns is gone; they show the generic context-overflow message. The compatibility epoch moves 93 → 94, so an older Host and a newer client refuse each other at the handshake instead of failing on a snapshot decode.Test coverage removed
Twenty-one tests encoded the terminal contract, including two usage-accounting tests that relied on the deleted verdict to produce their abort. Those whose underlying obligation survived were re-pointed at an observable that still exists (the pinned-steering test now asserts the steer's text survives the fold verbatim). One is a real loss:
the cold-start estimate covers the FULL provider input including the system promptis gone — its fixture suppresses usage to force a cold start and so emits notoken_usageevent, leaving the verdict as its only observable.Review focus
MATERIALIZED_IMAGE_TOKENS = 2_000is the one number from outside this repo. It sits above Anthropic's ~1,600-token ceiling for an image up to 1.15 megapixels and between opencode's 1,500 and Codex's 1,844. A per-image floor (Gemini charges 258 tokens for anything under 384px on both sides) is far below it, so the constant never under-bills on the schemes this runtime targets.MAX_MODEL_IMAGE_EDGE = 2000carries no citation and predates this PR.Still open on #4283, out of scope: image Tool Results are structurally invisible to the active-turn prune (
extractPayloadreturns nothing fortype: 'content'). Predates this PR.Verification
npm test— all 10 workspaces passed.npm run format,npm run lint,node scripts/protocol-epoch-check.mjs --base origin/main— clean.Each commit reverts alone: the two step-0 behaviour tests fail with the turn-start trigger commit reverted and the tree stays green. New coverage: pair validity and half-pair rejection at decode; read-model round-trip; anchor written from the last step while
inputstays the sum; a table over{prior anchor, gate armed}asserting which trigger fires at turn start and which does not; a foreign model or unknown run header discards the anchor; the synthetic/compactusage row does not shadow the real one; a finalization step's anchor excludes the emptied tool schemas; a second turn reads the anchor back from the durable ledger; a persistedcontext_budget_exhaustedcompletion decodes ascontext_overflow.Reproducing locally:
npx tsx --testrun directly againstpackages/runtime/srcfails 13 filesystem-worker tests withbundle_not_found. That is an artifact of the invocation — the bundle lives indist/workers/andimport.meta.urlthen resolves tosrc/workers/.npm testbuilds first and passes.AI use
Tool(s) and scope: Claude Code — mechanism analysis, implementation, and tests, reviewed and verified by the author.
Checklist
Does this PR entail a change in behavior?