fix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdict - #4486

Merged
Astro-Han merged 9 commits into
mainfrom
fix/projection-artifact-cost
Sep 2, 2026
Merged

fix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdict#4486
Astro-Han merged 9 commits into
mainfrom
fix/projection-artifact-cost

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Refs #4458: a turn ending in context_budget_exhausted with 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. estimateRuntimeEventsTokens measured the durable projection, where an artifact flattens to a placeholder string — about 276 tokens for a 500 KB screenshot. midTurnRequestPayloadChars is JSON.stringify(messages).length over 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. effectiveToolResultMedia is 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 resolveContextBudgetCapacity synthesized as 32_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. ContextBudgetCapacity and its source discriminator go with the fabrication that needed them.

Also deleted, each unreachable in production

  • Targeted image omission.overshootTokens came from lastRequestInputTokens, 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.
  • A duplicate inline-image predicate.isMaterializedMediaPart matched any file part with an object data, contradicting its own doc comment; folded onto the stricter isInlineImageFilePart.
  • Media pricing in active-tool-result-prune.extractPayload returns early for output.type === 'content', the shape every materialized image Tool Result has, so the term never executed.
  • Unread width/height on validateImageBytes, 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 inputTokens plus a signed char delta, but step 0 had no anchor at all, and the only pre-turn ruler (estimateRuntimeEventsTokens at 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_usage records gain lastRequestAnchor: { 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 (input on 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 same modelId and llmConnectionId via 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 contextRemaining is 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 maxHistoryEstimatedTokens gate 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, defaultHistoryBudgetTokens still 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_exhausted is retired at the decode boundary. Nothing produced it after the gate deletion, and nothing downstream distinguished it from context_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 to context_overflow; the CompleteEvent member, 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, so RUNTIME_HOST_COMPATIBILITY_EPOCH moves to 94.

Also removed as consumer-free: exceedsContextWindow, the coldStartChars estimate parameter (the whole payload against a zero anchor is the same formula), the never-produced midTurn.reserveTailEvents policy knob, and two single-caller wrappers folded into their call sites.

Refs #4458, #4283

Behaviour changes to review

  1. A request a local estimate judges too large now dispatches. On a genuinely oversized one the provider rejects, recovery compacts and retries once, and a second rejection ends the turn as a real error: reason: context_overflow, the class message Context 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.
  2. Images now carry real cost on the ledger, and a Tool Result carrying media is always a stale-archive candidate: after minRecentTurnsFull turns it becomes a re-readable placeholder whatever its reference text weighs. The maxResultEstimatedTokens gate (2,048) now decides only text-only results, so the coincidence of MATERIALIZED_IMAGE_TOKENS (2,000) sitting just under it cannot flip the outcome.
  3. A turn whose first request would already exceed the high-water mark now compacts before that request, as a pre_turn fold 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.
  4. token_usage records gain the optional lastRequestAnchor under the same closed-allowlist validator. Older builds reading a session this build wrote reject those records as malformed — the pre-existing cost of hasExactShape, not a new one, but it applies here too.
  5. Old sessions persisted with stopReason: context_budget_exhausted load as context_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 prompt is gone — its fixture suppresses usage to force a cold start and so emits no token_usage event, leaving the verdict as its only observable.

Review focus

MATERIALIZED_IMAGE_TOKENS = 2_000 is 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 = 2000 carries no citation and predates this PR.

Still open on #4283, out of scope: image Tool Results are structurally invisible to the active-turn prune (extractPayload returns nothing for type: '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 input stays 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 /compact usage 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 persisted context_budget_exhausted completion decodes as context_overflow.

Reproducing locally: npx tsx --test run directly against packages/runtime/src fails 13 filesystem-worker tests with bundle_not_found. That is an artifact of the invocation — the bundle lives in dist/workers/ and import.meta.url then resolves to src/workers/. npm test builds first and passes.

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — mechanism analysis, implementation, and tests, reviewed and verified by the author.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Behaviour changes to review above
  • No

@Astro-HanAstro-Han changed the title fix(runtime): price artifact media inside the context budgetfix(runtime): measure a materialized image by what it billsSep 1, 2026
@github-actionsgithub-actionsBot added the effort/M Under 500 readable lines label Sep 1, 2026
@Astro-Han
Astro-Han marked this pull request as ready for review September 1, 2026 16:21
@github-actionsgithub-actionsBot added effort/L Under 1000 readable lines and removed effort/M Under 500 readable lines labels Sep 1, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 600338c to 2895104CompareSeptember 1, 2026 17:43
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 2895104 to 5a4c7baCompareSeptember 1, 2026 18:54
@Astro-HanAstro-Han changed the title fix(runtime): measure a materialized image by what it billsfix(runtime): price images by pixel area, let the provider decide fit, anchor estimates on real usageSep 1, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 725cfc2 to 0d4528bCompareSeptember 2, 2026 03:05
@github-actionsgithub-actionsBot added effort/XL Over 1000 readable lines and removed effort/L Under 1000 readable lines labels Sep 2, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 0d4528b to 0e504b6CompareSeptember 2, 2026 05:07
An 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.
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 0e504b6 to eacbcceCompareSeptember 2, 2026 05:38
@Astro-HanAstro-Han changed the title fix(runtime): price images by pixel area, let the provider decide fit, anchor estimates on real usagefix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdictSep 2, 2026

@Joob1nJoob1n left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
  • persistedRequestAnchor scans 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. requestEstimateAnchor cold-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 maxHistoryEstimatedTokens is 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

  1. Docs still describe the deleted contract.docs/architecture/llm-compaction-events-log-projection-draft.md lines 183, 396 and 407 (and the zh-CN twin) still state the 32,000-token fallback and "terminates with context_budget_exhausted if still over budget". The PR touches no docs.
  2. Downgrade note for the release.decodeRuntimeEvent throws Invalid RuntimeEvent schema on an unknown key, so a session written by this build (with lastRequestAnchor) 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.
  3. Two constants meet by accident.MATERIALIZED_IMAGE_TOKENS = 2_000 and maxResultEstimatedTokens: 2_048 live 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.tscontext_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 低估最多少一次本该发生的主动折叠,随后由拒绝路径兜底。

非阻塞

  1. 文档仍描述已删除的契约。docs/architecture/llm-compaction-events-log-projection-draft.md 第 183、396、407 行(及 zh-CN 版本)仍写着 32,000-token 兜底与「仍超预算则以 context_budget_exhausted 终止」。PR 未改任何文档。
  2. 发布时的降级说明。decodeRuntimeEvent 遇到未知键会抛 Invalid RuntimeEvent schema,所以本版本写过的会话(含 lastRequestAnchor)在旧版本上会加载失败而不是跳过该记录。这是既有的闭合 schema 策略,不是本 PR 的缺陷,但因为是本 PR 新增了持久化键,值得在 release note 里写一句。
  3. 两个常量意外相遇。MATERIALIZED_IMAGE_TOKENS = 2_000maxResultEstimatedTokens: 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

@jackwenerjackwener left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
ContributorAuthor

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 5b339b2ca5:

  1. Docs — the three passages in the compaction draft (both languages) now describe what is on the branch: capacity is the declared window or nothing, an estimate only asks for a compaction, and a rejected request is compacted, retried once, then reported as context_overflow. Good catch; I had written those off as historical drafts.
  2. Downgrade note — added to CHANGELOG.md under 0.2.0: sessions this build writes do not open in earlier releases because of the lastRequestAnchor key, downgrading needs a runtime.sqlite copy taken beforehand, context_budget_exhausted is retired, epoch 94.
  3. Two constants — I went with putting the intent in code rather than tying the numbers together: a Tool Result carrying media is always a stale-archive candidate, and maxResultEstimatedTokens decides text-only results alone. Two tests pin it (a single image with a two-character reference is collected; 1,000 tokens of plain text is not). The identical comparison in active-tool-result-prune is left as is: that path never sees a type: 'content' image result, which is a separate gap.
  4. Description corrected to twenty-one, with the two usage-accounting tests named. Thanks for counting.

One small thing worth flagging so it does not trip up PR-3: with an undeclared window maxHistoryEstimatedTokens is still there rather than absent. defaultHistoryBudgetTokens returns 32,000 for providers other than DeepSeek, and the pre-turn gate uses it as a shaping threshold when no anchor is available. It can only ask for a compaction, never end a turn, which is why I left it alone for now; the PR description says so as well. When PR-3 replaces the count-bounded tail with a token bound, that constant looks like a natural thing to retire in the same pass, if you agree.

Looking forward to PR-3.

@hqhq1025hqhq1025 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Astro-Han
Astro-Han merged commit 92fa528 into mainSep 2, 2026
2 checks passed
@Astro-Han
Astro-Han deleted the fix/projection-artifact-cost branch September 2, 2026 08:17
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 2, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 2, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XLOver 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Astro-Han@jackwener@Joob1n@hqhq1025
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdict - #4486

Merged
Astro-Han merged 9 commits into
mainfrom
fix/projection-artifact-cost
Sep 2, 2026
Merged

fix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdict#4486
Astro-Han merged 9 commits into
mainfrom
fix/projection-artifact-cost

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Refs #4458: a turn ending in context_budget_exhausted with 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. estimateRuntimeEventsTokens measured the durable projection, where an artifact flattens to a placeholder string — about 276 tokens for a 500 KB screenshot. midTurnRequestPayloadChars is JSON.stringify(messages).length over 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. effectiveToolResultMedia is 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 resolveContextBudgetCapacity synthesized as 32_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. ContextBudgetCapacity and its source discriminator go with the fabrication that needed them.

Also deleted, each unreachable in production

  • Targeted image omission.overshootTokens came from lastRequestInputTokens, 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.
  • A duplicate inline-image predicate.isMaterializedMediaPart matched any file part with an object data, contradicting its own doc comment; folded onto the stricter isInlineImageFilePart.
  • Media pricing in active-tool-result-prune.extractPayload returns early for output.type === 'content', the shape every materialized image Tool Result has, so the term never executed.
  • Unread width/height on validateImageBytes, 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 inputTokens plus a signed char delta, but step 0 had no anchor at all, and the only pre-turn ruler (estimateRuntimeEventsTokens at 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_usage records gain lastRequestAnchor: { 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 (input on 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 same modelId and llmConnectionId via 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 contextRemaining is 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 maxHistoryEstimatedTokens gate 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, defaultHistoryBudgetTokens still 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_exhausted is retired at the decode boundary. Nothing produced it after the gate deletion, and nothing downstream distinguished it from context_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 to context_overflow; the CompleteEvent member, 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, so RUNTIME_HOST_COMPATIBILITY_EPOCH moves to 94.

Also removed as consumer-free: exceedsContextWindow, the coldStartChars estimate parameter (the whole payload against a zero anchor is the same formula), the never-produced midTurn.reserveTailEvents policy knob, and two single-caller wrappers folded into their call sites.

Refs #4458, #4283

Behaviour changes to review

  1. A request a local estimate judges too large now dispatches. On a genuinely oversized one the provider rejects, recovery compacts and retries once, and a second rejection ends the turn as a real error: reason: context_overflow, the class message Context 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.
  2. Images now carry real cost on the ledger, and a Tool Result carrying media is always a stale-archive candidate: after minRecentTurnsFull turns it becomes a re-readable placeholder whatever its reference text weighs. The maxResultEstimatedTokens gate (2,048) now decides only text-only results, so the coincidence of MATERIALIZED_IMAGE_TOKENS (2,000) sitting just under it cannot flip the outcome.
  3. A turn whose first request would already exceed the high-water mark now compacts before that request, as a pre_turn fold 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.
  4. token_usage records gain the optional lastRequestAnchor under the same closed-allowlist validator. Older builds reading a session this build wrote reject those records as malformed — the pre-existing cost of hasExactShape, not a new one, but it applies here too.
  5. Old sessions persisted with stopReason: context_budget_exhausted load as context_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 prompt is gone — its fixture suppresses usage to force a cold start and so emits no token_usage event, leaving the verdict as its only observable.

Review focus

MATERIALIZED_IMAGE_TOKENS = 2_000 is 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 = 2000 carries no citation and predates this PR.

Still open on #4283, out of scope: image Tool Results are structurally invisible to the active-turn prune (extractPayload returns nothing for type: '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 input stays 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 /compact usage 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 persisted context_budget_exhausted completion decodes as context_overflow.

Reproducing locally: npx tsx --test run directly against packages/runtime/src fails 13 filesystem-worker tests with bundle_not_found. That is an artifact of the invocation — the bundle lives in dist/workers/ and import.meta.url then resolves to src/workers/. npm test builds first and passes.

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — mechanism analysis, implementation, and tests, reviewed and verified by the author.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Behaviour changes to review above
  • No

@Astro-HanAstro-Han changed the title fix(runtime): price artifact media inside the context budgetfix(runtime): measure a materialized image by what it billsSep 1, 2026
@github-actionsgithub-actionsBot added the effort/M Under 500 readable lines label Sep 1, 2026
@Astro-Han
Astro-Han marked this pull request as ready for review September 1, 2026 16:21
@github-actionsgithub-actionsBot added effort/L Under 1000 readable lines and removed effort/M Under 500 readable lines labels Sep 1, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 600338c to 2895104CompareSeptember 1, 2026 17:43
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 2895104 to 5a4c7baCompareSeptember 1, 2026 18:54
@Astro-HanAstro-Han changed the title fix(runtime): measure a materialized image by what it billsfix(runtime): price images by pixel area, let the provider decide fit, anchor estimates on real usageSep 1, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 725cfc2 to 0d4528bCompareSeptember 2, 2026 03:05
@github-actionsgithub-actionsBot added effort/XL Over 1000 readable lines and removed effort/L Under 1000 readable lines labels Sep 2, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 0d4528b to 0e504b6CompareSeptember 2, 2026 05:07
An 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.
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 0e504b6 to eacbcceCompareSeptember 2, 2026 05:38
@Astro-HanAstro-Han changed the title fix(runtime): price images by pixel area, let the provider decide fit, anchor estimates on real usagefix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdictSep 2, 2026

@Joob1nJoob1n left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
  • persistedRequestAnchor scans 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. requestEstimateAnchor cold-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 maxHistoryEstimatedTokens is 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

  1. Docs still describe the deleted contract.docs/architecture/llm-compaction-events-log-projection-draft.md lines 183, 396 and 407 (and the zh-CN twin) still state the 32,000-token fallback and "terminates with context_budget_exhausted if still over budget". The PR touches no docs.
  2. Downgrade note for the release.decodeRuntimeEvent throws Invalid RuntimeEvent schema on an unknown key, so a session written by this build (with lastRequestAnchor) 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.
  3. Two constants meet by accident.MATERIALIZED_IMAGE_TOKENS = 2_000 and maxResultEstimatedTokens: 2_048 live 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.tscontext_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 低估最多少一次本该发生的主动折叠,随后由拒绝路径兜底。

非阻塞

  1. 文档仍描述已删除的契约。docs/architecture/llm-compaction-events-log-projection-draft.md 第 183、396、407 行(及 zh-CN 版本)仍写着 32,000-token 兜底与「仍超预算则以 context_budget_exhausted 终止」。PR 未改任何文档。
  2. 发布时的降级说明。decodeRuntimeEvent 遇到未知键会抛 Invalid RuntimeEvent schema,所以本版本写过的会话(含 lastRequestAnchor)在旧版本上会加载失败而不是跳过该记录。这是既有的闭合 schema 策略,不是本 PR 的缺陷,但因为是本 PR 新增了持久化键,值得在 release note 里写一句。
  3. 两个常量意外相遇。MATERIALIZED_IMAGE_TOKENS = 2_000maxResultEstimatedTokens: 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

@jackwenerjackwener left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
ContributorAuthor

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 5b339b2ca5:

  1. Docs — the three passages in the compaction draft (both languages) now describe what is on the branch: capacity is the declared window or nothing, an estimate only asks for a compaction, and a rejected request is compacted, retried once, then reported as context_overflow. Good catch; I had written those off as historical drafts.
  2. Downgrade note — added to CHANGELOG.md under 0.2.0: sessions this build writes do not open in earlier releases because of the lastRequestAnchor key, downgrading needs a runtime.sqlite copy taken beforehand, context_budget_exhausted is retired, epoch 94.
  3. Two constants — I went with putting the intent in code rather than tying the numbers together: a Tool Result carrying media is always a stale-archive candidate, and maxResultEstimatedTokens decides text-only results alone. Two tests pin it (a single image with a two-character reference is collected; 1,000 tokens of plain text is not). The identical comparison in active-tool-result-prune is left as is: that path never sees a type: 'content' image result, which is a separate gap.
  4. Description corrected to twenty-one, with the two usage-accounting tests named. Thanks for counting.

One small thing worth flagging so it does not trip up PR-3: with an undeclared window maxHistoryEstimatedTokens is still there rather than absent. defaultHistoryBudgetTokens returns 32,000 for providers other than DeepSeek, and the pre-turn gate uses it as a shaping threshold when no anchor is available. It can only ask for a compaction, never end a turn, which is why I left it alone for now; the PR description says so as well. When PR-3 replaces the count-bounded tail with a token bound, that constant looks like a natural thing to retire in the same pass, if you agree.

Looking forward to PR-3.

@hqhq1025hqhq1025 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Astro-Han
Astro-Han merged commit 92fa528 into mainSep 2, 2026
2 checks passed
@Astro-Han
Astro-Han deleted the fix/projection-artifact-cost branch September 2, 2026 08:17
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 2, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 2, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XLOver 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Astro-Han@jackwener@Joob1n@hqhq1025
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdict - #4486

Merged
Astro-Han merged 9 commits into
mainfrom
fix/projection-artifact-cost
Sep 2, 2026
Merged

fix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdict#4486
Astro-Han merged 9 commits into
mainfrom
fix/projection-artifact-cost

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Refs #4458: a turn ending in context_budget_exhausted with 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. estimateRuntimeEventsTokens measured the durable projection, where an artifact flattens to a placeholder string — about 276 tokens for a 500 KB screenshot. midTurnRequestPayloadChars is JSON.stringify(messages).length over 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. effectiveToolResultMedia is 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 resolveContextBudgetCapacity synthesized as 32_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. ContextBudgetCapacity and its source discriminator go with the fabrication that needed them.

Also deleted, each unreachable in production

  • Targeted image omission.overshootTokens came from lastRequestInputTokens, 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.
  • A duplicate inline-image predicate.isMaterializedMediaPart matched any file part with an object data, contradicting its own doc comment; folded onto the stricter isInlineImageFilePart.
  • Media pricing in active-tool-result-prune.extractPayload returns early for output.type === 'content', the shape every materialized image Tool Result has, so the term never executed.
  • Unread width/height on validateImageBytes, 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 inputTokens plus a signed char delta, but step 0 had no anchor at all, and the only pre-turn ruler (estimateRuntimeEventsTokens at 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_usage records gain lastRequestAnchor: { 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 (input on 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 same modelId and llmConnectionId via 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 contextRemaining is 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 maxHistoryEstimatedTokens gate 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, defaultHistoryBudgetTokens still 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_exhausted is retired at the decode boundary. Nothing produced it after the gate deletion, and nothing downstream distinguished it from context_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 to context_overflow; the CompleteEvent member, 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, so RUNTIME_HOST_COMPATIBILITY_EPOCH moves to 94.

Also removed as consumer-free: exceedsContextWindow, the coldStartChars estimate parameter (the whole payload against a zero anchor is the same formula), the never-produced midTurn.reserveTailEvents policy knob, and two single-caller wrappers folded into their call sites.

Refs #4458, #4283

Behaviour changes to review

  1. A request a local estimate judges too large now dispatches. On a genuinely oversized one the provider rejects, recovery compacts and retries once, and a second rejection ends the turn as a real error: reason: context_overflow, the class message Context 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.
  2. Images now carry real cost on the ledger, and a Tool Result carrying media is always a stale-archive candidate: after minRecentTurnsFull turns it becomes a re-readable placeholder whatever its reference text weighs. The maxResultEstimatedTokens gate (2,048) now decides only text-only results, so the coincidence of MATERIALIZED_IMAGE_TOKENS (2,000) sitting just under it cannot flip the outcome.
  3. A turn whose first request would already exceed the high-water mark now compacts before that request, as a pre_turn fold 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.
  4. token_usage records gain the optional lastRequestAnchor under the same closed-allowlist validator. Older builds reading a session this build wrote reject those records as malformed — the pre-existing cost of hasExactShape, not a new one, but it applies here too.
  5. Old sessions persisted with stopReason: context_budget_exhausted load as context_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 prompt is gone — its fixture suppresses usage to force a cold start and so emits no token_usage event, leaving the verdict as its only observable.

Review focus

MATERIALIZED_IMAGE_TOKENS = 2_000 is 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 = 2000 carries no citation and predates this PR.

Still open on #4283, out of scope: image Tool Results are structurally invisible to the active-turn prune (extractPayload returns nothing for type: '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 input stays 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 /compact usage 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 persisted context_budget_exhausted completion decodes as context_overflow.

Reproducing locally: npx tsx --test run directly against packages/runtime/src fails 13 filesystem-worker tests with bundle_not_found. That is an artifact of the invocation — the bundle lives in dist/workers/ and import.meta.url then resolves to src/workers/. npm test builds first and passes.

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — mechanism analysis, implementation, and tests, reviewed and verified by the author.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Behaviour changes to review above
  • No

@Astro-HanAstro-Han changed the title fix(runtime): price artifact media inside the context budgetfix(runtime): measure a materialized image by what it billsSep 1, 2026
@github-actionsgithub-actionsBot added the effort/M Under 500 readable lines label Sep 1, 2026
@Astro-Han
Astro-Han marked this pull request as ready for review September 1, 2026 16:21
@github-actionsgithub-actionsBot added effort/L Under 1000 readable lines and removed effort/M Under 500 readable lines labels Sep 1, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 600338c to 2895104CompareSeptember 1, 2026 17:43
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 2895104 to 5a4c7baCompareSeptember 1, 2026 18:54
@Astro-HanAstro-Han changed the title fix(runtime): measure a materialized image by what it billsfix(runtime): price images by pixel area, let the provider decide fit, anchor estimates on real usageSep 1, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 725cfc2 to 0d4528bCompareSeptember 2, 2026 03:05
@github-actionsgithub-actionsBot added effort/XL Over 1000 readable lines and removed effort/L Under 1000 readable lines labels Sep 2, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 0d4528b to 0e504b6CompareSeptember 2, 2026 05:07
An 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.
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 0e504b6 to eacbcceCompareSeptember 2, 2026 05:38
@Astro-HanAstro-Han changed the title fix(runtime): price images by pixel area, let the provider decide fit, anchor estimates on real usagefix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdictSep 2, 2026

@Joob1nJoob1n left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
  • persistedRequestAnchor scans 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. requestEstimateAnchor cold-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 maxHistoryEstimatedTokens is 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

  1. Docs still describe the deleted contract.docs/architecture/llm-compaction-events-log-projection-draft.md lines 183, 396 and 407 (and the zh-CN twin) still state the 32,000-token fallback and "terminates with context_budget_exhausted if still over budget". The PR touches no docs.
  2. Downgrade note for the release.decodeRuntimeEvent throws Invalid RuntimeEvent schema on an unknown key, so a session written by this build (with lastRequestAnchor) 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.
  3. Two constants meet by accident.MATERIALIZED_IMAGE_TOKENS = 2_000 and maxResultEstimatedTokens: 2_048 live 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.tscontext_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 低估最多少一次本该发生的主动折叠,随后由拒绝路径兜底。

非阻塞

  1. 文档仍描述已删除的契约。docs/architecture/llm-compaction-events-log-projection-draft.md 第 183、396、407 行(及 zh-CN 版本)仍写着 32,000-token 兜底与「仍超预算则以 context_budget_exhausted 终止」。PR 未改任何文档。
  2. 发布时的降级说明。decodeRuntimeEvent 遇到未知键会抛 Invalid RuntimeEvent schema,所以本版本写过的会话(含 lastRequestAnchor)在旧版本上会加载失败而不是跳过该记录。这是既有的闭合 schema 策略,不是本 PR 的缺陷,但因为是本 PR 新增了持久化键,值得在 release note 里写一句。
  3. 两个常量意外相遇。MATERIALIZED_IMAGE_TOKENS = 2_000maxResultEstimatedTokens: 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

@jackwenerjackwener left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
ContributorAuthor

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 5b339b2ca5:

  1. Docs — the three passages in the compaction draft (both languages) now describe what is on the branch: capacity is the declared window or nothing, an estimate only asks for a compaction, and a rejected request is compacted, retried once, then reported as context_overflow. Good catch; I had written those off as historical drafts.
  2. Downgrade note — added to CHANGELOG.md under 0.2.0: sessions this build writes do not open in earlier releases because of the lastRequestAnchor key, downgrading needs a runtime.sqlite copy taken beforehand, context_budget_exhausted is retired, epoch 94.
  3. Two constants — I went with putting the intent in code rather than tying the numbers together: a Tool Result carrying media is always a stale-archive candidate, and maxResultEstimatedTokens decides text-only results alone. Two tests pin it (a single image with a two-character reference is collected; 1,000 tokens of plain text is not). The identical comparison in active-tool-result-prune is left as is: that path never sees a type: 'content' image result, which is a separate gap.
  4. Description corrected to twenty-one, with the two usage-accounting tests named. Thanks for counting.

One small thing worth flagging so it does not trip up PR-3: with an undeclared window maxHistoryEstimatedTokens is still there rather than absent. defaultHistoryBudgetTokens returns 32,000 for providers other than DeepSeek, and the pre-turn gate uses it as a shaping threshold when no anchor is available. It can only ask for a compaction, never end a turn, which is why I left it alone for now; the PR description says so as well. When PR-3 replaces the count-bounded tail with a token bound, that constant looks like a natural thing to retire in the same pass, if you agree.

Looking forward to PR-3.

@hqhq1025hqhq1025 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Astro-Han
Astro-Han merged commit 92fa528 into mainSep 2, 2026
2 checks passed
@Astro-Han
Astro-Han deleted the fix/projection-artifact-cost branch September 2, 2026 08:17
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 2, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 2, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XLOver 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Astro-Han@jackwener@Joob1n@hqhq1025
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdict - #4486

Merged
Astro-Han merged 9 commits into
mainfrom
fix/projection-artifact-cost
Sep 2, 2026
Merged

fix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdict#4486
Astro-Han merged 9 commits into
mainfrom
fix/projection-artifact-cost

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Refs #4458: a turn ending in context_budget_exhausted with 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. estimateRuntimeEventsTokens measured the durable projection, where an artifact flattens to a placeholder string — about 276 tokens for a 500 KB screenshot. midTurnRequestPayloadChars is JSON.stringify(messages).length over 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. effectiveToolResultMedia is 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 resolveContextBudgetCapacity synthesized as 32_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. ContextBudgetCapacity and its source discriminator go with the fabrication that needed them.

Also deleted, each unreachable in production

  • Targeted image omission.overshootTokens came from lastRequestInputTokens, 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.
  • A duplicate inline-image predicate.isMaterializedMediaPart matched any file part with an object data, contradicting its own doc comment; folded onto the stricter isInlineImageFilePart.
  • Media pricing in active-tool-result-prune.extractPayload returns early for output.type === 'content', the shape every materialized image Tool Result has, so the term never executed.
  • Unread width/height on validateImageBytes, 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 inputTokens plus a signed char delta, but step 0 had no anchor at all, and the only pre-turn ruler (estimateRuntimeEventsTokens at 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_usage records gain lastRequestAnchor: { 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 (input on 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 same modelId and llmConnectionId via 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 contextRemaining is 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 maxHistoryEstimatedTokens gate 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, defaultHistoryBudgetTokens still 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_exhausted is retired at the decode boundary. Nothing produced it after the gate deletion, and nothing downstream distinguished it from context_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 to context_overflow; the CompleteEvent member, 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, so RUNTIME_HOST_COMPATIBILITY_EPOCH moves to 94.

Also removed as consumer-free: exceedsContextWindow, the coldStartChars estimate parameter (the whole payload against a zero anchor is the same formula), the never-produced midTurn.reserveTailEvents policy knob, and two single-caller wrappers folded into their call sites.

Refs #4458, #4283

Behaviour changes to review

  1. A request a local estimate judges too large now dispatches. On a genuinely oversized one the provider rejects, recovery compacts and retries once, and a second rejection ends the turn as a real error: reason: context_overflow, the class message Context 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.
  2. Images now carry real cost on the ledger, and a Tool Result carrying media is always a stale-archive candidate: after minRecentTurnsFull turns it becomes a re-readable placeholder whatever its reference text weighs. The maxResultEstimatedTokens gate (2,048) now decides only text-only results, so the coincidence of MATERIALIZED_IMAGE_TOKENS (2,000) sitting just under it cannot flip the outcome.
  3. A turn whose first request would already exceed the high-water mark now compacts before that request, as a pre_turn fold 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.
  4. token_usage records gain the optional lastRequestAnchor under the same closed-allowlist validator. Older builds reading a session this build wrote reject those records as malformed — the pre-existing cost of hasExactShape, not a new one, but it applies here too.
  5. Old sessions persisted with stopReason: context_budget_exhausted load as context_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 prompt is gone — its fixture suppresses usage to force a cold start and so emits no token_usage event, leaving the verdict as its only observable.

Review focus

MATERIALIZED_IMAGE_TOKENS = 2_000 is 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 = 2000 carries no citation and predates this PR.

Still open on #4283, out of scope: image Tool Results are structurally invisible to the active-turn prune (extractPayload returns nothing for type: '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 input stays 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 /compact usage 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 persisted context_budget_exhausted completion decodes as context_overflow.

Reproducing locally: npx tsx --test run directly against packages/runtime/src fails 13 filesystem-worker tests with bundle_not_found. That is an artifact of the invocation — the bundle lives in dist/workers/ and import.meta.url then resolves to src/workers/. npm test builds first and passes.

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — mechanism analysis, implementation, and tests, reviewed and verified by the author.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Behaviour changes to review above
  • No

@Astro-HanAstro-Han changed the title fix(runtime): price artifact media inside the context budgetfix(runtime): measure a materialized image by what it billsSep 1, 2026
@github-actionsgithub-actionsBot added the effort/M Under 500 readable lines label Sep 1, 2026
@Astro-Han
Astro-Han marked this pull request as ready for review September 1, 2026 16:21
@github-actionsgithub-actionsBot added effort/L Under 1000 readable lines and removed effort/M Under 500 readable lines labels Sep 1, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 600338c to 2895104CompareSeptember 1, 2026 17:43
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 2895104 to 5a4c7baCompareSeptember 1, 2026 18:54
@Astro-HanAstro-Han changed the title fix(runtime): measure a materialized image by what it billsfix(runtime): price images by pixel area, let the provider decide fit, anchor estimates on real usageSep 1, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 725cfc2 to 0d4528bCompareSeptember 2, 2026 03:05
@github-actionsgithub-actionsBot added effort/XL Over 1000 readable lines and removed effort/L Under 1000 readable lines labels Sep 2, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 0d4528b to 0e504b6CompareSeptember 2, 2026 05:07
An 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.
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 0e504b6 to eacbcceCompareSeptember 2, 2026 05:38
@Astro-HanAstro-Han changed the title fix(runtime): price images by pixel area, let the provider decide fit, anchor estimates on real usagefix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdictSep 2, 2026

@Joob1nJoob1n left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
  • persistedRequestAnchor scans 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. requestEstimateAnchor cold-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 maxHistoryEstimatedTokens is 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

  1. Docs still describe the deleted contract.docs/architecture/llm-compaction-events-log-projection-draft.md lines 183, 396 and 407 (and the zh-CN twin) still state the 32,000-token fallback and "terminates with context_budget_exhausted if still over budget". The PR touches no docs.
  2. Downgrade note for the release.decodeRuntimeEvent throws Invalid RuntimeEvent schema on an unknown key, so a session written by this build (with lastRequestAnchor) 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.
  3. Two constants meet by accident.MATERIALIZED_IMAGE_TOKENS = 2_000 and maxResultEstimatedTokens: 2_048 live 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.tscontext_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 低估最多少一次本该发生的主动折叠,随后由拒绝路径兜底。

非阻塞

  1. 文档仍描述已删除的契约。docs/architecture/llm-compaction-events-log-projection-draft.md 第 183、396、407 行(及 zh-CN 版本)仍写着 32,000-token 兜底与「仍超预算则以 context_budget_exhausted 终止」。PR 未改任何文档。
  2. 发布时的降级说明。decodeRuntimeEvent 遇到未知键会抛 Invalid RuntimeEvent schema,所以本版本写过的会话(含 lastRequestAnchor)在旧版本上会加载失败而不是跳过该记录。这是既有的闭合 schema 策略,不是本 PR 的缺陷,但因为是本 PR 新增了持久化键,值得在 release note 里写一句。
  3. 两个常量意外相遇。MATERIALIZED_IMAGE_TOKENS = 2_000maxResultEstimatedTokens: 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

@jackwenerjackwener left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
ContributorAuthor

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 5b339b2ca5:

  1. Docs — the three passages in the compaction draft (both languages) now describe what is on the branch: capacity is the declared window or nothing, an estimate only asks for a compaction, and a rejected request is compacted, retried once, then reported as context_overflow. Good catch; I had written those off as historical drafts.
  2. Downgrade note — added to CHANGELOG.md under 0.2.0: sessions this build writes do not open in earlier releases because of the lastRequestAnchor key, downgrading needs a runtime.sqlite copy taken beforehand, context_budget_exhausted is retired, epoch 94.
  3. Two constants — I went with putting the intent in code rather than tying the numbers together: a Tool Result carrying media is always a stale-archive candidate, and maxResultEstimatedTokens decides text-only results alone. Two tests pin it (a single image with a two-character reference is collected; 1,000 tokens of plain text is not). The identical comparison in active-tool-result-prune is left as is: that path never sees a type: 'content' image result, which is a separate gap.
  4. Description corrected to twenty-one, with the two usage-accounting tests named. Thanks for counting.

One small thing worth flagging so it does not trip up PR-3: with an undeclared window maxHistoryEstimatedTokens is still there rather than absent. defaultHistoryBudgetTokens returns 32,000 for providers other than DeepSeek, and the pre-turn gate uses it as a shaping threshold when no anchor is available. It can only ask for a compaction, never end a turn, which is why I left it alone for now; the PR description says so as well. When PR-3 replaces the count-bounded tail with a token bound, that constant looks like a natural thing to retire in the same pass, if you agree.

Looking forward to PR-3.

@hqhq1025hqhq1025 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Astro-Han
Astro-Han merged commit 92fa528 into mainSep 2, 2026
2 checks passed
@Astro-Han
Astro-Han deleted the fix/projection-artifact-cost branch September 2, 2026 08:17
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 2, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 2, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XLOver 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Astro-Han@jackwener@Joob1n@hqhq1025
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdict - #4486

Merged
Astro-Han merged 9 commits into
mainfrom
fix/projection-artifact-cost
Sep 2, 2026
Merged

fix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdict#4486
Astro-Han merged 9 commits into
mainfrom
fix/projection-artifact-cost

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Refs #4458: a turn ending in context_budget_exhausted with 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. estimateRuntimeEventsTokens measured the durable projection, where an artifact flattens to a placeholder string — about 276 tokens for a 500 KB screenshot. midTurnRequestPayloadChars is JSON.stringify(messages).length over 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. effectiveToolResultMedia is 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 resolveContextBudgetCapacity synthesized as 32_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. ContextBudgetCapacity and its source discriminator go with the fabrication that needed them.

Also deleted, each unreachable in production

  • Targeted image omission.overshootTokens came from lastRequestInputTokens, 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.
  • A duplicate inline-image predicate.isMaterializedMediaPart matched any file part with an object data, contradicting its own doc comment; folded onto the stricter isInlineImageFilePart.
  • Media pricing in active-tool-result-prune.extractPayload returns early for output.type === 'content', the shape every materialized image Tool Result has, so the term never executed.
  • Unread width/height on validateImageBytes, 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 inputTokens plus a signed char delta, but step 0 had no anchor at all, and the only pre-turn ruler (estimateRuntimeEventsTokens at 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_usage records gain lastRequestAnchor: { 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 (input on 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 same modelId and llmConnectionId via 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 contextRemaining is 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 maxHistoryEstimatedTokens gate 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, defaultHistoryBudgetTokens still 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_exhausted is retired at the decode boundary. Nothing produced it after the gate deletion, and nothing downstream distinguished it from context_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 to context_overflow; the CompleteEvent member, 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, so RUNTIME_HOST_COMPATIBILITY_EPOCH moves to 94.

Also removed as consumer-free: exceedsContextWindow, the coldStartChars estimate parameter (the whole payload against a zero anchor is the same formula), the never-produced midTurn.reserveTailEvents policy knob, and two single-caller wrappers folded into their call sites.

Refs #4458, #4283

Behaviour changes to review

  1. A request a local estimate judges too large now dispatches. On a genuinely oversized one the provider rejects, recovery compacts and retries once, and a second rejection ends the turn as a real error: reason: context_overflow, the class message Context 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.
  2. Images now carry real cost on the ledger, and a Tool Result carrying media is always a stale-archive candidate: after minRecentTurnsFull turns it becomes a re-readable placeholder whatever its reference text weighs. The maxResultEstimatedTokens gate (2,048) now decides only text-only results, so the coincidence of MATERIALIZED_IMAGE_TOKENS (2,000) sitting just under it cannot flip the outcome.
  3. A turn whose first request would already exceed the high-water mark now compacts before that request, as a pre_turn fold 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.
  4. token_usage records gain the optional lastRequestAnchor under the same closed-allowlist validator. Older builds reading a session this build wrote reject those records as malformed — the pre-existing cost of hasExactShape, not a new one, but it applies here too.
  5. Old sessions persisted with stopReason: context_budget_exhausted load as context_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 prompt is gone — its fixture suppresses usage to force a cold start and so emits no token_usage event, leaving the verdict as its only observable.

Review focus

MATERIALIZED_IMAGE_TOKENS = 2_000 is 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 = 2000 carries no citation and predates this PR.

Still open on #4283, out of scope: image Tool Results are structurally invisible to the active-turn prune (extractPayload returns nothing for type: '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 input stays 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 /compact usage 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 persisted context_budget_exhausted completion decodes as context_overflow.

Reproducing locally: npx tsx --test run directly against packages/runtime/src fails 13 filesystem-worker tests with bundle_not_found. That is an artifact of the invocation — the bundle lives in dist/workers/ and import.meta.url then resolves to src/workers/. npm test builds first and passes.

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — mechanism analysis, implementation, and tests, reviewed and verified by the author.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Behaviour changes to review above
  • No

@Astro-HanAstro-Han changed the title fix(runtime): price artifact media inside the context budgetfix(runtime): measure a materialized image by what it billsSep 1, 2026
@github-actionsgithub-actionsBot added the effort/M Under 500 readable lines label Sep 1, 2026
@Astro-Han
Astro-Han marked this pull request as ready for review September 1, 2026 16:21
@github-actionsgithub-actionsBot added effort/L Under 1000 readable lines and removed effort/M Under 500 readable lines labels Sep 1, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 600338c to 2895104CompareSeptember 1, 2026 17:43
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 2895104 to 5a4c7baCompareSeptember 1, 2026 18:54
@Astro-HanAstro-Han changed the title fix(runtime): measure a materialized image by what it billsfix(runtime): price images by pixel area, let the provider decide fit, anchor estimates on real usageSep 1, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 725cfc2 to 0d4528bCompareSeptember 2, 2026 03:05
@github-actionsgithub-actionsBot added effort/XL Over 1000 readable lines and removed effort/L Under 1000 readable lines labels Sep 2, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 0d4528b to 0e504b6CompareSeptember 2, 2026 05:07
An 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.
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 0e504b6 to eacbcceCompareSeptember 2, 2026 05:38
@Astro-HanAstro-Han changed the title fix(runtime): price images by pixel area, let the provider decide fit, anchor estimates on real usagefix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdictSep 2, 2026

@Joob1nJoob1n left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
  • persistedRequestAnchor scans 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. requestEstimateAnchor cold-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 maxHistoryEstimatedTokens is 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

  1. Docs still describe the deleted contract.docs/architecture/llm-compaction-events-log-projection-draft.md lines 183, 396 and 407 (and the zh-CN twin) still state the 32,000-token fallback and "terminates with context_budget_exhausted if still over budget". The PR touches no docs.
  2. Downgrade note for the release.decodeRuntimeEvent throws Invalid RuntimeEvent schema on an unknown key, so a session written by this build (with lastRequestAnchor) 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.
  3. Two constants meet by accident.MATERIALIZED_IMAGE_TOKENS = 2_000 and maxResultEstimatedTokens: 2_048 live 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.tscontext_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 低估最多少一次本该发生的主动折叠,随后由拒绝路径兜底。

非阻塞

  1. 文档仍描述已删除的契约。docs/architecture/llm-compaction-events-log-projection-draft.md 第 183、396、407 行(及 zh-CN 版本)仍写着 32,000-token 兜底与「仍超预算则以 context_budget_exhausted 终止」。PR 未改任何文档。
  2. 发布时的降级说明。decodeRuntimeEvent 遇到未知键会抛 Invalid RuntimeEvent schema,所以本版本写过的会话(含 lastRequestAnchor)在旧版本上会加载失败而不是跳过该记录。这是既有的闭合 schema 策略,不是本 PR 的缺陷,但因为是本 PR 新增了持久化键,值得在 release note 里写一句。
  3. 两个常量意外相遇。MATERIALIZED_IMAGE_TOKENS = 2_000maxResultEstimatedTokens: 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

@jackwenerjackwener left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
ContributorAuthor

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 5b339b2ca5:

  1. Docs — the three passages in the compaction draft (both languages) now describe what is on the branch: capacity is the declared window or nothing, an estimate only asks for a compaction, and a rejected request is compacted, retried once, then reported as context_overflow. Good catch; I had written those off as historical drafts.
  2. Downgrade note — added to CHANGELOG.md under 0.2.0: sessions this build writes do not open in earlier releases because of the lastRequestAnchor key, downgrading needs a runtime.sqlite copy taken beforehand, context_budget_exhausted is retired, epoch 94.
  3. Two constants — I went with putting the intent in code rather than tying the numbers together: a Tool Result carrying media is always a stale-archive candidate, and maxResultEstimatedTokens decides text-only results alone. Two tests pin it (a single image with a two-character reference is collected; 1,000 tokens of plain text is not). The identical comparison in active-tool-result-prune is left as is: that path never sees a type: 'content' image result, which is a separate gap.
  4. Description corrected to twenty-one, with the two usage-accounting tests named. Thanks for counting.

One small thing worth flagging so it does not trip up PR-3: with an undeclared window maxHistoryEstimatedTokens is still there rather than absent. defaultHistoryBudgetTokens returns 32,000 for providers other than DeepSeek, and the pre-turn gate uses it as a shaping threshold when no anchor is available. It can only ask for a compaction, never end a turn, which is why I left it alone for now; the PR description says so as well. When PR-3 replaces the count-bounded tail with a token bound, that constant looks like a natural thing to retire in the same pass, if you agree.

Looking forward to PR-3.

@hqhq1025hqhq1025 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Astro-Han
Astro-Han merged commit 92fa528 into mainSep 2, 2026
2 checks passed
@Astro-Han
Astro-Han deleted the fix/projection-artifact-cost branch September 2, 2026 08:17
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 2, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 2, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XLOver 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Astro-Han@jackwener@Joob1n@hqhq1025
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdict - #4486

Merged
Astro-Han merged 9 commits into
mainfrom
fix/projection-artifact-cost
Sep 2, 2026
Merged

fix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdict#4486
Astro-Han merged 9 commits into
mainfrom
fix/projection-artifact-cost

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Refs #4458: a turn ending in context_budget_exhausted with 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. estimateRuntimeEventsTokens measured the durable projection, where an artifact flattens to a placeholder string — about 276 tokens for a 500 KB screenshot. midTurnRequestPayloadChars is JSON.stringify(messages).length over 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. effectiveToolResultMedia is 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 resolveContextBudgetCapacity synthesized as 32_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. ContextBudgetCapacity and its source discriminator go with the fabrication that needed them.

Also deleted, each unreachable in production

  • Targeted image omission.overshootTokens came from lastRequestInputTokens, 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.
  • A duplicate inline-image predicate.isMaterializedMediaPart matched any file part with an object data, contradicting its own doc comment; folded onto the stricter isInlineImageFilePart.
  • Media pricing in active-tool-result-prune.extractPayload returns early for output.type === 'content', the shape every materialized image Tool Result has, so the term never executed.
  • Unread width/height on validateImageBytes, 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 inputTokens plus a signed char delta, but step 0 had no anchor at all, and the only pre-turn ruler (estimateRuntimeEventsTokens at 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_usage records gain lastRequestAnchor: { 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 (input on 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 same modelId and llmConnectionId via 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 contextRemaining is 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 maxHistoryEstimatedTokens gate 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, defaultHistoryBudgetTokens still 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_exhausted is retired at the decode boundary. Nothing produced it after the gate deletion, and nothing downstream distinguished it from context_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 to context_overflow; the CompleteEvent member, 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, so RUNTIME_HOST_COMPATIBILITY_EPOCH moves to 94.

Also removed as consumer-free: exceedsContextWindow, the coldStartChars estimate parameter (the whole payload against a zero anchor is the same formula), the never-produced midTurn.reserveTailEvents policy knob, and two single-caller wrappers folded into their call sites.

Refs #4458, #4283

Behaviour changes to review

  1. A request a local estimate judges too large now dispatches. On a genuinely oversized one the provider rejects, recovery compacts and retries once, and a second rejection ends the turn as a real error: reason: context_overflow, the class message Context 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.
  2. Images now carry real cost on the ledger, and a Tool Result carrying media is always a stale-archive candidate: after minRecentTurnsFull turns it becomes a re-readable placeholder whatever its reference text weighs. The maxResultEstimatedTokens gate (2,048) now decides only text-only results, so the coincidence of MATERIALIZED_IMAGE_TOKENS (2,000) sitting just under it cannot flip the outcome.
  3. A turn whose first request would already exceed the high-water mark now compacts before that request, as a pre_turn fold 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.
  4. token_usage records gain the optional lastRequestAnchor under the same closed-allowlist validator. Older builds reading a session this build wrote reject those records as malformed — the pre-existing cost of hasExactShape, not a new one, but it applies here too.
  5. Old sessions persisted with stopReason: context_budget_exhausted load as context_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 prompt is gone — its fixture suppresses usage to force a cold start and so emits no token_usage event, leaving the verdict as its only observable.

Review focus

MATERIALIZED_IMAGE_TOKENS = 2_000 is 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 = 2000 carries no citation and predates this PR.

Still open on #4283, out of scope: image Tool Results are structurally invisible to the active-turn prune (extractPayload returns nothing for type: '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 input stays 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 /compact usage 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 persisted context_budget_exhausted completion decodes as context_overflow.

Reproducing locally: npx tsx --test run directly against packages/runtime/src fails 13 filesystem-worker tests with bundle_not_found. That is an artifact of the invocation — the bundle lives in dist/workers/ and import.meta.url then resolves to src/workers/. npm test builds first and passes.

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — mechanism analysis, implementation, and tests, reviewed and verified by the author.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Behaviour changes to review above
  • No

@Astro-HanAstro-Han changed the title fix(runtime): price artifact media inside the context budgetfix(runtime): measure a materialized image by what it billsSep 1, 2026
@github-actionsgithub-actionsBot added the effort/M Under 500 readable lines label Sep 1, 2026
@Astro-Han
Astro-Han marked this pull request as ready for review September 1, 2026 16:21
@github-actionsgithub-actionsBot added effort/L Under 1000 readable lines and removed effort/M Under 500 readable lines labels Sep 1, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 600338c to 2895104CompareSeptember 1, 2026 17:43
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 2895104 to 5a4c7baCompareSeptember 1, 2026 18:54
@Astro-HanAstro-Han changed the title fix(runtime): measure a materialized image by what it billsfix(runtime): price images by pixel area, let the provider decide fit, anchor estimates on real usageSep 1, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 725cfc2 to 0d4528bCompareSeptember 2, 2026 03:05
@github-actionsgithub-actionsBot added effort/XL Over 1000 readable lines and removed effort/L Under 1000 readable lines labels Sep 2, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 0d4528b to 0e504b6CompareSeptember 2, 2026 05:07
An 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.
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 0e504b6 to eacbcceCompareSeptember 2, 2026 05:38
@Astro-HanAstro-Han changed the title fix(runtime): price images by pixel area, let the provider decide fit, anchor estimates on real usagefix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdictSep 2, 2026

@Joob1nJoob1n left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
  • persistedRequestAnchor scans 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. requestEstimateAnchor cold-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 maxHistoryEstimatedTokens is 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

  1. Docs still describe the deleted contract.docs/architecture/llm-compaction-events-log-projection-draft.md lines 183, 396 and 407 (and the zh-CN twin) still state the 32,000-token fallback and "terminates with context_budget_exhausted if still over budget". The PR touches no docs.
  2. Downgrade note for the release.decodeRuntimeEvent throws Invalid RuntimeEvent schema on an unknown key, so a session written by this build (with lastRequestAnchor) 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.
  3. Two constants meet by accident.MATERIALIZED_IMAGE_TOKENS = 2_000 and maxResultEstimatedTokens: 2_048 live 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.tscontext_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 低估最多少一次本该发生的主动折叠,随后由拒绝路径兜底。

非阻塞

  1. 文档仍描述已删除的契约。docs/architecture/llm-compaction-events-log-projection-draft.md 第 183、396、407 行(及 zh-CN 版本)仍写着 32,000-token 兜底与「仍超预算则以 context_budget_exhausted 终止」。PR 未改任何文档。
  2. 发布时的降级说明。decodeRuntimeEvent 遇到未知键会抛 Invalid RuntimeEvent schema,所以本版本写过的会话(含 lastRequestAnchor)在旧版本上会加载失败而不是跳过该记录。这是既有的闭合 schema 策略,不是本 PR 的缺陷,但因为是本 PR 新增了持久化键,值得在 release note 里写一句。
  3. 两个常量意外相遇。MATERIALIZED_IMAGE_TOKENS = 2_000maxResultEstimatedTokens: 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

@jackwenerjackwener left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
ContributorAuthor

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 5b339b2ca5:

  1. Docs — the three passages in the compaction draft (both languages) now describe what is on the branch: capacity is the declared window or nothing, an estimate only asks for a compaction, and a rejected request is compacted, retried once, then reported as context_overflow. Good catch; I had written those off as historical drafts.
  2. Downgrade note — added to CHANGELOG.md under 0.2.0: sessions this build writes do not open in earlier releases because of the lastRequestAnchor key, downgrading needs a runtime.sqlite copy taken beforehand, context_budget_exhausted is retired, epoch 94.
  3. Two constants — I went with putting the intent in code rather than tying the numbers together: a Tool Result carrying media is always a stale-archive candidate, and maxResultEstimatedTokens decides text-only results alone. Two tests pin it (a single image with a two-character reference is collected; 1,000 tokens of plain text is not). The identical comparison in active-tool-result-prune is left as is: that path never sees a type: 'content' image result, which is a separate gap.
  4. Description corrected to twenty-one, with the two usage-accounting tests named. Thanks for counting.

One small thing worth flagging so it does not trip up PR-3: with an undeclared window maxHistoryEstimatedTokens is still there rather than absent. defaultHistoryBudgetTokens returns 32,000 for providers other than DeepSeek, and the pre-turn gate uses it as a shaping threshold when no anchor is available. It can only ask for a compaction, never end a turn, which is why I left it alone for now; the PR description says so as well. When PR-3 replaces the count-bounded tail with a token bound, that constant looks like a natural thing to retire in the same pass, if you agree.

Looking forward to PR-3.

@hqhq1025hqhq1025 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Astro-Han
Astro-Han merged commit 92fa528 into mainSep 2, 2026
2 checks passed
@Astro-Han
Astro-Han deleted the fix/projection-artifact-cost branch September 2, 2026 08:17
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 2, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 2, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XLOver 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Astro-Han@jackwener@Joob1n@hqhq1025
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdict - #4486

Merged
Astro-Han merged 9 commits into
mainfrom
fix/projection-artifact-cost
Sep 2, 2026
Merged

fix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdict#4486
Astro-Han merged 9 commits into
mainfrom
fix/projection-artifact-cost

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Refs #4458: a turn ending in context_budget_exhausted with 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. estimateRuntimeEventsTokens measured the durable projection, where an artifact flattens to a placeholder string — about 276 tokens for a 500 KB screenshot. midTurnRequestPayloadChars is JSON.stringify(messages).length over 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. effectiveToolResultMedia is 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 resolveContextBudgetCapacity synthesized as 32_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. ContextBudgetCapacity and its source discriminator go with the fabrication that needed them.

Also deleted, each unreachable in production

  • Targeted image omission.overshootTokens came from lastRequestInputTokens, 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.
  • A duplicate inline-image predicate.isMaterializedMediaPart matched any file part with an object data, contradicting its own doc comment; folded onto the stricter isInlineImageFilePart.
  • Media pricing in active-tool-result-prune.extractPayload returns early for output.type === 'content', the shape every materialized image Tool Result has, so the term never executed.
  • Unread width/height on validateImageBytes, 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 inputTokens plus a signed char delta, but step 0 had no anchor at all, and the only pre-turn ruler (estimateRuntimeEventsTokens at 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_usage records gain lastRequestAnchor: { 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 (input on 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 same modelId and llmConnectionId via 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 contextRemaining is 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 maxHistoryEstimatedTokens gate 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, defaultHistoryBudgetTokens still 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_exhausted is retired at the decode boundary. Nothing produced it after the gate deletion, and nothing downstream distinguished it from context_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 to context_overflow; the CompleteEvent member, 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, so RUNTIME_HOST_COMPATIBILITY_EPOCH moves to 94.

Also removed as consumer-free: exceedsContextWindow, the coldStartChars estimate parameter (the whole payload against a zero anchor is the same formula), the never-produced midTurn.reserveTailEvents policy knob, and two single-caller wrappers folded into their call sites.

Refs #4458, #4283

Behaviour changes to review

  1. A request a local estimate judges too large now dispatches. On a genuinely oversized one the provider rejects, recovery compacts and retries once, and a second rejection ends the turn as a real error: reason: context_overflow, the class message Context 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.
  2. Images now carry real cost on the ledger, and a Tool Result carrying media is always a stale-archive candidate: after minRecentTurnsFull turns it becomes a re-readable placeholder whatever its reference text weighs. The maxResultEstimatedTokens gate (2,048) now decides only text-only results, so the coincidence of MATERIALIZED_IMAGE_TOKENS (2,000) sitting just under it cannot flip the outcome.
  3. A turn whose first request would already exceed the high-water mark now compacts before that request, as a pre_turn fold 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.
  4. token_usage records gain the optional lastRequestAnchor under the same closed-allowlist validator. Older builds reading a session this build wrote reject those records as malformed — the pre-existing cost of hasExactShape, not a new one, but it applies here too.
  5. Old sessions persisted with stopReason: context_budget_exhausted load as context_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 prompt is gone — its fixture suppresses usage to force a cold start and so emits no token_usage event, leaving the verdict as its only observable.

Review focus

MATERIALIZED_IMAGE_TOKENS = 2_000 is 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 = 2000 carries no citation and predates this PR.

Still open on #4283, out of scope: image Tool Results are structurally invisible to the active-turn prune (extractPayload returns nothing for type: '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 input stays 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 /compact usage 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 persisted context_budget_exhausted completion decodes as context_overflow.

Reproducing locally: npx tsx --test run directly against packages/runtime/src fails 13 filesystem-worker tests with bundle_not_found. That is an artifact of the invocation — the bundle lives in dist/workers/ and import.meta.url then resolves to src/workers/. npm test builds first and passes.

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — mechanism analysis, implementation, and tests, reviewed and verified by the author.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Behaviour changes to review above
  • No

@Astro-HanAstro-Han changed the title fix(runtime): price artifact media inside the context budgetfix(runtime): measure a materialized image by what it billsSep 1, 2026
@github-actionsgithub-actionsBot added the effort/M Under 500 readable lines label Sep 1, 2026
@Astro-Han
Astro-Han marked this pull request as ready for review September 1, 2026 16:21
@github-actionsgithub-actionsBot added effort/L Under 1000 readable lines and removed effort/M Under 500 readable lines labels Sep 1, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 600338c to 2895104CompareSeptember 1, 2026 17:43
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 2895104 to 5a4c7baCompareSeptember 1, 2026 18:54
@Astro-HanAstro-Han changed the title fix(runtime): measure a materialized image by what it billsfix(runtime): price images by pixel area, let the provider decide fit, anchor estimates on real usageSep 1, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 725cfc2 to 0d4528bCompareSeptember 2, 2026 03:05
@github-actionsgithub-actionsBot added effort/XL Over 1000 readable lines and removed effort/L Under 1000 readable lines labels Sep 2, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 0d4528b to 0e504b6CompareSeptember 2, 2026 05:07
An 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.
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 0e504b6 to eacbcceCompareSeptember 2, 2026 05:38
@Astro-HanAstro-Han changed the title fix(runtime): price images by pixel area, let the provider decide fit, anchor estimates on real usagefix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdictSep 2, 2026

@Joob1nJoob1n left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
  • persistedRequestAnchor scans 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. requestEstimateAnchor cold-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 maxHistoryEstimatedTokens is 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

  1. Docs still describe the deleted contract.docs/architecture/llm-compaction-events-log-projection-draft.md lines 183, 396 and 407 (and the zh-CN twin) still state the 32,000-token fallback and "terminates with context_budget_exhausted if still over budget". The PR touches no docs.
  2. Downgrade note for the release.decodeRuntimeEvent throws Invalid RuntimeEvent schema on an unknown key, so a session written by this build (with lastRequestAnchor) 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.
  3. Two constants meet by accident.MATERIALIZED_IMAGE_TOKENS = 2_000 and maxResultEstimatedTokens: 2_048 live 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.tscontext_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 低估最多少一次本该发生的主动折叠,随后由拒绝路径兜底。

非阻塞

  1. 文档仍描述已删除的契约。docs/architecture/llm-compaction-events-log-projection-draft.md 第 183、396、407 行(及 zh-CN 版本)仍写着 32,000-token 兜底与「仍超预算则以 context_budget_exhausted 终止」。PR 未改任何文档。
  2. 发布时的降级说明。decodeRuntimeEvent 遇到未知键会抛 Invalid RuntimeEvent schema,所以本版本写过的会话(含 lastRequestAnchor)在旧版本上会加载失败而不是跳过该记录。这是既有的闭合 schema 策略,不是本 PR 的缺陷,但因为是本 PR 新增了持久化键,值得在 release note 里写一句。
  3. 两个常量意外相遇。MATERIALIZED_IMAGE_TOKENS = 2_000maxResultEstimatedTokens: 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

@jackwenerjackwener left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
ContributorAuthor

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 5b339b2ca5:

  1. Docs — the three passages in the compaction draft (both languages) now describe what is on the branch: capacity is the declared window or nothing, an estimate only asks for a compaction, and a rejected request is compacted, retried once, then reported as context_overflow. Good catch; I had written those off as historical drafts.
  2. Downgrade note — added to CHANGELOG.md under 0.2.0: sessions this build writes do not open in earlier releases because of the lastRequestAnchor key, downgrading needs a runtime.sqlite copy taken beforehand, context_budget_exhausted is retired, epoch 94.
  3. Two constants — I went with putting the intent in code rather than tying the numbers together: a Tool Result carrying media is always a stale-archive candidate, and maxResultEstimatedTokens decides text-only results alone. Two tests pin it (a single image with a two-character reference is collected; 1,000 tokens of plain text is not). The identical comparison in active-tool-result-prune is left as is: that path never sees a type: 'content' image result, which is a separate gap.
  4. Description corrected to twenty-one, with the two usage-accounting tests named. Thanks for counting.

One small thing worth flagging so it does not trip up PR-3: with an undeclared window maxHistoryEstimatedTokens is still there rather than absent. defaultHistoryBudgetTokens returns 32,000 for providers other than DeepSeek, and the pre-turn gate uses it as a shaping threshold when no anchor is available. It can only ask for a compaction, never end a turn, which is why I left it alone for now; the PR description says so as well. When PR-3 replaces the count-bounded tail with a token bound, that constant looks like a natural thing to retire in the same pass, if you agree.

Looking forward to PR-3.

@hqhq1025hqhq1025 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Astro-Han
Astro-Han merged commit 92fa528 into mainSep 2, 2026
2 checks passed
@Astro-Han
Astro-Han deleted the fix/projection-artifact-cost branch September 2, 2026 08:17
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 2, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 2, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XLOver 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Astro-Han@jackwener@Joob1n@hqhq1025
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdict - #4486

Merged
Astro-Han merged 9 commits into
mainfrom
fix/projection-artifact-cost
Sep 2, 2026
Merged

fix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdict#4486
Astro-Han merged 9 commits into
mainfrom
fix/projection-artifact-cost

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Refs #4458: a turn ending in context_budget_exhausted with 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. estimateRuntimeEventsTokens measured the durable projection, where an artifact flattens to a placeholder string — about 276 tokens for a 500 KB screenshot. midTurnRequestPayloadChars is JSON.stringify(messages).length over 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. effectiveToolResultMedia is 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 resolveContextBudgetCapacity synthesized as 32_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. ContextBudgetCapacity and its source discriminator go with the fabrication that needed them.

Also deleted, each unreachable in production

  • Targeted image omission.overshootTokens came from lastRequestInputTokens, 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.
  • A duplicate inline-image predicate.isMaterializedMediaPart matched any file part with an object data, contradicting its own doc comment; folded onto the stricter isInlineImageFilePart.
  • Media pricing in active-tool-result-prune.extractPayload returns early for output.type === 'content', the shape every materialized image Tool Result has, so the term never executed.
  • Unread width/height on validateImageBytes, 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 inputTokens plus a signed char delta, but step 0 had no anchor at all, and the only pre-turn ruler (estimateRuntimeEventsTokens at 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_usage records gain lastRequestAnchor: { 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 (input on 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 same modelId and llmConnectionId via 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 contextRemaining is 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 maxHistoryEstimatedTokens gate 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, defaultHistoryBudgetTokens still 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_exhausted is retired at the decode boundary. Nothing produced it after the gate deletion, and nothing downstream distinguished it from context_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 to context_overflow; the CompleteEvent member, 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, so RUNTIME_HOST_COMPATIBILITY_EPOCH moves to 94.

Also removed as consumer-free: exceedsContextWindow, the coldStartChars estimate parameter (the whole payload against a zero anchor is the same formula), the never-produced midTurn.reserveTailEvents policy knob, and two single-caller wrappers folded into their call sites.

Refs #4458, #4283

Behaviour changes to review

  1. A request a local estimate judges too large now dispatches. On a genuinely oversized one the provider rejects, recovery compacts and retries once, and a second rejection ends the turn as a real error: reason: context_overflow, the class message Context 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.
  2. Images now carry real cost on the ledger, and a Tool Result carrying media is always a stale-archive candidate: after minRecentTurnsFull turns it becomes a re-readable placeholder whatever its reference text weighs. The maxResultEstimatedTokens gate (2,048) now decides only text-only results, so the coincidence of MATERIALIZED_IMAGE_TOKENS (2,000) sitting just under it cannot flip the outcome.
  3. A turn whose first request would already exceed the high-water mark now compacts before that request, as a pre_turn fold 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.
  4. token_usage records gain the optional lastRequestAnchor under the same closed-allowlist validator. Older builds reading a session this build wrote reject those records as malformed — the pre-existing cost of hasExactShape, not a new one, but it applies here too.
  5. Old sessions persisted with stopReason: context_budget_exhausted load as context_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 prompt is gone — its fixture suppresses usage to force a cold start and so emits no token_usage event, leaving the verdict as its only observable.

Review focus

MATERIALIZED_IMAGE_TOKENS = 2_000 is 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 = 2000 carries no citation and predates this PR.

Still open on #4283, out of scope: image Tool Results are structurally invisible to the active-turn prune (extractPayload returns nothing for type: '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 input stays 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 /compact usage 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 persisted context_budget_exhausted completion decodes as context_overflow.

Reproducing locally: npx tsx --test run directly against packages/runtime/src fails 13 filesystem-worker tests with bundle_not_found. That is an artifact of the invocation — the bundle lives in dist/workers/ and import.meta.url then resolves to src/workers/. npm test builds first and passes.

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — mechanism analysis, implementation, and tests, reviewed and verified by the author.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Behaviour changes to review above
  • No

@Astro-HanAstro-Han changed the title fix(runtime): price artifact media inside the context budgetfix(runtime): measure a materialized image by what it billsSep 1, 2026
@github-actionsgithub-actionsBot added the effort/M Under 500 readable lines label Sep 1, 2026
@Astro-Han
Astro-Han marked this pull request as ready for review September 1, 2026 16:21
@github-actionsgithub-actionsBot added effort/L Under 1000 readable lines and removed effort/M Under 500 readable lines labels Sep 1, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 600338c to 2895104CompareSeptember 1, 2026 17:43
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 2895104 to 5a4c7baCompareSeptember 1, 2026 18:54
@Astro-HanAstro-Han changed the title fix(runtime): measure a materialized image by what it billsfix(runtime): price images by pixel area, let the provider decide fit, anchor estimates on real usageSep 1, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 725cfc2 to 0d4528bCompareSeptember 2, 2026 03:05
@github-actionsgithub-actionsBot added effort/XL Over 1000 readable lines and removed effort/L Under 1000 readable lines labels Sep 2, 2026
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 0d4528b to 0e504b6CompareSeptember 2, 2026 05:07
An 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.
@Astro-Han
Astro-Hanforce-pushed the fix/projection-artifact-cost branch from 0e504b6 to eacbcceCompareSeptember 2, 2026 05:38
@Astro-HanAstro-Han changed the title fix(runtime): price images by pixel area, let the provider decide fit, anchor estimates on real usagefix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdictSep 2, 2026

@Joob1nJoob1n left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
  • persistedRequestAnchor scans 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. requestEstimateAnchor cold-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 maxHistoryEstimatedTokens is 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

  1. Docs still describe the deleted contract.docs/architecture/llm-compaction-events-log-projection-draft.md lines 183, 396 and 407 (and the zh-CN twin) still state the 32,000-token fallback and "terminates with context_budget_exhausted if still over budget". The PR touches no docs.
  2. Downgrade note for the release.decodeRuntimeEvent throws Invalid RuntimeEvent schema on an unknown key, so a session written by this build (with lastRequestAnchor) 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.
  3. Two constants meet by accident.MATERIALIZED_IMAGE_TOKENS = 2_000 and maxResultEstimatedTokens: 2_048 live 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.tscontext_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 低估最多少一次本该发生的主动折叠,随后由拒绝路径兜底。

非阻塞

  1. 文档仍描述已删除的契约。docs/architecture/llm-compaction-events-log-projection-draft.md 第 183、396、407 行(及 zh-CN 版本)仍写着 32,000-token 兜底与「仍超预算则以 context_budget_exhausted 终止」。PR 未改任何文档。
  2. 发布时的降级说明。decodeRuntimeEvent 遇到未知键会抛 Invalid RuntimeEvent schema,所以本版本写过的会话(含 lastRequestAnchor)在旧版本上会加载失败而不是跳过该记录。这是既有的闭合 schema 策略,不是本 PR 的缺陷,但因为是本 PR 新增了持久化键,值得在 release note 里写一句。
  3. 两个常量意外相遇。MATERIALIZED_IMAGE_TOKENS = 2_000maxResultEstimatedTokens: 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

@jackwenerjackwener left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
ContributorAuthor

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 5b339b2ca5:

  1. Docs — the three passages in the compaction draft (both languages) now describe what is on the branch: capacity is the declared window or nothing, an estimate only asks for a compaction, and a rejected request is compacted, retried once, then reported as context_overflow. Good catch; I had written those off as historical drafts.
  2. Downgrade note — added to CHANGELOG.md under 0.2.0: sessions this build writes do not open in earlier releases because of the lastRequestAnchor key, downgrading needs a runtime.sqlite copy taken beforehand, context_budget_exhausted is retired, epoch 94.
  3. Two constants — I went with putting the intent in code rather than tying the numbers together: a Tool Result carrying media is always a stale-archive candidate, and maxResultEstimatedTokens decides text-only results alone. Two tests pin it (a single image with a two-character reference is collected; 1,000 tokens of plain text is not). The identical comparison in active-tool-result-prune is left as is: that path never sees a type: 'content' image result, which is a separate gap.
  4. Description corrected to twenty-one, with the two usage-accounting tests named. Thanks for counting.

One small thing worth flagging so it does not trip up PR-3: with an undeclared window maxHistoryEstimatedTokens is still there rather than absent. defaultHistoryBudgetTokens returns 32,000 for providers other than DeepSeek, and the pre-turn gate uses it as a shaping threshold when no anchor is available. It can only ask for a compaction, never end a turn, which is why I left it alone for now; the PR description says so as well. When PR-3 replaces the count-bounded tail with a token bound, that constant looks like a natural thing to retire in the same pass, if you agree.

Looking forward to PR-3.

@hqhq1025hqhq1025 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Astro-Han
Astro-Han merged commit 92fa528 into mainSep 2, 2026
2 checks passed
@Astro-Han
Astro-Han deleted the fix/projection-artifact-cost branch September 2, 2026 08:17
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 2, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 2, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
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_014ajaRxC4jydavY9nYUFj5J
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 3, 2026
The 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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XLOver 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Astro-Han@jackwener@Joob1n@hqhq1025