fix(runtime): stop estimating context fit; the provider decides - #4653

Merged
likun666661 merged 2 commits into
apache:mainfrom
Joob1n:feat/context-send-module
Sep 3, 2026
Merged

fix(runtime): stop estimating context fit; the provider decides#4653
likun666661 merged 2 commits into
apache:mainfrom
Joob1n:feat/context-send-module

Conversation

@Joob1n

@Joob1nJoob1n commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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

  • The threshold is the previous accepted request's real inputTokens + outputTokens, plus the room the next reply needs, against the window the user declared. A /models report and generated metadata are hints beside the setting, never thresholds. No declaration means no proactive threshold.
  • The reply reserve is 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.
  • Compaction 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; a selected folded projection is a separate fact, and only that one is allowed to support a claim about what the request still contains.
  • Whether a request fits is the provider's answer. A classified context-length rejection folds once and resends; a rejection after a fold that was actually applied is reported as still too large after compaction; an unclassifiable error is reported as it came.
  • A finishReason: length drives 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_note kinds 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_usage persists the anchor as { inputTokens, outputTokens } and still decodes the retired payloadChars. 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 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 as input_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-host protocol 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

  • The dropping note compares input against input, not against the baseline: on wires that do not resend reasoning, input + output is not the floor of the next input, so a baseline comparison would report every such step as provider dropping.
  • The note is suppressed when the step's own active tool set shrank. A finalization step resolves an empty tool set and legitimately drops several thousand schema tokens with no fold, prune or image omission.
  • The reported-window note fires on the crossing rather than per send, because usage keeps growing past the line on providers that accept over-window requests; the persisted anchor carries the previous total, so a resumed session does not repeat a crossing it already reported.
  • resolveSelectedModelContextWindow still resolves the metadata window for display and for contextRemaining; 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:

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

Tool(s) and scope: Claude Code — implementation; 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 Summary above
  • No

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@likun666661

Copy link
Copy Markdown
Member

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.

  1. compactionUsedThisSend does not prove that history was compacted.

buildMidTurnCapacityCompactProjection() sets the flag before compactActiveRequestHistory() returns. If summary generation, validation, or checkpoint persistence fails, the code fails open with the raw projection but leaves the flag set. A later context-overflow rejection then:

  • skips reactive recovery because the send budget appears spent; and
  • writes context_message_too_large, whose copy says that history was already compacted and the new message itself does not fit.

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 proactive fold fails open -> dispatched raw request overflows.

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:

  • compactionAttemptedThisSend, used only to enforce the one-attempt budget; and
  • compactionAppliedThisSend, set only after a folded projection is actually selected?

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.

  1. The architecture docs in this PR still describe the superseded behavior.

docs/architecture/llm-compaction-events-log-projection-draft.md lines 183-189 (and the matching zh-CN section) say that the reserve is the model-declared maxOutputTokens and that finishReason: length emits a Compact command. The PR and implementation now say min(2 * last reply, 8000) and deliberately make finishReason: length drive nothing. Since this commit is intended to document the provider-decided architecture, these sections need to agree with the new design.

One wording point for the final problem statement: min(2 * last reply, 8000) is still a local heuristic for a reversible proactive action. That is fine, but the precise invariant is “local estimates have no terminal authority”, not literally “the runtime does no estimation”. With that wording, the causal spine is small and coherent:

accepted provider usage + user target -> optional reversible fold -> provider verdict -> at most one recovery attempt.

So my overall read is: the root problem definition has converged and this PR can solve the send-side authority/looping problem, but the message itself conclusion and the architecture-doc contradictions should be resolved before merge. The accepted-boundary retreat and the remaining compaction-side guarantees are correctly scoped to the follow-up PR rather than being claimed here.

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
@Joob1n
Joob1nforce-pushed the feat/context-send-module branch from 03781ee to 441603cCompareSeptember 3, 2026 11:52
@Joob1n

Copy link
Copy Markdown
ContributorAuthor

Both blockers were real, and the first was a defect I introduced rather than a wording problem. Fixed on 441603ceb.

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: compactionUsedThisSend was set before compactActiveRequestHistory returned, so a fold that failed open still looked like a compacted send. A later rejection then skipped reactive recovery and wrote a note asserting that history had already been compacted, while that request had in fact gone out carrying its full raw history.

Split exactly as you proposed:

  • compactionAttemptedThisSend — the budget, and only the budget. Set on entry from either the proactive or the reactive path. One attempt per send is still the rule, because the summarizer's own failure circuit latches for the send, so a second entry would dispatch nothing new.
  • compactionAppliedThisSend — set only where a folded projection is actually selected, in both paths. Nothing else may support a statement about what a still-rejected request contains.

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 context_overflow_after_compaction and reads: history was compacted and the provider still called this request too large; what remains also carries the system prompt, the tool schemas, the summary and the recent tail; shortening this message is the part you control.

Both regressions are in overflow-reactive-recovery.test.ts:

  • "a fold that failed open does not claim the request was compacted" — ['tool', 'overflow', 'overflow'] with a summarizer that returns nothing. Fails on the previous commit, where the note is written.
  • "a rejection after an applied fold says the request is still too large" — the same script with a working summarizer, asserting the note is present.

2. Architecture documents. Corrected in both languages, in the same commit as the rest of the doc pass: the reserve is min(2 × last reply, 8000) measured from the reply the model actually wrote, finishReason: length emits no Compact command and the reason is stated, and provider-overflow recovery is described as sharing one budget with the active-turn evaluator rather than owning a second one.

On the wording. You are right and I have adopted it. min(2 × last reply, 8000) is a local heuristic for a reversible action, so "the runtime does no estimation" was never the claim worth making. The PR now opens with the invariant as you put it: no local number may terminate a turn; a local number may only trigger a reversible fold, and the verdict is the provider's. Your causal spine is the design, stated more compactly than I managed:

accepted provider usage + user target -> optional reversible fold -> provider verdict -> at most one recovery attempt

The accepted-boundary retreat stays scoped to PR 2, as you read it.

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

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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XLUnder 2500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@Joob1n@likun666661@me2seeks
, '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): stop estimating context fit; the provider decides - #4653

