Skip to content

v2: context-window ceilings are inverted on large models — request ceiling (context − 256k) below compaction ceiling (context − 15%), so sessions fail at ~75.6% #58463

Description

@oopen

Description

On a 1M context model, the default compaction buffer is 15% of the window:
157,286 tokens. That is large in absolute terms (about 10× the 16k minimum),
but smaller than the 256,000 output reservation v2 uses per request. The two
ceilings are therefore inverted:

  • compaction ceiling: 1 048 576 − 157 286 = 891 290 (85%)
  • request ceiling: 1 048 576 − 256 000 = 792 576 (75.6%)

The request overflows 98,714 tokens before compaction can run. On a 1M model
the percentage buffer is both large (157k below the limit) and insufficient
(below the 256k reservation).

flowchart LR
  A["0"] --> B["792,576 · 75.6%<br/>request ceiling"]
  B --> C["891,290 · 85%<br/>compaction ceiling"]
  C --> D["1,048,576 · 100%<br/>context limit"]
  B -. "98,714<br/>reservation exhausted" .-> C
Loading

Sessions fail at ~75.6% of the window, or compact mid-turn. Observed on
OpenCode 2.1.4.

Model: DeepSeek V4.1 Flash (deepseek-flash), context_window: 1048576,
max_output_tokens: 393216. No limit override.

Error:

This model's maximum context length is 1048576 tokens. However, you requested
1048582 tokens (799027 in the messages, 249555 in the completion).

799 027 + 249 555 = 1 048 582. The 249 555 completion is the room left:
1 048 576 − 249 555 = 799 021. That 799 021 is the engine's estimate; the
provider counted 799 027, so the request exceeded the window by 6 tokens.

  • compaction.auto: true: the overflow recovery compacts at ~75.6% mid-turn.
  • compaction.auto: false: the turn fails.

v1 capped every request at min(limit.output, 32_000), so the request ceiling
was 96.9% and compaction (85%) ran first.

Expected: the request ceiling should not be lower than the compaction ceiling
(compute the buffer from the output reservation, or cap maxTokens below the
compaction ceiling).

Code references, dev (v2) as of 2026-10-23:
packages/core/src/session/model-request.ts:48-54 (OUTPUT_TOKEN_MAX = 256_000),
:85-90 (outputLimit), packages/core/src/session/compaction.ts:1104-1111
(calculateCeiling).

Workaround, no per-model limit.output: a v2 model transform caps
limit.output at 32,000 for every model.

~/.config/opencode/opencode.jsonc:

{
  "compaction": { "keep": { "tokens": 24000 } }
}

auto keeps its default (true); buffer is unset (default 15%, min. 16k →
compaction at 85%). keep.tokens: 24000 is a compaction-quality setting
(recent context kept verbatim), not part of the ordering fix.

~/.config/opencode/plugin/output-cap.ts:

export default {
  id: "output-cap",
  effect: (ctx) =>
    ctx.model.transform((models) => {
      for (const m of models.list())
        if (m.limit.output > 32_000)
          models.update(m.providerID, m.id, (item) => {
            item.limit.output = 32_000
          })
    }),
}

Restart opencode, then verify (redirect to a file; the CLI truncates large
payloads on a pipe):

opencode api get /api/model > /tmp/model.json
jq '.data[] | select(.modelID=="deepseek-flash") | .limit' /tmp/model.json

Expected: {"context":1048576,"output":32000}. The request ceiling is back to
1,016,576 (96.9%), above the 85% compaction ceiling.

Related issues (all closed, none fixed for this ordering)

Plugins

none for the reproduction. Workaround: local plugin output-cap.ts (v2 model transform, caps limit.output at 32000 for every model).

OpenCode version

2.1.4

Steps to reproduce

  1. Use a model with context >= 1M and max_output_tokens >= 256000 (DeepSeek V4.1 Flash: 1048576 / 393216).
  2. Leave limit unset in opencode.jsonc. Set compaction.auto: false (or true).
  3. Run a session until the prompt reaches ~792k tokens.
  4. The next request fails with the context-length error (auto:false) or triggers a compaction at ~75.6% (auto:true).

Screenshot

Context window: request ceiling 792,576 (75.6%) below compaction ceiling 891,290 (85%); 98,714-token zone where maxTokens shrinks to the remaining window

Operating System

Linux

Terminal

N/A (opencode web)

