Skip to content

fix(core): keep requests inside strict context windows and compact later - #59951

Open
neriousy wants to merge 1 commit into
devfrom
context-budget
Open

neriousy wants to merge 1 commit into
devfrom
context-budget

Conversation

@neriousy

@neriousy neriousy commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Issue for this PR

Closes #58463

Supersedes #59934, which only covered normal turns.

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

This reworks how the context window is budgeted. I replayed about 41k real steps from local sessions through a port of the current rules. Four things go wrong today:

  1. chars/4 undercounts some tokenizers badly. On tool-heavy steps Claude counts about 1.6× opencode's estimate, Gemini 1.26×, GPT 1.02×. The 15% margin on the unmeasured tail is too small on 99% of Claude steps.
  2. Output limits fill the window exactly. Once the prompt passes W − output, max_tokens is the exact room left, so strict providers (Anthropic, OpenRouter) reject the request whenever the estimate runs low. Kimi K2.6 via OpenRouter hits this from a 26k prompt on. Claude on a 200k window hits it at about 136k, so in practice compaction is forced by a rejection before it ever reaches its trigger.
  3. The summary request fills the window exactly too. fitOutput's floor branch uses the room left with no margin. Fixing only normal turns (fix(core): leave headroom when fitting output to the context window #59934) moves the rejection there: in the replay, 39% of Claude 200k compactions then fell back to text.
  4. The 15% reserve is the wrong shape. How far the context overshoots the trigger depends on step sizes, not on the window. The overshoot exceeds m with probability E[(G − m)⁺] / E[G] for step growth G. So 15% is wasted on 1M windows (133k between trigger and cache-hit limit) and thin on small ones.

Changes:

  • outputLimit never claims the last 1% of the window (at least 4k, at most 5%), and keeps a 25% margin on the unmeasured tail. This covers normal turns, the summary request's starting limit, and both branches of fitOutput.
  • estimatePrompt scales the unmeasured tail by a ratio learned from the history: between consecutive measured responses, how many tokens the provider counted for what chars/4 estimated. It is clamped to [1, 3], and falls back to 1 until two responses since the last compaction have been measured. No new state.
  • deliver sends the history before the kept tail unchanged up to the cache-hit limit, not just up to the trigger. When it can, it sizes that history from the measured count of the response that ends it. The margin and the kept tail now add up instead of covering the same overshoots.
  • The trigger is W − 16k − min(10k, 5% W) instead of W − max(15% W, 16k). That is 974k instead of 850k on 1M windows and 174k instead of 170k on 200k. Windows under 32k keep the old 15%.

In the replay on strict providers (they reject prompt + max_tokens past the window):

compactions that reuse the cache mean context per request rejected requests per 1k steps
Claude 200k 99% → 99%, but before, every compaction is forced by a rejected request 42% → 52% 17 → 0.09
Claude Opus 1M 97% → 99% 44% → 50% 0 → 0.01
Kimi 262k (OpenRouter) 99% → 99%, but before, every compaction is forced by a rejected request 15% → 49% 590 → 0.06

Trade-offs: the summary's output limit on a 200k window drops from 30k to 22k, which is still above the largest summaries. Windows between 32k and 200k compact a few thousand tokens earlier, for example 44.8k instead of 48k on 64k. Larger context per request also costs more per step, since every request rereads its prefix.

Not included: a cost-oriented trigger mode, and the 64k floor on background compaction (#58818).

Before and after

These come from a simulator that ports the budgeting rules before and after this PR and replays real step sizes from local sessions (about 41k steps). The source for the simulator and the charts is in a separate repo, and I can publish it.

Why the estimate needs calibrating. For the same tool output, Claude needs about 1.6× the tokens chars/4 predicts, Gemini about 1.26×. The 15% margin covers GPT and almost nothing else.

Provider tokens per chars/4 token, by model family

Where the window goes. Compaction starts later on large windows. The window never fills to the last token, because 1% (at least 4k) is never claimed by max_tokens. Purple marks where max_tokens is fitted to the room left. For Kimi that already happens from a 26k prompt.

Window budget before and after for Claude 200k, Kimi 262k and Claude Opus 1M

One session, before and after. Before, Claude 200k never reaches its trigger: the first request whose output is fitted to the window gets rejected, at about 70%, and that rejection forces the compaction. Kimi via OpenRouter is rejected on more than half of its requests and compacts every couple of steps at about 15% of the window. After, both run to the trigger and compact with a cache hit.

Prompt over one simulated session, before and after

Requests rejected at the window edge. This is the share of fitted requests a strict provider rejects, replaying every real step. Headroom on normal turns alone (#59934) still leaves 6% of Claude requests rejected, and moves the failure to the summary request.

Rejected share of fitted requests by rule and model family

Before and after per model: mean context, compactions that reuse the cache, rejected requests

How did you verify your code works?

  • New tests:
  • Updated expectations that pinned the old ceiling (85%) and the old summary limits (30k becomes 22k on 200k). One runner test now sends the second summary as text, because the measured count says its history no longer fits a 4k window unchanged; the previous kept tail is still summarized.
  • bun run script/test.ts in packages/core: 6901 pass. The 7 failures are shell-tool tests that also fail on dev here (macOS bash 3.2).
  • Session test files --rerun-each 10 --randomize: all pass.
  • bun run check passes.

Visual

<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72" fill="none">
  <style>
    .p{animation:p 5s ease-in infinite}
    @keyframes p{0%{width:34px}78%{width:186px}84%{width:186px}92%{width:46px}100%{width:34px}}
    @media (prefers-reduced-motion:reduce){.p{animation:none}}
  </style>
  <rect x="20" y="24" width="200" height="22" rx="5" stroke="currentColor" stroke-opacity=".35"/>
  <rect class="p" x="20" y="24" width="186" height="22" rx="5" fill="currentColor" fill-opacity=".5"/>
  <rect x="212" y="24" width="8" height="22" rx="2" fill="currentColor" fill-opacity=".14"/>
  <line x1="190" y1="18" x2="190" y2="52" stroke="currentColor" stroke-opacity=".3" stroke-dasharray="2 2"/>
  <line x1="206" y1="18" x2="206" y2="52" stroke="currentColor" stroke-opacity=".85"/>
  <text x="190" y="62" font-family="system-ui,sans-serif" font-size="8" text-anchor="middle" fill="currentColor" fill-opacity=".45">15%</text>
  <text x="206" y="13" font-family="system-ui,sans-serif" font-size="8" text-anchor="middle" fill="currentColor" fill-opacity=".75">compact</text>
  <text x="218" y="62" font-family="system-ui,sans-serif" font-size="8" text-anchor="middle" fill="currentColor" fill-opacity=".6">free</text>
</svg>

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@opencodejr opencodejr Bot 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.

The headroom in outputLimit and fitOutput fixes the exact-fill rejections from #58463. I called outputLimit from both checkouts with the issue's DeepSeek 1M, Kimi 262k and Claude 200k numbers. On base, those requests leave 300–1,170 tokens free in the window by our own estimate, which a small provider undercount overruns. With this PR they leave 4.5k–12k. The three changed session test files pass (281 tests), and packages/core type-checks.

Public API: no schema, SDK, HTTP or CLI changes. Only the default of compaction.buffer changes: 15% of the window (at least 16k) becomes 16k plus min(10k, 5% of the window). Windows under 32k keep 15%. Existing configs still work as before.

One concern, inline: tokenRatio pairs measured responses across agent and variant changes, which measuredPrompt deliberately excludes.

Related: #50574

* characters. chars/4 runs low on dense code and JSON for some tokenizers, about 1.6× for Claude. Never below 1, so
* an overcount only compacts a little early, and 1 until two responses since the last compaction have been measured.
*/
const tokenRatio = (context: SessionContext.Loaded) => {

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.

tokenRatio only checks the model id, but measuredPrompt also requires the same variant and agent because "they shape the request". When the agent changes between two measured responses (e.g. plan → build), the difference in system prompt and tool definitions ends up in counted, while estimated only covers the appended messages. Early in a session, a single pair like that, with a tail of about 1k estimated tokens, can push the ratio to the 3× cap. That ratio then triples the estimate for the next large tool result, which can start compaction early and cut max_tokens well below what fits. A negative delta in the other direction pulls the ratio down. Could the ratio only use consecutive pairs with the same agent and variant (and model) as the current request?

This branch has not been deployed

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

Labels

None yet

Projects

None yet

1 participant