Merged
likun666661 merged 2 commits into
apache:mainfrom
Joob1n:feat/context-send-module
Sep 3, 2026
Merged

fix(runtime): stop estimating context fit; the provider decides#4653
likun666661 merged 2 commits into
apache:mainfrom
Joob1n:feat/context-send-module

Conversation

@Joob1n

@Joob1nJoob1n commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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

  • The threshold is the previous accepted request's real inputTokens + outputTokens, plus the room the next reply needs, against the window the user declared. A /models report and generated metadata are hints beside the setting, never thresholds. No declaration means no proactive threshold.
  • The reply reserve is 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.
  • Compaction 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; a selected folded projection is a separate fact, and only that one is allowed to support a claim about what the request still contains.
  • Whether a request fits is the provider's answer. A classified context-length rejection folds once and resends; a rejection after a fold that was actually applied is reported as still too large after compaction; an unclassifiable error is reported as it came.
  • A finishReason: length drives 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_note kinds 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_usage persists the anchor as { inputTokens, outputTokens } and still decodes the retired payloadChars. 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 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 as input_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-host protocol 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

  • The dropping note compares input against input, not against the baseline: on wires that do not resend reasoning, input + output is not the floor of the next input, so a baseline comparison would report every such step as provider dropping.
  • The note is suppressed when the step's own active tool set shrank. A finalization step resolves an empty tool set and legitimately drops several thousand schema tokens with no fold, prune or image omission.
  • The reported-window note fires on the crossing rather than per send, because usage keeps growing past the line on providers that accept over-window requests; the persisted anchor carries the previous total, so a resumed session does not repeat a crossing it already reported.
  • resolveSelectedModelContextWindow still resolves the metadata window for display and for contextRemaining; 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:

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

Tool(s) and scope: Claude Code — implementation; 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 Summary above
  • No

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@likun666661

Copy link
Copy Markdown
Member

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.

  1. compactionUsedThisSend does not prove that history was compacted.

buildMidTurnCapacityCompactProjection() sets the flag before compactActiveRequestHistory() returns. If summary generation, validation, or checkpoint persistence fails, the code fails open with the raw projection but leaves the flag set. A later context-overflow rejection then:

  • skips reactive recovery because the send budget appears spent; and
  • writes context_message_too_large, whose copy says that history was already compacted and the new message itself does not fit.

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 proactive fold fails open -> dispatched raw request overflows.

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:

  • compactionAttemptedThisSend, used only to enforce the one-attempt budget; and
  • compactionAppliedThisSend, set only after a folded projection is actually selected?

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.

  1. The architecture docs in this PR still describe the superseded behavior.

docs/architecture/llm-compaction-events-log-projection-draft.md lines 183-189 (and the matching zh-CN section) say that the reserve is the model-declared maxOutputTokens and that finishReason: length emits a Compact command. The PR and implementation now say min(2 * last reply, 8000) and deliberately make finishReason: length drive nothing. Since this commit is intended to document the provider-decided architecture, these sections need to agree with the new design.

One wording point for the final problem statement: min(2 * last reply, 8000) is still a local heuristic for a reversible proactive action. That is fine, but the precise invariant is “local estimates have no terminal authority”, not literally “the runtime does no estimation”. With that wording, the causal spine is small and coherent:

accepted provider usage + user target -> optional reversible fold -> provider verdict -> at most one recovery attempt.

So my overall read is: the root problem definition has converged and this PR can solve the send-side authority/looping problem, but the message itself conclusion and the architecture-doc contradictions should be resolved before merge. The accepted-boundary retreat and the remaining compaction-side guarantees are correctly scoped to the follow-up PR rather than being claimed here.

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
@Joob1n
Joob1nforce-pushed the feat/context-send-module branch from 03781ee to 441603cCompareSeptember 3, 2026 11:52
@Joob1n

Copy link
Copy Markdown
ContributorAuthor

Both blockers were real, and the first was a defect I introduced rather than a wording problem. Fixed on 441603ceb.

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: compactionUsedThisSend was set before compactActiveRequestHistory returned, so a fold that failed open still looked like a compacted send. A later rejection then skipped reactive recovery and wrote a note asserting that history had already been compacted, while that request had in fact gone out carrying its full raw history.

Split exactly as you proposed:

  • compactionAttemptedThisSend — the budget, and only the budget. Set on entry from either the proactive or the reactive path. One attempt per send is still the rule, because the summarizer's own failure circuit latches for the send, so a second entry would dispatch nothing new.
  • compactionAppliedThisSend — set only where a folded projection is actually selected, in both paths. Nothing else may support a statement about what a still-rejected request contains.

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 context_overflow_after_compaction and reads: history was compacted and the provider still called this request too large; what remains also carries the system prompt, the tool schemas, the summary and the recent tail; shortening this message is the part you control.

Both regressions are in overflow-reactive-recovery.test.ts:

  • "a fold that failed open does not claim the request was compacted" — ['tool', 'overflow', 'overflow'] with a summarizer that returns nothing. Fails on the previous commit, where the note is written.
  • "a rejection after an applied fold says the request is still too large" — the same script with a working summarizer, asserting the note is present.

2. Architecture documents. Corrected in both languages, in the same commit as the rest of the doc pass: the reserve is min(2 × last reply, 8000) measured from the reply the model actually wrote, finishReason: length emits no Compact command and the reason is stated, and provider-overflow recovery is described as sharing one budget with the active-turn evaluator rather than owning a second one.

On the wording. You are right and I have adopted it. min(2 × last reply, 8000) is a local heuristic for a reversible action, so "the runtime does no estimation" was never the claim worth making. The PR now opens with the invariant as you put it: no local number may terminate a turn; a local number may only trigger a reversible fold, and the verdict is the provider's. Your causal spine is the design, stated more compactly than I managed:

accepted provider usage + user target -> optional reversible fold -> provider verdict -> at most one recovery attempt

The accepted-boundary retreat stays scoped to PR 2, as you read it.

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

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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XLUnder 2500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@Joob1n@likun666661@me2seeks
, '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): stop estimating context fit; the provider decides - #4653

Merged
likun666661 merged 2 commits into
apache:mainfrom
Joob1n:feat/context-send-module
Sep 3, 2026
Merged

fix(runtime): stop estimating context fit; the provider decides#4653
likun666661 merged 2 commits into
apache:mainfrom
Joob1n:feat/context-send-module

Conversation

@Joob1n

@Joob1nJoob1n commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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

  • The threshold is the previous accepted request's real inputTokens + outputTokens, plus the room the next reply needs, against the window the user declared. A /models report and generated metadata are hints beside the setting, never thresholds. No declaration means no proactive threshold.
  • The reply reserve is 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.
  • Compaction 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; a selected folded projection is a separate fact, and only that one is allowed to support a claim about what the request still contains.
  • Whether a request fits is the provider's answer. A classified context-length rejection folds once and resends; a rejection after a fold that was actually applied is reported as still too large after compaction; an unclassifiable error is reported as it came.
  • A finishReason: length drives 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_note kinds 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_usage persists the anchor as { inputTokens, outputTokens } and still decodes the retired payloadChars. 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 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 as input_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-host protocol 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

  • The dropping note compares input against input, not against the baseline: on wires that do not resend reasoning, input + output is not the floor of the next input, so a baseline comparison would report every such step as provider dropping.
  • The note is suppressed when the step's own active tool set shrank. A finalization step resolves an empty tool set and legitimately drops several thousand schema tokens with no fold, prune or image omission.
  • The reported-window note fires on the crossing rather than per send, because usage keeps growing past the line on providers that accept over-window requests; the persisted anchor carries the previous total, so a resumed session does not repeat a crossing it already reported.
  • resolveSelectedModelContextWindow still resolves the metadata window for display and for contextRemaining; 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:

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

Tool(s) and scope: Claude Code — implementation; 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 Summary above
  • No

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@likun666661

Copy link
Copy Markdown
Member

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.

  1. compactionUsedThisSend does not prove that history was compacted.

buildMidTurnCapacityCompactProjection() sets the flag before compactActiveRequestHistory() returns. If summary generation, validation, or checkpoint persistence fails, the code fails open with the raw projection but leaves the flag set. A later context-overflow rejection then:

  • skips reactive recovery because the send budget appears spent; and
  • writes context_message_too_large, whose copy says that history was already compacted and the new message itself does not fit.

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 proactive fold fails open -> dispatched raw request overflows.

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:

  • compactionAttemptedThisSend, used only to enforce the one-attempt budget; and
  • compactionAppliedThisSend, set only after a folded projection is actually selected?

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.

  1. The architecture docs in this PR still describe the superseded behavior.

docs/architecture/llm-compaction-events-log-projection-draft.md lines 183-189 (and the matching zh-CN section) say that the reserve is the model-declared maxOutputTokens and that finishReason: length emits a Compact command. The PR and implementation now say min(2 * last reply, 8000) and deliberately make finishReason: length drive nothing. Since this commit is intended to document the provider-decided architecture, these sections need to agree with the new design.

One wording point for the final problem statement: min(2 * last reply, 8000) is still a local heuristic for a reversible proactive action. That is fine, but the precise invariant is “local estimates have no terminal authority”, not literally “the runtime does no estimation”. With that wording, the causal spine is small and coherent:

accepted provider usage + user target -> optional reversible fold -> provider verdict -> at most one recovery attempt.

So my overall read is: the root problem definition has converged and this PR can solve the send-side authority/looping problem, but the message itself conclusion and the architecture-doc contradictions should be resolved before merge. The accepted-boundary retreat and the remaining compaction-side guarantees are correctly scoped to the follow-up PR rather than being claimed here.

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
@Joob1n
Joob1nforce-pushed the feat/context-send-module branch from 03781ee to 441603cCompareSeptember 3, 2026 11:52
@Joob1n

Copy link
Copy Markdown
ContributorAuthor

Both blockers were real, and the first was a defect I introduced rather than a wording problem. Fixed on 441603ceb.

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: compactionUsedThisSend was set before compactActiveRequestHistory returned, so a fold that failed open still looked like a compacted send. A later rejection then skipped reactive recovery and wrote a note asserting that history had already been compacted, while that request had in fact gone out carrying its full raw history.

Split exactly as you proposed:

  • compactionAttemptedThisSend — the budget, and only the budget. Set on entry from either the proactive or the reactive path. One attempt per send is still the rule, because the summarizer's own failure circuit latches for the send, so a second entry would dispatch nothing new.
  • compactionAppliedThisSend — set only where a folded projection is actually selected, in both paths. Nothing else may support a statement about what a still-rejected request contains.

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 context_overflow_after_compaction and reads: history was compacted and the provider still called this request too large; what remains also carries the system prompt, the tool schemas, the summary and the recent tail; shortening this message is the part you control.

Both regressions are in overflow-reactive-recovery.test.ts:

  • "a fold that failed open does not claim the request was compacted" — ['tool', 'overflow', 'overflow'] with a summarizer that returns nothing. Fails on the previous commit, where the note is written.
  • "a rejection after an applied fold says the request is still too large" — the same script with a working summarizer, asserting the note is present.

2. Architecture documents. Corrected in both languages, in the same commit as the rest of the doc pass: the reserve is min(2 × last reply, 8000) measured from the reply the model actually wrote, finishReason: length emits no Compact command and the reason is stated, and provider-overflow recovery is described as sharing one budget with the active-turn evaluator rather than owning a second one.

On the wording. You are right and I have adopted it. min(2 × last reply, 8000) is a local heuristic for a reversible action, so "the runtime does no estimation" was never the claim worth making. The PR now opens with the invariant as you put it: no local number may terminate a turn; a local number may only trigger a reversible fold, and the verdict is the provider's. Your causal spine is the design, stated more compactly than I managed:

accepted provider usage + user target -> optional reversible fold -> provider verdict -> at most one recovery attempt

The accepted-boundary retreat stays scoped to PR 2, as you read it.

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

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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XLUnder 2500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@Joob1n@likun666661@me2seeks
, '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): stop estimating context fit; the provider decides - #4653

Merged
likun666661 merged 2 commits into
apache:mainfrom
Joob1n:feat/context-send-module
Sep 3, 2026
Merged

fix(runtime): stop estimating context fit; the provider decides#4653
likun666661 merged 2 commits into
apache:mainfrom
Joob1n:feat/context-send-module

Conversation

@Joob1n

@Joob1nJoob1n commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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

  • The threshold is the previous accepted request's real inputTokens + outputTokens, plus the room the next reply needs, against the window the user declared. A /models report and generated metadata are hints beside the setting, never thresholds. No declaration means no proactive threshold.
  • The reply reserve is 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.
  • Compaction 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; a selected folded projection is a separate fact, and only that one is allowed to support a claim about what the request still contains.
  • Whether a request fits is the provider's answer. A classified context-length rejection folds once and resends; a rejection after a fold that was actually applied is reported as still too large after compaction; an unclassifiable error is reported as it came.
  • A finishReason: length drives 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_note kinds 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_usage persists the anchor as { inputTokens, outputTokens } and still decodes the retired payloadChars. 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 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 as input_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-host protocol 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

  • The dropping note compares input against input, not against the baseline: on wires that do not resend reasoning, input + output is not the floor of the next input, so a baseline comparison would report every such step as provider dropping.
  • The note is suppressed when the step's own active tool set shrank. A finalization step resolves an empty tool set and legitimately drops several thousand schema tokens with no fold, prune or image omission.
  • The reported-window note fires on the crossing rather than per send, because usage keeps growing past the line on providers that accept over-window requests; the persisted anchor carries the previous total, so a resumed session does not repeat a crossing it already reported.
  • resolveSelectedModelContextWindow still resolves the metadata window for display and for contextRemaining; 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:

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

Tool(s) and scope: Claude Code — implementation; 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 Summary above
  • No

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@likun666661

Copy link
Copy Markdown
Member

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.

  1. compactionUsedThisSend does not prove that history was compacted.

buildMidTurnCapacityCompactProjection() sets the flag before compactActiveRequestHistory() returns. If summary generation, validation, or checkpoint persistence fails, the code fails open with the raw projection but leaves the flag set. A later context-overflow rejection then:

  • skips reactive recovery because the send budget appears spent; and
  • writes context_message_too_large, whose copy says that history was already compacted and the new message itself does not fit.

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 proactive fold fails open -> dispatched raw request overflows.

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:

  • compactionAttemptedThisSend, used only to enforce the one-attempt budget; and
  • compactionAppliedThisSend, set only after a folded projection is actually selected?

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.

  1. The architecture docs in this PR still describe the superseded behavior.

docs/architecture/llm-compaction-events-log-projection-draft.md lines 183-189 (and the matching zh-CN section) say that the reserve is the model-declared maxOutputTokens and that finishReason: length emits a Compact command. The PR and implementation now say min(2 * last reply, 8000) and deliberately make finishReason: length drive nothing. Since this commit is intended to document the provider-decided architecture, these sections need to agree with the new design.

One wording point for the final problem statement: min(2 * last reply, 8000) is still a local heuristic for a reversible proactive action. That is fine, but the precise invariant is “local estimates have no terminal authority”, not literally “the runtime does no estimation”. With that wording, the causal spine is small and coherent:

accepted provider usage + user target -> optional reversible fold -> provider verdict -> at most one recovery attempt.

So my overall read is: the root problem definition has converged and this PR can solve the send-side authority/looping problem, but the message itself conclusion and the architecture-doc contradictions should be resolved before merge. The accepted-boundary retreat and the remaining compaction-side guarantees are correctly scoped to the follow-up PR rather than being claimed here.

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
@Joob1n
Joob1nforce-pushed the feat/context-send-module branch from 03781ee to 441603cCompareSeptember 3, 2026 11:52
@Joob1n

Copy link
Copy Markdown
ContributorAuthor

Both blockers were real, and the first was a defect I introduced rather than a wording problem. Fixed on 441603ceb.

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: compactionUsedThisSend was set before compactActiveRequestHistory returned, so a fold that failed open still looked like a compacted send. A later rejection then skipped reactive recovery and wrote a note asserting that history had already been compacted, while that request had in fact gone out carrying its full raw history.

Split exactly as you proposed:

  • compactionAttemptedThisSend — the budget, and only the budget. Set on entry from either the proactive or the reactive path. One attempt per send is still the rule, because the summarizer's own failure circuit latches for the send, so a second entry would dispatch nothing new.
  • compactionAppliedThisSend — set only where a folded projection is actually selected, in both paths. Nothing else may support a statement about what a still-rejected request contains.

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 context_overflow_after_compaction and reads: history was compacted and the provider still called this request too large; what remains also carries the system prompt, the tool schemas, the summary and the recent tail; shortening this message is the part you control.

Both regressions are in overflow-reactive-recovery.test.ts:

  • "a fold that failed open does not claim the request was compacted" — ['tool', 'overflow', 'overflow'] with a summarizer that returns nothing. Fails on the previous commit, where the note is written.
  • "a rejection after an applied fold says the request is still too large" — the same script with a working summarizer, asserting the note is present.

2. Architecture documents. Corrected in both languages, in the same commit as the rest of the doc pass: the reserve is min(2 × last reply, 8000) measured from the reply the model actually wrote, finishReason: length emits no Compact command and the reason is stated, and provider-overflow recovery is described as sharing one budget with the active-turn evaluator rather than owning a second one.

On the wording. You are right and I have adopted it. min(2 × last reply, 8000) is a local heuristic for a reversible action, so "the runtime does no estimation" was never the claim worth making. The PR now opens with the invariant as you put it: no local number may terminate a turn; a local number may only trigger a reversible fold, and the verdict is the provider's. Your causal spine is the design, stated more compactly than I managed:

accepted provider usage + user target -> optional reversible fold -> provider verdict -> at most one recovery attempt

The accepted-boundary retreat stays scoped to PR 2, as you read it.

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

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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XLUnder 2500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@Joob1n@likun666661@me2seeks
, '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): stop estimating context fit; the provider decides - #4653

Merged
likun666661 merged 2 commits into
apache:mainfrom
Joob1n:feat/context-send-module
Sep 3, 2026
Merged

fix(runtime): stop estimating context fit; the provider decides#4653
likun666661 merged 2 commits into
apache:mainfrom
Joob1n:feat/context-send-module

Conversation

@Joob1n

@Joob1nJoob1n commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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

  • The threshold is the previous accepted request's real inputTokens + outputTokens, plus the room the next reply needs, against the window the user declared. A /models report and generated metadata are hints beside the setting, never thresholds. No declaration means no proactive threshold.
  • The reply reserve is 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.
  • Compaction 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; a selected folded projection is a separate fact, and only that one is allowed to support a claim about what the request still contains.
  • Whether a request fits is the provider's answer. A classified context-length rejection folds once and resends; a rejection after a fold that was actually applied is reported as still too large after compaction; an unclassifiable error is reported as it came.
  • A finishReason: length drives 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_note kinds 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_usage persists the anchor as { inputTokens, outputTokens } and still decodes the retired payloadChars. 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 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 as input_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-host protocol 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

  • The dropping note compares input against input, not against the baseline: on wires that do not resend reasoning, input + output is not the floor of the next input, so a baseline comparison would report every such step as provider dropping.
  • The note is suppressed when the step's own active tool set shrank. A finalization step resolves an empty tool set and legitimately drops several thousand schema tokens with no fold, prune or image omission.
  • The reported-window note fires on the crossing rather than per send, because usage keeps growing past the line on providers that accept over-window requests; the persisted anchor carries the previous total, so a resumed session does not repeat a crossing it already reported.
  • resolveSelectedModelContextWindow still resolves the metadata window for display and for contextRemaining; 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:

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

Tool(s) and scope: Claude Code — implementation; 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 Summary above
  • No

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@likun666661

Copy link
Copy Markdown
Member

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.

  1. compactionUsedThisSend does not prove that history was compacted.

buildMidTurnCapacityCompactProjection() sets the flag before compactActiveRequestHistory() returns. If summary generation, validation, or checkpoint persistence fails, the code fails open with the raw projection but leaves the flag set. A later context-overflow rejection then:

  • skips reactive recovery because the send budget appears spent; and
  • writes context_message_too_large, whose copy says that history was already compacted and the new message itself does not fit.

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 proactive fold fails open -> dispatched raw request overflows.

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:

  • compactionAttemptedThisSend, used only to enforce the one-attempt budget; and
  • compactionAppliedThisSend, set only after a folded projection is actually selected?

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.

  1. The architecture docs in this PR still describe the superseded behavior.

docs/architecture/llm-compaction-events-log-projection-draft.md lines 183-189 (and the matching zh-CN section) say that the reserve is the model-declared maxOutputTokens and that finishReason: length emits a Compact command. The PR and implementation now say min(2 * last reply, 8000) and deliberately make finishReason: length drive nothing. Since this commit is intended to document the provider-decided architecture, these sections need to agree with the new design.

One wording point for the final problem statement: min(2 * last reply, 8000) is still a local heuristic for a reversible proactive action. That is fine, but the precise invariant is “local estimates have no terminal authority”, not literally “the runtime does no estimation”. With that wording, the causal spine is small and coherent:

accepted provider usage + user target -> optional reversible fold -> provider verdict -> at most one recovery attempt.

So my overall read is: the root problem definition has converged and this PR can solve the send-side authority/looping problem, but the message itself conclusion and the architecture-doc contradictions should be resolved before merge. The accepted-boundary retreat and the remaining compaction-side guarantees are correctly scoped to the follow-up PR rather than being claimed here.

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
@Joob1n
Joob1nforce-pushed the feat/context-send-module branch from 03781ee to 441603cCompareSeptember 3, 2026 11:52
@Joob1n

Copy link
Copy Markdown
ContributorAuthor

Both blockers were real, and the first was a defect I introduced rather than a wording problem. Fixed on 441603ceb.

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: compactionUsedThisSend was set before compactActiveRequestHistory returned, so a fold that failed open still looked like a compacted send. A later rejection then skipped reactive recovery and wrote a note asserting that history had already been compacted, while that request had in fact gone out carrying its full raw history.

Split exactly as you proposed:

  • compactionAttemptedThisSend — the budget, and only the budget. Set on entry from either the proactive or the reactive path. One attempt per send is still the rule, because the summarizer's own failure circuit latches for the send, so a second entry would dispatch nothing new.
  • compactionAppliedThisSend — set only where a folded projection is actually selected, in both paths. Nothing else may support a statement about what a still-rejected request contains.

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 context_overflow_after_compaction and reads: history was compacted and the provider still called this request too large; what remains also carries the system prompt, the tool schemas, the summary and the recent tail; shortening this message is the part you control.

Both regressions are in overflow-reactive-recovery.test.ts:

  • "a fold that failed open does not claim the request was compacted" — ['tool', 'overflow', 'overflow'] with a summarizer that returns nothing. Fails on the previous commit, where the note is written.
  • "a rejection after an applied fold says the request is still too large" — the same script with a working summarizer, asserting the note is present.

2. Architecture documents. Corrected in both languages, in the same commit as the rest of the doc pass: the reserve is min(2 × last reply, 8000) measured from the reply the model actually wrote, finishReason: length emits no Compact command and the reason is stated, and provider-overflow recovery is described as sharing one budget with the active-turn evaluator rather than owning a second one.

On the wording. You are right and I have adopted it. min(2 × last reply, 8000) is a local heuristic for a reversible action, so "the runtime does no estimation" was never the claim worth making. The PR now opens with the invariant as you put it: no local number may terminate a turn; a local number may only trigger a reversible fold, and the verdict is the provider's. Your causal spine is the design, stated more compactly than I managed:

accepted provider usage + user target -> optional reversible fold -> provider verdict -> at most one recovery attempt

The accepted-boundary retreat stays scoped to PR 2, as you read it.

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

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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XLUnder 2500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@Joob1n@likun666661@me2seeks
, '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): stop estimating context fit; the provider decides - #4653

Merged
likun666661 merged 2 commits into
apache:mainfrom
Joob1n:feat/context-send-module
Sep 3, 2026
Merged

fix(runtime): stop estimating context fit; the provider decides#4653
likun666661 merged 2 commits into
apache:mainfrom
Joob1n:feat/context-send-module

Conversation

@Joob1n

@Joob1nJoob1n commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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

  • The threshold is the previous accepted request's real inputTokens + outputTokens, plus the room the next reply needs, against the window the user declared. A /models report and generated metadata are hints beside the setting, never thresholds. No declaration means no proactive threshold.
  • The reply reserve is 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.
  • Compaction 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; a selected folded projection is a separate fact, and only that one is allowed to support a claim about what the request still contains.
  • Whether a request fits is the provider's answer. A classified context-length rejection folds once and resends; a rejection after a fold that was actually applied is reported as still too large after compaction; an unclassifiable error is reported as it came.
  • A finishReason: length drives 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_note kinds 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_usage persists the anchor as { inputTokens, outputTokens } and still decodes the retired payloadChars. 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 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 as input_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-host protocol 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

  • The dropping note compares input against input, not against the baseline: on wires that do not resend reasoning, input + output is not the floor of the next input, so a baseline comparison would report every such step as provider dropping.
  • The note is suppressed when the step's own active tool set shrank. A finalization step resolves an empty tool set and legitimately drops several thousand schema tokens with no fold, prune or image omission.
  • The reported-window note fires on the crossing rather than per send, because usage keeps growing past the line on providers that accept over-window requests; the persisted anchor carries the previous total, so a resumed session does not repeat a crossing it already reported.
  • resolveSelectedModelContextWindow still resolves the metadata window for display and for contextRemaining; 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:

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

Tool(s) and scope: Claude Code — implementation; 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 Summary above
  • No

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@likun666661

Copy link
Copy Markdown
Member

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.

  1. compactionUsedThisSend does not prove that history was compacted.

buildMidTurnCapacityCompactProjection() sets the flag before compactActiveRequestHistory() returns. If summary generation, validation, or checkpoint persistence fails, the code fails open with the raw projection but leaves the flag set. A later context-overflow rejection then:

  • skips reactive recovery because the send budget appears spent; and
  • writes context_message_too_large, whose copy says that history was already compacted and the new message itself does not fit.

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 proactive fold fails open -> dispatched raw request overflows.

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:

  • compactionAttemptedThisSend, used only to enforce the one-attempt budget; and
  • compactionAppliedThisSend, set only after a folded projection is actually selected?

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.

  1. The architecture docs in this PR still describe the superseded behavior.

docs/architecture/llm-compaction-events-log-projection-draft.md lines 183-189 (and the matching zh-CN section) say that the reserve is the model-declared maxOutputTokens and that finishReason: length emits a Compact command. The PR and implementation now say min(2 * last reply, 8000) and deliberately make finishReason: length drive nothing. Since this commit is intended to document the provider-decided architecture, these sections need to agree with the new design.

One wording point for the final problem statement: min(2 * last reply, 8000) is still a local heuristic for a reversible proactive action. That is fine, but the precise invariant is “local estimates have no terminal authority”, not literally “the runtime does no estimation”. With that wording, the causal spine is small and coherent:

accepted provider usage + user target -> optional reversible fold -> provider verdict -> at most one recovery attempt.

So my overall read is: the root problem definition has converged and this PR can solve the send-side authority/looping problem, but the message itself conclusion and the architecture-doc contradictions should be resolved before merge. The accepted-boundary retreat and the remaining compaction-side guarantees are correctly scoped to the follow-up PR rather than being claimed here.

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
@Joob1n
Joob1nforce-pushed the feat/context-send-module branch from 03781ee to 441603cCompareSeptember 3, 2026 11:52
@Joob1n

Copy link
Copy Markdown
ContributorAuthor

Both blockers were real, and the first was a defect I introduced rather than a wording problem. Fixed on 441603ceb.

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: compactionUsedThisSend was set before compactActiveRequestHistory returned, so a fold that failed open still looked like a compacted send. A later rejection then skipped reactive recovery and wrote a note asserting that history had already been compacted, while that request had in fact gone out carrying its full raw history.

Split exactly as you proposed:

  • compactionAttemptedThisSend — the budget, and only the budget. Set on entry from either the proactive or the reactive path. One attempt per send is still the rule, because the summarizer's own failure circuit latches for the send, so a second entry would dispatch nothing new.
  • compactionAppliedThisSend — set only where a folded projection is actually selected, in both paths. Nothing else may support a statement about what a still-rejected request contains.

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 context_overflow_after_compaction and reads: history was compacted and the provider still called this request too large; what remains also carries the system prompt, the tool schemas, the summary and the recent tail; shortening this message is the part you control.

Both regressions are in overflow-reactive-recovery.test.ts:

  • "a fold that failed open does not claim the request was compacted" — ['tool', 'overflow', 'overflow'] with a summarizer that returns nothing. Fails on the previous commit, where the note is written.
  • "a rejection after an applied fold says the request is still too large" — the same script with a working summarizer, asserting the note is present.

2. Architecture documents. Corrected in both languages, in the same commit as the rest of the doc pass: the reserve is min(2 × last reply, 8000) measured from the reply the model actually wrote, finishReason: length emits no Compact command and the reason is stated, and provider-overflow recovery is described as sharing one budget with the active-turn evaluator rather than owning a second one.

On the wording. You are right and I have adopted it. min(2 × last reply, 8000) is a local heuristic for a reversible action, so "the runtime does no estimation" was never the claim worth making. The PR now opens with the invariant as you put it: no local number may terminate a turn; a local number may only trigger a reversible fold, and the verdict is the provider's. Your causal spine is the design, stated more compactly than I managed:

accepted provider usage + user target -> optional reversible fold -> provider verdict -> at most one recovery attempt

The accepted-boundary retreat stays scoped to PR 2, as you read it.

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

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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XLUnder 2500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@Joob1n@likun666661@me2seeks
, '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): stop estimating context fit; the provider decides - #4653

Merged
likun666661 merged 2 commits into
apache:mainfrom
Joob1n:feat/context-send-module
Sep 3, 2026
Merged

fix(runtime): stop estimating context fit; the provider decides#4653
likun666661 merged 2 commits into
apache:mainfrom
Joob1n:feat/context-send-module

Conversation

@Joob1n

@Joob1nJoob1n commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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

  • The threshold is the previous accepted request's real inputTokens + outputTokens, plus the room the next reply needs, against the window the user declared. A /models report and generated metadata are hints beside the setting, never thresholds. No declaration means no proactive threshold.
  • The reply reserve is 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.
  • Compaction 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; a selected folded projection is a separate fact, and only that one is allowed to support a claim about what the request still contains.
  • Whether a request fits is the provider's answer. A classified context-length rejection folds once and resends; a rejection after a fold that was actually applied is reported as still too large after compaction; an unclassifiable error is reported as it came.
  • A finishReason: length drives 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_note kinds 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_usage persists the anchor as { inputTokens, outputTokens } and still decodes the retired payloadChars. 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 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 as input_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-host protocol 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

  • The dropping note compares input against input, not against the baseline: on wires that do not resend reasoning, input + output is not the floor of the next input, so a baseline comparison would report every such step as provider dropping.
  • The note is suppressed when the step's own active tool set shrank. A finalization step resolves an empty tool set and legitimately drops several thousand schema tokens with no fold, prune or image omission.
  • The reported-window note fires on the crossing rather than per send, because usage keeps growing past the line on providers that accept over-window requests; the persisted anchor carries the previous total, so a resumed session does not repeat a crossing it already reported.
  • resolveSelectedModelContextWindow still resolves the metadata window for display and for contextRemaining; 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:

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

Tool(s) and scope: Claude Code — implementation; 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 Summary above
  • No

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@likun666661

Copy link
Copy Markdown
Member

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.

  1. compactionUsedThisSend does not prove that history was compacted.

buildMidTurnCapacityCompactProjection() sets the flag before compactActiveRequestHistory() returns. If summary generation, validation, or checkpoint persistence fails, the code fails open with the raw projection but leaves the flag set. A later context-overflow rejection then:

  • skips reactive recovery because the send budget appears spent; and
  • writes context_message_too_large, whose copy says that history was already compacted and the new message itself does not fit.

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 proactive fold fails open -> dispatched raw request overflows.

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:

  • compactionAttemptedThisSend, used only to enforce the one-attempt budget; and
  • compactionAppliedThisSend, set only after a folded projection is actually selected?

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.

  1. The architecture docs in this PR still describe the superseded behavior.

docs/architecture/llm-compaction-events-log-projection-draft.md lines 183-189 (and the matching zh-CN section) say that the reserve is the model-declared maxOutputTokens and that finishReason: length emits a Compact command. The PR and implementation now say min(2 * last reply, 8000) and deliberately make finishReason: length drive nothing. Since this commit is intended to document the provider-decided architecture, these sections need to agree with the new design.

One wording point for the final problem statement: min(2 * last reply, 8000) is still a local heuristic for a reversible proactive action. That is fine, but the precise invariant is “local estimates have no terminal authority”, not literally “the runtime does no estimation”. With that wording, the causal spine is small and coherent:

accepted provider usage + user target -> optional reversible fold -> provider verdict -> at most one recovery attempt.

So my overall read is: the root problem definition has converged and this PR can solve the send-side authority/looping problem, but the message itself conclusion and the architecture-doc contradictions should be resolved before merge. The accepted-boundary retreat and the remaining compaction-side guarantees are correctly scoped to the follow-up PR rather than being claimed here.

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
@Joob1n
Joob1nforce-pushed the feat/context-send-module branch from 03781ee to 441603cCompareSeptember 3, 2026 11:52
@Joob1n

Copy link
Copy Markdown
ContributorAuthor

Both blockers were real, and the first was a defect I introduced rather than a wording problem. Fixed on 441603ceb.

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: compactionUsedThisSend was set before compactActiveRequestHistory returned, so a fold that failed open still looked like a compacted send. A later rejection then skipped reactive recovery and wrote a note asserting that history had already been compacted, while that request had in fact gone out carrying its full raw history.

Split exactly as you proposed:

  • compactionAttemptedThisSend — the budget, and only the budget. Set on entry from either the proactive or the reactive path. One attempt per send is still the rule, because the summarizer's own failure circuit latches for the send, so a second entry would dispatch nothing new.
  • compactionAppliedThisSend — set only where a folded projection is actually selected, in both paths. Nothing else may support a statement about what a still-rejected request contains.

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 context_overflow_after_compaction and reads: history was compacted and the provider still called this request too large; what remains also carries the system prompt, the tool schemas, the summary and the recent tail; shortening this message is the part you control.

Both regressions are in overflow-reactive-recovery.test.ts:

  • "a fold that failed open does not claim the request was compacted" — ['tool', 'overflow', 'overflow'] with a summarizer that returns nothing. Fails on the previous commit, where the note is written.
  • "a rejection after an applied fold says the request is still too large" — the same script with a working summarizer, asserting the note is present.

2. Architecture documents. Corrected in both languages, in the same commit as the rest of the doc pass: the reserve is min(2 × last reply, 8000) measured from the reply the model actually wrote, finishReason: length emits no Compact command and the reason is stated, and provider-overflow recovery is described as sharing one budget with the active-turn evaluator rather than owning a second one.

On the wording. You are right and I have adopted it. min(2 × last reply, 8000) is a local heuristic for a reversible action, so "the runtime does no estimation" was never the claim worth making. The PR now opens with the invariant as you put it: no local number may terminate a turn; a local number may only trigger a reversible fold, and the verdict is the provider's. Your causal spine is the design, stated more compactly than I managed:

accepted provider usage + user target -> optional reversible fold -> provider verdict -> at most one recovery attempt

The accepted-boundary retreat stays scoped to PR 2, as you read it.

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

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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XLUnder 2500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@Joob1n@likun666661@me2seeks
, '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): stop estimating context fit; the provider decides - #4653

Merged
likun666661 merged 2 commits into
apache:mainfrom
Joob1n:feat/context-send-module
Sep 3, 2026
Merged

fix(runtime): stop estimating context fit; the provider decides#4653
likun666661 merged 2 commits into
apache:mainfrom
Joob1n:feat/context-send-module

Conversation

@Joob1n

@Joob1nJoob1n commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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

  • The threshold is the previous accepted request's real inputTokens + outputTokens, plus the room the next reply needs, against the window the user declared. A /models report and generated metadata are hints beside the setting, never thresholds. No declaration means no proactive threshold.
  • The reply reserve is 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.
  • Compaction 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; a selected folded projection is a separate fact, and only that one is allowed to support a claim about what the request still contains.
  • Whether a request fits is the provider's answer. A classified context-length rejection folds once and resends; a rejection after a fold that was actually applied is reported as still too large after compaction; an unclassifiable error is reported as it came.
  • A finishReason: length drives 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_note kinds 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_usage persists the anchor as { inputTokens, outputTokens } and still decodes the retired payloadChars. 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 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 as input_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-host protocol 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

  • The dropping note compares input against input, not against the baseline: on wires that do not resend reasoning, input + output is not the floor of the next input, so a baseline comparison would report every such step as provider dropping.
  • The note is suppressed when the step's own active tool set shrank. A finalization step resolves an empty tool set and legitimately drops several thousand schema tokens with no fold, prune or image omission.
  • The reported-window note fires on the crossing rather than per send, because usage keeps growing past the line on providers that accept over-window requests; the persisted anchor carries the previous total, so a resumed session does not repeat a crossing it already reported.
  • resolveSelectedModelContextWindow still resolves the metadata window for display and for contextRemaining; 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:

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

Tool(s) and scope: Claude Code — implementation; 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 Summary above
  • No

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@likun666661

Copy link
Copy Markdown
Member

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.

  1. compactionUsedThisSend does not prove that history was compacted.

buildMidTurnCapacityCompactProjection() sets the flag before compactActiveRequestHistory() returns. If summary generation, validation, or checkpoint persistence fails, the code fails open with the raw projection but leaves the flag set. A later context-overflow rejection then:

  • skips reactive recovery because the send budget appears spent; and
  • writes context_message_too_large, whose copy says that history was already compacted and the new message itself does not fit.

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 proactive fold fails open -> dispatched raw request overflows.

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:

  • compactionAttemptedThisSend, used only to enforce the one-attempt budget; and
  • compactionAppliedThisSend, set only after a folded projection is actually selected?

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.

  1. The architecture docs in this PR still describe the superseded behavior.

docs/architecture/llm-compaction-events-log-projection-draft.md lines 183-189 (and the matching zh-CN section) say that the reserve is the model-declared maxOutputTokens and that finishReason: length emits a Compact command. The PR and implementation now say min(2 * last reply, 8000) and deliberately make finishReason: length drive nothing. Since this commit is intended to document the provider-decided architecture, these sections need to agree with the new design.

One wording point for the final problem statement: min(2 * last reply, 8000) is still a local heuristic for a reversible proactive action. That is fine, but the precise invariant is “local estimates have no terminal authority”, not literally “the runtime does no estimation”. With that wording, the causal spine is small and coherent:

accepted provider usage + user target -> optional reversible fold -> provider verdict -> at most one recovery attempt.

So my overall read is: the root problem definition has converged and this PR can solve the send-side authority/looping problem, but the message itself conclusion and the architecture-doc contradictions should be resolved before merge. The accepted-boundary retreat and the remaining compaction-side guarantees are correctly scoped to the follow-up PR rather than being claimed here.

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
@Joob1n
Joob1nforce-pushed the feat/context-send-module branch from 03781ee to 441603cCompareSeptember 3, 2026 11:52
@Joob1n

Copy link
Copy Markdown
ContributorAuthor

Both blockers were real, and the first was a defect I introduced rather than a wording problem. Fixed on 441603ceb.

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: compactionUsedThisSend was set before compactActiveRequestHistory returned, so a fold that failed open still looked like a compacted send. A later rejection then skipped reactive recovery and wrote a note asserting that history had already been compacted, while that request had in fact gone out carrying its full raw history.

Split exactly as you proposed:

  • compactionAttemptedThisSend — the budget, and only the budget. Set on entry from either the proactive or the reactive path. One attempt per send is still the rule, because the summarizer's own failure circuit latches for the send, so a second entry would dispatch nothing new.
  • compactionAppliedThisSend — set only where a folded projection is actually selected, in both paths. Nothing else may support a statement about what a still-rejected request contains.

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 context_overflow_after_compaction and reads: history was compacted and the provider still called this request too large; what remains also carries the system prompt, the tool schemas, the summary and the recent tail; shortening this message is the part you control.

Both regressions are in overflow-reactive-recovery.test.ts:

  • "a fold that failed open does not claim the request was compacted" — ['tool', 'overflow', 'overflow'] with a summarizer that returns nothing. Fails on the previous commit, where the note is written.
  • "a rejection after an applied fold says the request is still too large" — the same script with a working summarizer, asserting the note is present.

2. Architecture documents. Corrected in both languages, in the same commit as the rest of the doc pass: the reserve is min(2 × last reply, 8000) measured from the reply the model actually wrote, finishReason: length emits no Compact command and the reason is stated, and provider-overflow recovery is described as sharing one budget with the active-turn evaluator rather than owning a second one.

On the wording. You are right and I have adopted it. min(2 × last reply, 8000) is a local heuristic for a reversible action, so "the runtime does no estimation" was never the claim worth making. The PR now opens with the invariant as you put it: no local number may terminate a turn; a local number may only trigger a reversible fold, and the verdict is the provider's. Your causal spine is the design, stated more compactly than I managed:

accepted provider usage + user target -> optional reversible fold -> provider verdict -> at most one recovery attempt

The accepted-boundary retreat stays scoped to PR 2, as you read it.

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

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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XLUnder 2500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@Joob1n@likun666661@me2seeks