fix(runtime): stop estimating context fit; the provider decides - #4653
Conversation
|
The new framing is substantially more converged than #4574: the core problem is an authority problem, not a token-estimation-accuracy problem. Local signals may trigger a reversible fold, while only the provider may decide that a request does not fit; bounding compaction to one attempt per send also gives the flow a clear termination argument. I still see one blocking inference in the send module, plus a documentation mismatch.
That diagnosis is false on the fail-open path: the rejected request may still contain the full raw history. The existing fail-open tests demonstrate that raw history is preserved, but I could not find a combined regression covering Even after a successful fold, a second rejection only proves that the remaining request shape does not fit. That request still includes the system prompt, tool schemas, checkpoint/raw tail, and possibly a live tool call/result; it does not isolate the user message as the cause. Could we separate the two facts, for example:
The user-facing note should probably say that the request remains too large after the compaction attempt/applied projection, rather than claiming that the message alone has been proven too large. Please also add regressions for both a failed-open proactive attempt followed by overflow and a successful fold followed by a second overflow.
One wording point for the final problem statement:
So my overall read is: the root problem definition has converged and this PR can solve the send-side authority/looping problem, but the |
Runtime decided locally whether the next request would fit, from a
characters-per-token estimate against a context window it manufactured when
the user declared none. The estimate was wrong in both directions — it counted
an image at its base64 length and a reasoning replay not at all — and it was
wired to a terminal outcome, so a session could be ended by a number no
provider had ever seen. The invariant this replaces it with is not that the
runtime computes no local numbers, but that no local number may terminate a
turn: a local number may only trigger a reversible fold, and the verdict is
the provider's.
What decides now:
- The threshold is the previous accepted request's real `inputTokens +
outputTokens` plus the room the next reply needs, against the context window
the user declared. A provider's `/models` report and generated metadata are
hints beside the setting, never thresholds. With no declaration there is no
proactive threshold at all.
- The reply reserve is `min(2 x last reply, 8000)`, measured from the reply the
model actually wrote rather than the largest it could write: on a model whose
output limit is half its window, reserving the limit would fold at half the
declared window.
- The compaction module is entered at most once per send, whatever its outcome;
the summarizer's own failure circuit already latches for the send, so a
second entry would dispatch nothing new. Entering is the budget and only the
budget. A folded projection that is actually selected is a separate fact, and
only that one may support a claim about what a still-rejected request
contains: a fold that fails open leaves the raw history in place.
- Whether a request fits is the provider's answer. A classified context-length
rejection folds once and resends; a rejection after an applied fold is
reported as still too large after compaction; an unclassifiable error is
reported as it came, never guessed to be about size.
- A `finishReason: length` drives nothing. The provider running out of window
room and the provider's own lower output cap are indistinguishable from
outside.
What the user sees. Five `system_note` kinds explain the provider-side cases
that used to be silent: the provider dropping or rewriting context (an
append-only step whose input did not grow), a window worth declaring after a
rejection, an exchange that ran past the declared window, a request accepted
past the window the model itself reports while nothing is declared (once per
crossing), and a request still too large after a fold was applied.
Supporting changes. `token_usage` records persist the last-request anchor as
`{ inputTokens, outputTokens }` and still decode the retired `payloadChars`
key. Every OpenAI-compatible chat request asks for `stream_options.
include_usage`, because usage is the only signal this design reads; a relay
that rejects the field is answered once without it and remembered, so the
connection reports no usage rather than failing every request. The summarizer
request ends with a user instruction the model can answer, caps its output at
8,000 tokens, retries once shorter when cut and once stricter when malformed,
surfaces its provider's context-length rejection as `input_too_large`, and
latches any failure for the rest of the send instead of retrying it on every
step.
**Sessions this build writes do not open in earlier releases:** those decode
`token_usage` against a closed allowlist, so the reshaped `lastRequestAnchor`
fails the record and, with it, the Session. Downgrading needs a copy of the
workspace's `runtime.sqlite` taken before the upgrade. Nothing produces the
`context_budget_exhausted` stop reason any more; sessions that recorded it
still decode and present. The Runtime Host compatibility epoch moves to 106.
Design: apache#4559. Supersedes apache#4574, whose review findings are mapped there.
Refs apache#4559, apache#4458, apache#4486, apache#4634
Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
The compaction chapter still described a local size verdict: a manufactured capacity, a high-water ratio, and a replay-time check that a checkpoint still fits. None of those exist now. Both language versions state the implemented rule instead: capacity is the user's declaration or nothing, the active-turn trigger is the previous accepted request's real usage plus a reply reserve of `min(2 x last reply, 8000)` reaching it, a `finishReason: length` triggers nothing because its cause cannot be told apart from outside, and whether a request fits is always the provider's answer. Refs apache#4559 Generated-by: Claude Code Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
03781ee to
441603c
Compare
|
Both blockers were real, and the first was a defect I introduced rather than a wording problem. Fixed on 1. The flag proved the wrong thing. You are right that entering the module and compacting are different facts, and that the code conflated them: Split exactly as you proposed:
Your second point stands too, and I have taken the conclusion out of the copy. Even after an applied fold, a second rejection proves only that the remaining request shape does not fit; it does not isolate the user message. The note kind is now Both regressions are in
2. Architecture documents. Corrected in both languages, in the same commit as the rest of the doc pass: the reserve is On the wording. You are right and I have adopted it. The accepted-boundary retreat stays scoped to PR 2, as you read it. |
me2seeks
left a comment
There was a problem hiding this comment.
LGTM. Verified the carry-over of every #4574 finding against the diff: the send-level summarizer latch now covers all fail-open reasons, the reply reserve is min(2 × last reply, 8000), the dropping note suppresses tool-schema shrinks and fires on <= (plateau), the strict-relay stream_options retreat is remembered per base URL, the reported-window note fires once per crossing with the persisted anchor carrying it across sessions, and the once-per-send compaction budget is shared between the proactive and reactive entries.
I also traced the context_window_overrun note frequency: a mid-turn fold keeps only 1 tail event (reserveTailEvents: 1 — head anchor), so post-fold input drops to ~10–20K and the note does not spam in healthy sessions; it can only repeat when the fold keeps failing open, which is exactly when the message is warranted.
Nice simplification dropping the cutByOwnBudget discriminator one level up — not asking the question is cleaner than answering it.
Summary
Runtime decided locally whether the next request would fit, from a characters-per-token estimate against a context window it manufactured when the user declared none. The invariant this replaces it with is not that the runtime computes no local numbers, but that no local number may terminate a turn: a local number may only trigger a reversible fold, and the verdict is the provider's. The estimate was wrong in both directions — an image counted at its base64 length, a reasoning replay not at all — and it was wired to a terminal outcome, so a session could be ended by a number no provider had ever seen. This removes the estimate's authority.
This is the send module of the design on #4559, and it replaces #4574. That PR is closed, its review findings are mapped to this series in its closing comment, and two things changed after its last round: the compaction module now runs at most once per send, and a cut reply no longer triggers a fold. Both are removals.
What decides now
inputTokens + outputTokens, plus the room the next reply needs, against the window the user declared. A/modelsreport and generated metadata are hints beside the setting, never thresholds. No declaration means no proactive threshold.min(2 × last reply, 8000), measured from the reply the model actually wrote rather than the largest it could write. On a model whose declared output limit is half its window (k3-256k reports 131,072 against 262,144) reserving the limit would fold at half the declared window.finishReason: lengthdrives nothing. The provider running out of window room and the provider's own lower output cap are indistinguishable from outside, and an indistinguishable signal must not drive an action. The cut reply is visible to the user either way.What the user sees. Five
system_notekinds cover the provider-side cases that used to be silent: the provider dropping or rewriting context (an append-only step whose input did not grow), a window worth declaring after a rejection, an exchange past the declared window, a request accepted past the window the model itself reports while nothing is declared (once per crossing), and a request still too large after a fold was applied.Supporting changes.
token_usagepersists the anchor as{ inputTokens, outputTokens }and still decodes the retiredpayloadChars. Every OpenAI-compatible chat request asks forstream_options.include_usage, because usage is the only signal this design reads; a relay that rejects the field is answered once without it and remembered, so that connection reports no usage rather than failing every request. The summarizer request ends with a user instruction the model can answer, caps output at 8,000 tokens, retries once shorter when cut and once stricter when malformed, surfaces a context-length rejection asinput_too_large, and latches any failure for the rest of the send.Refs #4559, #4458, #4486, #4634
Follow-ups, not in this PR
Verification
Every local gate: workspace builds,
npm run typecheck,lint,format:check,check:renderer-architecture,check:app-shell-hooks,astryx:theme --check,astryx:surface-inventory,check:asf-headers,protocol-epoch-check— clean. Runtime suites: mid-turn capacity 73/73, overflow recovery 48/48, history compaction and checkpoint 48/48, summarizer 52/52, provider conformance 25/25.runtime-hostprotocol and composition 28/28 (that suite times out under parallel load on my machine and passes on its own; CI runs it serially).Live, against a local Ollama driving the real backend: a declared 1,500-token window folds at a 2,227-token baseline, the checkpoint lands at 1,737 characters, and the next request drops from 2,954 to 1,222 input tokens. With no declaration the same model plateaus at 3,716 input tokens while Maka keeps appending, which is the provider-dropping case the note now reports. Three defects came out of that run and are fixed here: providers returning no usage at all without
stream_options, empty summaries when the folded span ends on an assistant turn, and summaries cut at the output cap.Self-review
input + outputis not the floor of the next input, so a baseline comparison would report every such step as provider dropping.resolveSelectedModelContextWindowstill resolves the metadata window for display and forcontextRemaining; only the threshold is declaration-only. The Host's composition keeps reporting it, so the existing composition expectation is unchanged.AI use
Select exactly one:
Tool(s) and scope: Claude Code — implementation; reviewed and verified by the author.
Checklist
Does this PR entail a change in behavior?
https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J