Activity

  1. opencode-agent commented on Oct 23, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed analysis, @oopen. I reproduced this on 2.1.4, and it still happens on the latest dev (24b5ed8).

    Setup: a mock OpenAI-compatible provider with limit: { context: 1048576, output: 393216 }, no compaction.buffer. The mock reports an ~800k prompt after the first turn, then counts a few tokens more than OpenCode on later requests and enforces the 1,048,576 window the way DeepSeek does.

    • At ~800k the prompt is still below the 85% compaction ceiling (891,290), so nothing compacts before the next request.
    • That request asks for max_tokens: 248568, exactly the room OpenCode estimates is left, and the provider rejects it: you requested 1048583 tokens (800015 in the messages, 248568 in the completion).
    • With compaction.auto: false the turn fails. With compaction.auto: true overflow recovery compacts mid-turn at ~76% instead of at the preflight 85%.

    The script below runs the reproduction (with OC_DEV=<checkout> it runs from source). I've passed this to the maintainers with pointers to outputLimit in model-request.ts and calculateCeiling in compaction.ts. Your limit.output cap plugin looks like a reasonable workaround for now.

    Related: #50574 (1M-context sessions not compacting before a provider 400), #29363 (the change that removed the 32k output clamp).

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduces #58463: on a 1M-context / 393k-output model, a prompt of ~800k (below the 85% compaction
    # ceiling of 891,290) gets max_tokens = exact estimated room, so a few tokens of provider/engine count
    # drift overflow the window; compaction never runs first.
    # Usage: ./repro.sh [opencode command...]   (default: opencode from PATH)
    # From a source checkout, set OC_DEV=/path/to/repo (runs packages/cli/src/index.ts, with its own server).
    set -u
    OC=("${@:-opencode}")
    if [ -n "${OC_DEV:-}" ]; then
      OC=(bun --conditions=browser --preload="$OC_DEV/packages/tui/node_modules/@opentui/solid/scripts/preload.js" "$OC_DEV/packages/cli/src/index.ts")
    fi
    DIR=$(cd "$(dirname "$0")" && pwd)
    for AUTO in false true; do
      P=$(mktemp -d); export HOME=$(mktemp -d)
      cat > "$P/opencode.json" <<JSON
    { "compaction": { "auto": $AUTO },
      "providers": { "mock": { "name": "Mock DeepSeek", "package": "@opencode/ai/providers/openai-compatible",
        "settings": { "baseURL": "http://127.0.0.1:4599/v1", "apiKey": "x" },
        "models": { "flash": { "name": "flash", "limit": { "context": 1048576, "output": 393216 } } } } } }
    JSON
      bun "$DIR/mock.ts" > "$P/mock.log" 2>&1 & MOCK=$!; sleep 1
      echo "===== compaction.auto=$AUTO ====="
      CONN=(--standalone); SRV=""
      if [ -n "${OC_DEV:-}" ]; then
        export OPENCODE_PASSWORD=repro
        (cd "$P" && exec "${OC[@]}" serve --port 4610 > "$P/serve.log" 2>&1) & SRV=$!
        for i in $(seq 60); do curl -s -o /dev/null http://127.0.0.1:4610 && break; sleep 1; done
        CONN=(--server http://127.0.0.1:4610)
      fi
      (cd "$P" && timeout 120 "${OC[@]}" run "${CONN[@]}" -m mock/flash --session ses_repro_$AUTO "hello" 2>&1 | tail -3)
      echo "--- turn 2 (prompt now ~800k, below the 891,290 compaction ceiling)"
      (cd "$P" && timeout 120 "${OC[@]}" run "${CONN[@]}" -m mock/flash --session ses_repro_$AUTO "second" 2>&1 | tail -4)
      echo "--- requests seen by the provider"; cat "$P/mock.log"
      kill $MOCK; wait $MOCK 2>/dev/null
      [ -n "$SRV" ] && { pkill -P $SRV 2>/dev/null; kill $SRV 2>/dev/null; wait $SRV 2>/dev/null; }
    done

    Run it: bash repro.sh

  2. oopen commented on Oct 23, 2026

    @oopen
    Author

    Reproduced here too, same mechanism.

    The overshoot size depends on the count drift between the engine estimate and
    the provider (1 token in your mock, 6 on DeepSeek). The cause is that
    maxTokens is set to the remaining room with no headroom, while the compaction
    ceiling is computed above the request ceiling.

    #50574 is the same class on 1M models. Its ceiling formula is from 2.0.12
    (context − max(min(output, 32k), buffer) = 968k); on 2.1.4 the default is
    context − 15% = 891k, still above the 792k request ceiling.

    The workaround is a v2 model transform that caps limit.output at 32k for
    every model (not a per-model providers.<id>.models.<id>.limit override).
    compaction.keep.tokens: 24000 is a separate quality setting. It restores the
    v1 ordering: request ceiling 96.9% > compaction 85%.

  3. mark-kojo commented on Oct 23, 2026

    @mark-kojo

    Same mechanism on a much smaller prompt, with Kimi K2.6 through OpenRouter (2.1.3 and dev). The catalog lists context: 262144, output: 235929, so the output request fills almost the whole window and the drift only has to be a few hundred tokens:

    This endpoint's maximum context length is 262144 tokens. However, you requested about 262223 tokens
    (11262 of text input, 4335 of image input, 11546 of tool input, 235080 in the output).
    

    So the session compacts at about 27k tokens, roughly 10% of the window. The drift here is bigger than in the DeepSeek case because the estimate seems to leave out part of the tool definitions (an MCP server with ~45 tools) and pictures.

    One side effect worth knowing: after the mid-turn compaction the model only had the summary and answered a request nobody made.

    Capping the output with a provider override (limit: { output: 32000 }) fixes it for us, which matches the v1 behaviour you mention.

  4. oopen commented on Oct 23, 2026

    @oopen
    Author

    The Kimi case is the same ratio problem at a different scale: 235,929/262,144 =
    90% of the window reserved for output, so the compaction ceiling collapses to
    26,215 (10%). The 79-token overflow is the estimator missing part of the tool
    definitions/images, which maxTokens = room cannot absorb.

    Two separate defects:

    1. maxTokens = room leaves no headroom. The request must leave the compaction
      buffer free: maxTokens = min(output, context − buffer − prompt). On Kimi
      that gives ~195k output at 27k input instead of 235k, and the session can
      reach 222k (85%) before compaction instead of 26k.
    2. The preflight estimate must include tool schemas and image tokens; a 79-token
      miss is enough at the boundary.

    A 32k cap fixes both by brute force but throws away 200k of output on this model.

  5. Dopetaiga commented on Oct 23, 2026

    @Dopetaiga

    I'd like to take this. Plan: make the compaction ceiling never exceed the request ceiling (context minus the output-token reservation) in calculateCeiling / the model-request ceiling math, so compaction always triggers before the request-side overflow on ~1M-context models. Will verify against the numbers from the issue and open a PR.

  6. oopen commented on Oct 23, 2026

    @oopen
    Author

    Proposition — derive the output budget and reserve from the model's output limit

    Verified against anomalyco/opencode@e3e83be (dev), 2026-10-23.

    TL;DR

    Two budgets are computed independently and neither reserves anything for the
    other. Replace the percentage reserve with a reserve derived from the output
    budget, and make the compaction request truncate its prompt to the ceiling:

    F_policy = 32 000   output budget for the summary (output-first default)
                         16 000 in the window-first variant
    S_policy =  8 000   safety reserve (same in both variants)
    
    F       = min(F_policy, O, floor(window / 4))
    S       = min(S_policy, floor(F / 2))
    reserve = min(F + S, floor(window / 2))
    T       = window - reserve
    request:            M(P) = min(O, window - S - P)
    compaction:         truncate the conversation to T - overhead, then ask for F
    

    O = min(limit.output, OUTPUT_TOKEN_MAX) is the engine's usable output, and
    overhead is the summary prompt plus its nudge — about 1k tokens.

    At P = T the compaction request sends T input + F output = window - S
    and fits with S of margin. If the prompt overshot T — a single step grew by
    up to ~22k tokens at p99, per the comment on RESERVE_SHARE — the prompt is
    truncated back to T instead of the output being squeezed.

    On 1M the reserve becomes 40k (3.8%) instead of 15% (157k). On 32k a derived
    reserve of 12k replaces a 16k floor that today pushes the ceiling down to 51%.
    On 128k the ceiling drops by 15.5 points — the cost of a 32k summary budget,
    which is 24% of that window.

    The default keeps F at the allowance the code already grants a shrinking
    summary (SUMMARY_OUTPUT_FLOOR = 32 000). An under-sized F does not degrade
    the summary, it fails the compaction and re-fails on every following turn, so
    the default is the safe end of the trade and measurement lowers it if warranted.

    What is broken

    The two budgets, quoted from e3e83be:

    // model-request.ts
    const requested = Math.min(limit.output > 0 ? limit.output : OUTPUT_TOKEN_FALLBACK, OUTPUT_TOKEN_MAX)
    const room = limit.context - inputTokens.measured - Math.ceil(inputTokens.estimated * (1 + ESTIMATE_ERROR))
    return Math.min(requested, Math.max(OUTPUT_TOKEN_MIN, room))
    
    // compaction.ts
    const window = limit.input || limit.context
    if (buffer !== undefined && buffer < window) return window - buffer
    return window - Math.max(Math.floor(window * RESERVE_SHARE), window >= 2 * RESERVE_MIN ? RESERVE_MIN : 0)

    The request budget is min(O, C - P): it ends exactly on C with no margin, so
    any drift between the estimate and the provider's count overflows. The ceiling
    is unrelated to it, and limit.output never enters it.

    Model C O Engine prompt Request budget Provider prompt Total
    DeepSeek V4.1 Flash 1 048 576 256 000 799 021 249 555 799 027 1 048 582 (+6)
    Kimi K2.6 262 144 235 929 27 064 235 080 27 143 262 223 (+79)

    Both were primary requests issued below a ceiling of 85%, and both asked for the
    entire remaining window.

    That is the reported failure: P + M lands on C, so six tokens of drift reject
    the request. A separate risk is a reply that consumes its whole budget — it
    leaves the next prompt at C - S, past the trigger and already 235k above where
    it started on Kimi, with no room for the tool results that follow. Neither
    reported request got that far. The second risk is why the provider-rejection path
    and the SHRINK_STEPS ladder must stay — no arithmetic bound can anticipate tool
    output. What the arithmetic removes is the zero-margin request, and the waste of
    asking for 235k of output on a 27k prompt.

    The change

    • P + M(P) <= C - S for every request with P <= C - S; at P = C - S the
      budget is 0 and the request must not be sent.
    • At compaction the conversation is truncated to T - overhead, so the full
      request — conversation plus summary prompt and nudge — is T and
      T + F = C - S. This replaces fit = max(budget, summaryRoom), which lets the
      compaction prompt run up to C - 16_000 to keep the prefix cache: a deliberate
      trade this proposal reverses in favour of a guaranteed F.
    • On the default branch (no buffer), P = T implies M = F. Under a buffer
      override the compaction budget is reserve - S.
    • M(P) depends on S only, so it is identical in both variants; the variant
      moves the ceiling, not the per-turn output.
    flowchart LR
      A["P < T<br/>M = min(O, C - S - P)"] --> B["P >= T<br/>truncate to T - overhead"]
      B --> C["compaction: T + F = C - S<br/>(full request, overhead included)"]
      C --> D["summary, P drops"]
      D --> A
    
    Loading

    Ceilings

    Window Today T Today reserve Output-first Window-first
    1M 891 290 (85.0%) 157 286 1 008 576 (96.2%) 1 024 576 (97.7%)
    262k 222 823 (85.0%) 39 321 222 144 (84.7%) 238 144 (90.8%)
    128k 111 412 (85.0%) 19 660 91 072 (69.5%) 107 072 (81.7%)
    64k 49 536 (75.6%) 16 000 41 152 (62.8%) 41 536 (63.4%)
    32k 16 768 (51.2%) 16 000 20 480 (62.5%) 20 480 (62.5%)
    16k 13 927 (85.0%) 2 457 10 240 (62.5%) 10 240 (62.5%)

    The 64k row is 65 536, above the point where S stops tracking the window, so
    its reserve is 24 384 (37.2%) and the ceiling reads 62.8% and not 62.5%. The 32k
    and 16k rows follow T = 5C/8 exactly. The three large windows are all at or
    above 128 000, where S and F are both capped and T = C - 40 000.

    One decision needed

    F_policy: 32k (output-first default, matches SUMMARY_OUTPUT_FLOOR, 128k at
    69.5%) or 16k (window-first, 128k at 81.7%)? The failure modes are asymmetric:
    too large an F costs ceiling, too small an F fails the compaction outright
    and re-fails every turn. The default should stay at 32k until the p99 of
    usage.output_tokens over compaction responses is measured; 16k is only safe
    once that tail is known to sit well below it.

    Full derivation, implementation sketch, tests and release note

    Why not 15%

    15% is doing two unrelated jobs: a summary budget on large windows, and a
    step-absorption margin everywhere. The summary needs an absolute amount; a step
    is better handled by truncating the prompt at compaction time.

    Net effect on the reserve: 157k → 40k on 1M, 39.3k → 40k on 262k, 19.7k → 40k on
    128k, 16k → 24.4k on 64k, 16k → 12.3k on 32k, 2.5k → 6.1k on 16k. This is not a
    uniform shrink. It is a fixed budget where a proxy used to be, so it falls where
    the proxy was too large and rises where it was too small.

    The 16k floor is one defect with two symptoms. The window >= 2 * RESERVE_MIN
    guard makes the reserve discontinuous at 32 000 — the ceiling drops from 85% to
    50% across a single token. And between 32 000 and 106 667 the floor dominates the
    15% share, so 64k and 32k windows lose 9.4 and 33.8 points of ceiling to a floor
    meant for larger windows.

    A derived reserve has neither problem: it is continuous across all six rows, with
    kinks only where S and F stop tracking the window (64 000 and 128 000).

    The 128k row is the honest cost of the default: a 32k summary budget is 24% of a
    128k window, so the ceiling drops to 69.5%. The window-first variant gives back
    12 points there at the price of a 16k summary budget. That trade is the one open
    product decision.

    Proposal

    Let:

    • C = context window (limit.input when set, as calculateCeiling already does)
    • P = prompt tokens, measured + ceil(estimated * (1 + ESTIMATE_ERROR)), one
      value for both the trigger and the request (see "one estimate, one inflation")
    • O = min(limit.output, OUTPUT_TOKEN_MAX)
    • F = compaction output budget, S = request safety reserve
    • reserve = tokens kept free below C, T = C - reserve
    • overhead = summary prompt + nudge, about 1k tokens

    Default (output-first):

    F_policy = 32 000
    S_policy =  8 000
    
    F        = min(F_policy, O, floor(C / 4))
    S        = min(S_policy, floor(F / 2))
    reserve  = min(F + S, floor(C / 2))
    T        = C - reserve
    M(P)     = min(O, C - S - P)
    

    Window-first variant: F_policy = 16 000, S_policy = 8 000. Only F_policy
    changes: S covers estimate drift and does not depend on the summary budget.

    • The window / 2 cap only binds a buffer override: the derived default peaks
      at 3C/8.
    • With compaction.auto: false, M reaches 0 at C - S and the request must
      fail with a typed error rather than be sent with a token budget of 0.
    • The provider-rejection path stays. A tool result can still push the prompt past
      C between two requests; S covers estimate drift, not tool output.

    Numbers

    Output at a given prompt — the per-turn budgets, M(P), identical in both
    variants because they depend on S only. T is in the table above.

    Case Today Output-first Window-first
    1M at 800k 248 576 240 576 240 576
    262k at the reported 27 064 235 080 227 080 227 080
    128k at 80k 51 072 43 072 43 072

    Small windows, output-first default. All three rows sit below 128 000, so
    F = C/4; the 64k row is 65 536, above the point where S is capped at 8 000:

    Window F S T T + F
    64k 16 384 8 000 41 152 (62.8%) 57 536 (8k free)
    32k 8 192 4 096 20 480 (62.5%) 28 672 (4k free)
    16k 4 096 2 048 10 240 (62.5%) 14 336 (2k free)

    Compaction request at T, full request including overhead. The total is
    C - S in both variants — the variant moves the trigger point, not the total:

    Window T + F free
    1M 1 040 576 8k
    262k 254 144 8k
    128k 123 072 8k

    Why this is the best option

    Against a 32k cap on limit.output

    A cap truncates output on every turn regardless of the window: it hits long
    reasoning (as reported in #29363: reasoning: 32000, output: 0, finish_reason: length) and large writes, and on Kimi it throws away 204k of
    output the model is allowed to produce. It does not move the compaction
    ceiling — limit.output does not appear in calculateCeiling — so it buys
    nothing on the ceiling it is usually proposed to protect.

    Against 15%

    It scales with the window while the need does not: 157k on 1M for a summary that
    is structured and terse, 2 457 on 16k for a prompt plus a summary, and a cliff
    at 32 000. The margin's real job — absorbing a step past the trigger, p99 ≈ 22k —
    is done deterministically by truncation instead of by a window-proportional
    number that is wrong in both directions.

    Against tuning compaction.buffer

    buffer is already the exact reserve: T = C - buffer, ignored when
    buffer >= window. So it cannot fix the request path — outputLimit never
    reads it — and buffer: 0 today sets the ceiling to C, i.e. no reserve at all.
    The proposal keeps buffer as an override but clamps it to [1, C/2] and makes
    the request path honour the same S.

    Who decides what

    Decision Nature Owner
    F >= p99(summary output), S >= p99(drift) empirical measurement
    F_policy = 32k vs 16k product trade-off: summary headroom vs ceiling at 128k maintainers
    compaction.buffer: keep, clamp, rename to reserve API / compatibility maintainers
    Per-deployment override operations operator

    Implementation sketch

    packages/core/src/session/limits.ts (new, shared). It also owns the output
    constants, so that model-request.ts can import limitsFor without a cycle:

    export const OUTPUT_TOKEN_FALLBACK = 32_000
    export const OUTPUT_TOKEN_MAX = 256_000
    export const ESTIMATE_ERROR = 0.15
    export const F_POLICY = 32_000
    export const S_POLICY = 8_000
    
    export type Limits = { output: number; safety: number; reserve: number; ceiling: number }
    
    export const limitsFor = (limit: Model.Info["limit"], buffer?: number): Limits => {
      const window = limit.input || limit.context
      const declared =
        Number.isFinite(limit.output) && limit.output > 0 ? limit.output : OUTPUT_TOKEN_FALLBACK
      const usable = Math.min(declared, OUTPUT_TOKEN_MAX)
      if (!Number.isFinite(window) || window <= 0)
        return { output: usable, safety: 0, reserve: 0, ceiling: Number.POSITIVE_INFINITY }
    
      const output = Math.min(F_POLICY, usable, Math.floor(window / 4))
      const safety = Math.min(S_POLICY, Math.floor(output / 2))
      const half = Math.floor(window / 2)
      const chosen = buffer != null && Number.isFinite(buffer) ? buffer : output + safety
      // Inner max: never leave the summary with nothing. Outer min: never exceed half the window.
      const reserve = Math.min(Math.max(chosen, Math.min(safety + 1_024, half)), half)
      return {
        output: Math.max(0, Math.min(output, reserve - safety)),
        safety,
        reserve,
        ceiling: window - reserve,
      }
    }

    model-request.ts:

    export const outputLimit = (
      limit: Model.Info["limit"],
      inputTokens?: Input["inputTokens"],
      buffer?: number,
    ) => {
      const { output, safety } = limitsFor(limit, buffer)
      if (inputTokens === undefined) return output
      const window = limit.input || limit.context
      if (!Number.isFinite(window) || window <= 0) return output
      const room =
        window - safety - inputTokens.measured - Math.ceil(inputTokens.estimated * (1 + ESTIMATE_ERROR))
      return Math.min(output, Math.max(0, room))
    }

    The call site must not send a zero budget: when outputLimit(...) < 1, compact
    before sending (auto) or fail with a typed session error (auto: false). Never put
    max_tokens: 0 on the wire.

    compaction.ts:

    const calculateCeiling = (limit: Model.Info["limit"], buffer?: number) =>
      limitsFor(limit, buffer).ceiling
    
    // in summarize()
    const overhead = Token.estimate(prompt) + Token.estimate(NUDGE)
    const fit = budget // drops the `max(budget, summaryRoom(limit))` relaxation
    const result = yield* deliver(trigger, prepared, budget - overhead, send, {
      fit: fit - overhead,
      history: withoutTail(prepared.request, keepID),
    })

    Notes:

    • outputLimit must switch from limit.context to limit.input || limit.context.
      calculateCeiling already does this; the two currently disagree when
      limit.input < limit.context.
    • Pass usable, not raw limit.output, into limitsFor: F must never exceed
      what the engine can actually ask for.
    • prepare must keep receiving the ceiling as its budget:
      prepare({ ..., inputTokens: { measured: T, estimated: 0 } }) gives
      maxTokens = min(O, C - S - T) = F. deliver then truncates to T - overhead
      and send appends overhead, so the full request is T and
      T + F = C - S. Neither the truncation nor the overhead is counted twice.
    • buffer has to reach model-request.ts, which has no access to
      compaction.Settings. Add a buffer field to Input, filled by prepare
      from the compaction settings, and thread it through the two fitOutput calls as
      well — otherwise S differs between the main path and the shrink path.
    • fitOutput becomes inert under the truncation rule. prepare sizes the
      compaction request at F, deliver truncates to T - overhead, so
      min(current, …) can only lower a budget that is already guaranteed. Keep it
      as a defensive assertion or drop it, but do not rely on it. The deliver ladder
      is still needed: it is what absorbs a provider that counts more than the
      estimator, and the payload-too-large path, which no arithmetic can predict.
    • OUTPUT_TOKEN_MIN must leave outputLimit. It is documented as reachable
      only with auto: false, and at that point room = C - S - P < 1: sending
      1 024 tokens overflows the window by 1 024. Remove the clamp and let the caller
      fail with a typed session error — the honest outcome when automatic compaction
      is off. The constant then becomes unused and should be deleted.
    • Include the compaction prompt in the estimate used for the compaction request
      only. Adding it to every normal request costs its size on each turn and moves
      the trigger earlier for no reason.
    • An under-sized F fails the compaction, it does not degrade it.
      finish_reason: "length" on the summary maps to compaction.failed
      ("Compaction summary reached the output token limit"), and deliver never
      retries a Failure from send. due() stays true, so the next turn attempts
      the same failing compaction. This is why F_policy starts at
      SUMMARY_OUTPUT_FLOOR rather than below it.
    • precompute starts at PRECOMPUTE_RATIO * T, so a higher T delays it. Its
      tailLimit already caps the kept tail at a quarter of T, so the checkpoint
      fits by construction once T is above a few thousand tokens.

    One estimate, one inflation

    due() compares measured + estimated to the ceiling; outputLimit allocates
    against measured + ceil(estimated * 1.15). The gap is 15% of the estimated
    part — negligible on a fully measured prompt, ~120k on an 800k prompt that is
    entirely estimated.

    Both sides must use the inflated value, P as defined above. The consequence is
    that the trigger fires earlier exactly when the estimate is least reliable, which
    is the intended direction: the ceiling then bounds the request rather than the
    estimate. Landing this change without unifying P leaves the budget and the
    trigger disagreeing by up to 15% of the conversation.

    estimateContext becomes P, so precompute moves with it: the background
    summary now starts at PRECOMPUTE_RATIO of the ceiling measured on the inflated
    prompt, and PRECOMPUTE_MIN_TOKENS / tailLimit must be re-read against the new
    T rather than the old 15% ceiling.

    How to set F_policy and S_policy: measurement

    Log per request: inputTokens.measured, inputTokens.estimated, the provider's
    usage.prompt_tokens (cache included), the tool-schema and image token counts,
    T, M, reserve, and the branch (policy / window4 / buffer).

    Three distributions matter, and they are not the same:

    drift   = provider_prompt_tokens - (measured + estimated)      → S
    step    = P(next request) - P(previous request)                → truncation, not S
    summary = usage.output_tokens of the compaction response       → F
    

    With the current reports, drift is 6 tokens on DeepSeek and 79 on Kimi. Step is
    ~22k at p99 per the comment on RESERVE_SHARE — two orders of magnitude larger.
    Sizing S on drift alone is correct only because truncation absorbs the
    step; the proposal should not be read as making S cover both.

    For F, the relevant statistic is not the mean but the tail that produces
    finish_reason: "length": any summary longer than F becomes a failed
    compaction, not a shorter summary.

    Migration

    • The 15% default disappears, and with it the 32 000 discontinuity. Per-window
      ceilings: 1M 85.0% → 96.2%, 262k 85.0% → 84.7%, 128k 85.0% → 69.5%,
      64k 75.6% → 62.8%, 32k 51.2% → 62.5%, 16k 85.0% → 62.5%.
    • 128k loses ceiling to a 32k summary budget: F = 32 000 is 24% of the window.
      The window-first variant gives back 12 points there with a 16k budget.
    • 16k loses ceiling to the 3C/8 reserve: it rises from 2 457 to 6 144, and the
      summary budget from 2 457 to 4 096 — a deliberate increase, not a by-product of
      F_policy.
    • On 262k the ceiling is essentially unchanged (85.0% → 84.7%). The gain there is
      the S margin on the request, not the ceiling.
    • buffer keeps its meaning (T = C - buffer) but is clamped to [1, C/2]
      instead of being ignored at or above the window. A user reproducing the old 1M
      ceiling sets compaction.buffer: 157286; the output at 800k is then 240 576,
      not 248 576, because S is reserved.
    • Per-turn output is lower by S at every prompt size: 248 576 → 240 576 at 800k
      on 1M, 235 080 → 227 080 on Kimi, 51 072 → 43 072 on 128k.
    • Models whose output limit is small keep a small reserve: a 4k-output model on
      1M gets F = 4k, S = 2k, T = 99.4%. That is intended — F is bounded by
      what the summary can actually emit — but it is a visible change from 85%.
    • A buffer below safety + F shrinks the summary budget accordingly; log a
      warning when it does.
    • max_tokens is never below 1; a request with no room is compacted or fails
      explicitly.

    Edge cases

    • Step past T: the conversation is truncated to T - overhead before
      summarising. At most F is dropped when the overshoot comes from the previous
      response, and at most reserve if tool results pushed the prompt to the window
      edge. The dropped history is not summarised: it is degraded by level (tool
      outputs clipped, then removed, then user text clipped) before any exchange is
      omitted.
    • Prompt already over C: M = 0; compaction must run with a truncated
      prompt or fail with a typed error.
    • Small windows: 3C/8 holds while both F and S track the window, i.e.
      C <= 64 000. From 64 000 S is capped at 8 000; from 128 000 F is capped
      at 32 000.
    • O < F_policy: F = O, S = O/2; the summary may use the model's whole
      output budget, which is the correct ceiling for that model.
    • auto: false: output shrinks to 0 at C - S, i.e. 8k earlier than today
      on 1M, and the request is refused rather than degraded. Document it as a
      deliberate trade-off, not as a stall.
    • keep: after compaction the checkpoint is summary + keep, and it must
      stay below T or the next turn re-triggers, on any conversation longer than
      keep. keep defaults to 15 000, so any window below ~29 000 re-compacts on
      every turn; keep = 24 000, the reported configuration, raises that threshold
      to ~43 000. Validate keep + summary + overhead < T and clamp with a warning
      rather than reject — a hard failure on startup would strand the session.
    • Provider variance: max_tokens stays a per-request reservation; a provider
      counting more input than the engine is covered by S.

    Tests

    • Table-driven limitsFor: six windows × two variants, plus buffer unset,
      0, null, NaN, < safety, >= C/2.
    • Invariants: P + M(P) <= C - S for P in [0, C - S]; M(T) = F on the
      default branch; T + F = C - S; output + safety <= reserve.
    • Compaction request: the full request, overhead included, estimates at T
      after truncation; the conversation target is T - overhead, not T.
    • A request with room < 1 is never sent: outputLimit returns 0 and the caller
      raises a typed error.
    • Degenerate inputs: limit.output at 0, undefined, NaN; window at 0,
      undefined, NaN (calculateCeiling must stay Infinity, as today).
    • Truncation: P > T ⇒ the compaction request's prompt is <= T and its budget
      is F; the dropped amount never exceeds reserve.
    • fitOutput does not change the budget once truncation has run.
    • keep clamp: with keep = 15 000 the raw condition is violated on the 16k
      window only; with keep = 24 000 on 16k and 32k. The test asserts the clamp
      brings those windows under T and leaves the other four unchanged.
    • Regression for both reports: DeepSeek at 799 027 and Kimi at 27 143 must
      produce P + M <= C - S and must not be rejected.
    • buffer: 0 must not produce a ceiling of C.
    • fitOutput and the main path must agree on S when buffer is set.

    Release note

    Output budgets are now derived from the model's output limit instead of 15% of
    the window. The compaction ceiling moves: 96.2% on 1M (was 85%), 84.7% on 262k,
    69.5% on 128k, 62.8% on 64k, 62.5% on 32k and 16k. compaction.buffer now
    clamps to [1, window/2]; buffer: 0 no longer disables the reserve. The
    output available on a turn is lower by the safety reserve (S), e.g. 248 576 →
    240 576 at 800k on a 1M window. compaction.keep is now validated against the
    ceiling and reduced with a warning when the checkpoint could not fit. Models
    with an output limit below 32k keep a proportionally smaller reserve.

    Open questions for maintainers

    • compaction.buffer: keep the name and clamp it, or add compaction.reserve
      with the new semantics and deprecate buffer?
    • Should M(P) and T be exposed in session metadata / the TUI, since they
      change per turn?
    • outputLimit uses limit.context while calculateCeiling uses
      limit.input || limit.context. Is aligning them part of this change or a
      separate fix?
    • Is raising OUTPUT_TOKEN_MAX (currently 256 000) for models with 350k+ output
      a separate issue?
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions