Skip to content

compaction.reserved silently ignored for models without limit.input (e.g. GLM-5.2) — revival of #13980 #38835

Closed as not planned

Description

@winstern1998-commits

Summary

compaction.reserved is silently ignored for any model whose limit lacks an input field. This was reported in #13980, which was closed by the stale bot without being fixed — the current code still ignores reserved on this code path. Filing this to revive it with a fuller analysis and to tie together several related reports.

Environment

  • opencode: 1.17.14 (source pinned to commit 203885d24; overflow.ts identical on current HEAD)
  • Model: zhipuai/glm-5.2 (limit = { context: 1000000, output: 131072 }, no input)

Reproduction

Config:

"compaction": { "auto": true, "reserved": 648928 }

Use a built-in model that has no limit.input (e.g. zhipuai/glm-5.2). Observe that compaction does not trigger at input - reserved; it triggers at context - maxOutputTokens = 1000000 - 32000 = 968000. The reserved value has zero effect.

Root cause

packages/opencode/src/session/overflow.ts — usable() has two mutually exclusive branches, and reserved only participates in one:

const reserved =
  input.cfg.compaction?.reserved ??
  Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax))
return input.model.limit.input
  ? Math.max(0, input.model.limit.input - reserved)          // branch A: reserved applies
  : Math.max(0, context - ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax))  // branch B: reserved ignored

Models without limit.input (which is common — most models.dev entries only declare context + output) fall into branch B, where reserved is computed but never used. maxOutputTokens = min(limit.output, OUTPUT_TOKEN_MAX=32000) (from provider/transform.ts), so for glm-5.2 the threshold is 1000000 - 32000 = 968000 regardless of reserved.

The config schema (packages/core/src/v1/config/provider.ts) does support limit.input on custom providers, but there is no way to override limit.input for a built-in provider's model — a custom provider with the same name as a built-in does not replace the built-in entry. So users of built-in providers cannot work around this in config.

Expected vs actual

Value
Expected threshold limit.input - reserved (reserved takes effect)
Actual threshold context - maxOutputTokens = 968000 (reserved silently ignored)

Impact

Silent failure: the user sets reserved, gets no error, but the setting never takes effect. This is a UX trap — there's no signal that the config is being ignored for their model.

Proposed fix

Make reserved apply in branch B as well:

: Math.max(0, context - ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax) - reserved)

This makes reserved a consistent "trigger N tokens earlier" knob regardless of whether limit.input is declared. Default reserved is min(COMPACTION_BUFFER=20000, maxOutputTokens), so the default threshold for branch-B models shifts earlier by ~20K — safer (triggers before the hard limit) and consistent with branch A's behavior.

Open semantic question for maintainers: should reserved be subtracted from limit.input (branch A) or from context - maxOutputTokens (branch B)? These differ when limit.input != context - maxOutputTokens. The two branches should agree on the reference point.

Related issues

Complementary upstream

The models.dev registry also omits limit.input for these models (systematic across the zhipuai provider). I'll file that separately against models.dev. But the opencode-side fix is needed regardless, since reserved should not be silently dropped for any model.

Activity

  1. diegonix commented on Jul 30, 2026

    @diegonix

    Heads-up from testing glm-5.2: this issue becomes even more relevant once the 32k output cap is bypassed.

    ollama-cloud/glm-5.2 is in the exact affected shape:

    • large limit.context
    • explicit limit.output
    • no limit.input

    So once OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX is raised, the fallback usable() branch matters a lot more for this model family. I have not finished a full end-to-end compaction reproduction yet, but GLM 5.2 definitely matches the configuration pattern described here.

  2. agent-daryl commented on Aug 18, 2026

    @agent-daryl

    Additional real-world data point (opencode 1.18.18, local model via OpenAI-compatible / llama.cpp)

    Confirmed still present on 1.18.18 (issue was reported against 1.17.14). Our stack: custom OpenAI-compatible provider (local llama.cpp server) running a 27B Q8_0 model with declared limit { context: 262144, output: 32768 } (no input), and compaction: { auto: true, prune: true, tail_turns: 2, reserved: 80000 }.

    Empirically measured threshold from the session DB:

    • total = 229,165 tokens → no compaction
    • total = 230,697 tokens → auto-compaction fired
    • implied threshold = 229,376 = 262144 − 32768 = context − maxOutputTokens, exactly branch B. reserved: 80000 had zero effect.

    User-visible harm (why this matters beyond a silent no-op):
    This model's output quality degrades (verbatim context repetition loops + early EOS, i.e. truncated outputs) once context reaches ~71–73% of the window (~185–190k of 262k). The effective trigger (229k) sat ~40k above the degeneration onset, so the model degraded well before compaction ever fired — the session became unusable and had to be abandoned. The whole point of reserved is to pull the trigger earlier; branch B silently disables it at exactly the moment it is needed.

    Verified workaround (custom providers):
    Adding "limit": { "input": 240000, "context": 262144, "output": 32768 } to the custom provider's model entry activates branch A: usable = 240000 − 80000 = 160,000 → auto-compaction now fires at 160k, safely before the degeneration onset. So custom/local providers (OpenAI-compatible, local inference servers) can work around this in config — unlike the built-in-provider case called out in the issue, where no override path exists.

    Suggestion:
    When branch B is selected and cfg.compaction.reserved is explicitly set, either

    1. apply reserved in branch B as well (context − maxOutputTokens − reserved), or
    2. emit a visible warning that compaction.reserved is being ignored because the model has no limit.input.

    A silent no-op on a user-facing knob is a UX trap: users believe a safety margin is in force when it isn't.

    Happy to share the full incident analysis (timestamps, DB compaction parts, llama.cpp log excerpts) on request.

  3. damsnotdam commented on Aug 22, 2026

    @damsnotdam

    Third-party confirmation on 1.18.21, plus three things this thread does not yet mention

    Same shape as @agent-daryl's 2026-08-18 report (custom OpenAI-compatible provider, local 27B, no
    limit.input), so I'll skip re-confirming the bug itself and only add what's new.

    Environment: opencode 1.18.21 (current npm latest), custom OpenAI-compatible provider, MLX
    server on Apple M4 Max / 64 GB. Model Qwen3.8-27B-MLX-4bit, declared
    { context: 229376, output: 16384 }, no input. Branch B threshold measured at
    229376 - 16384 = 212992 — matches usable() exactly. reserved had no effect, as reported.

    Formula verified against 15 automatic compactions across 6 models in 30 days of session DB
    ({"type":"compaction","auto":true} parts), on both 1.18.18 and 1.18.21: every one fired within
    +31..+3214 tokens of the branch-B threshold — the overshoot being just the size of the turn that
    crosses it. So the branch-B path is not merely theoretical, it is the one everyone without
    limit.input is on.


    1. The limit.input workaround has an undocumented prerequisite: the server never re-reads its
    config.

    This is worth adding to the thread because the workaround appears not to work otherwise, and the
    usual verification command actively misleads.

    My first attempt changed opencode.json, confirmed with opencode debug config that all three
    limit.input values were present in the resolved config — and the next session still compacted at
    the old threshold. The running server had loaded the config 15 hours earlier and kept the old
    value in memory. opencode debug config proves the file is correct; it says nothing about what
    the server applies.

    The control that actually decides is the message=loading path=… line in the server log, with its
    timestamp. Any opencode.json change needs a server restart. After restarting, compaction fired
    at the predicted threshold on the very next session.

    2. Setting limit.input desynchronises the context percentage shown in the UI.

    The UI computes its percentage against limit.context, which stops being the trigger's denominator
    once limit.input is set. On my model the indicator reads 57.2% at the exact moment
    auto-compaction fires, and will never approach ~93% again.

    Anyone applying this workaround will see compaction "firing early" and may well revert it. Either the
    indicator should use usable() as its denominator, or this is worth a line in the docs.

    3. The harm is not only degraded quality — it is throughput, on every token.

    @agent-daryl reported repetition loops and early EOS. On my stack the measurable cost showed up
    earlier and continuously. Same model, same machine, before and after the workaround:

    before after
    session size 204,384 35,663
    decode throughput 14.3 tok/s 25.5 tok/s
    prefix cache reused 5% (10,240/204,462) 97% (34,816/35,951)
    wall time for one turn ~20 min (never completed) 8.5 s

    Nominal throughput for this model on this machine is 27.1 tok/s: the session was running at half
    its nominal speed before compaction, purely from context length. Seven server-side rejections
    followed. Lowering the trigger did not just avoid the wall, it restored the throughput.

    For anyone weighing the trade-off, the steady-state cycle I measured afterwards is
    ~33k → 131k → ~33k tokens, ~44 turns per 1h07, at the cost of one ~9m30 compaction. Two
    consecutive compactions cost 18 min and 9m32; 59–70% of that is prefill, not generation, so
    lowering the threshold makes each compaction cheaper, not just more frequent.


    Two notes that may help triage

    • On the open semantic question ("should reserved subtract from limit.input or from
      context - maxOutputTokens?"): from a consumer's point of view the useful property is that the
      knob be monotonic and model-independent — "trigger N tokens earlier than you otherwise would",
      whichever branch computed the baseline. That reading also makes the proposed branch-B fix a
      behaviour-preserving default when reserved is unset.

    • fix(compaction): output-budget reservation capped at 20K for limit.input models, risking overflow #32656 interacts with the workaround recommended in this thread. Setting limit.input moves
      you into branch A, which is exactly the branch fix(compaction): output-budget reservation capped at 20K for limit.input models, risking overflow #32656 is about (reserved capped at
      COMPACTION_BUFFER = 20_000). It happens not to bite for me — my maxOutputTokens is 16,384,
      below the cap — but it will bite anyone whose model declares output > 20000 and who follows the
      advice here. Worth a cross-reference so the workaround does not hand people the sibling bug.

    • Compaction has no fallback of its own. If the compaction request is itself rejected by the
      server, SessionProcessor surfaces ContextOverflowError with finish="error" and stops — no
      retry, no fallback to a shorter history. My compaction prompts measured 0.25×–0.74× the
      session they compact (10 samples; worst case 229,620 → 80,650), so on a saturated session this is
      not hypothetical. That ratio may be useful for Compaction request exceeds context window on high-output models (v2) #42448.

  4. agent-daryl commented on Sep 21, 2026

    @agent-daryl

    Data point on your #32656 warning: on 1.18.31 the cap did not bite us, even though we are exactly the profile you said would.

    Current config (since 2026-09-15, opencode 1.18.31): model declares { context: 262144, input: 240000, output: 32768 } — output well above the 20,000 figure you cited — with compaction: { auto: true, prune: true, tail_turns: 2, reserved: 50000 }.

    Measured from our session DB (context size of the last assistant request before each auto-compaction part):

    So on 1.18.31 reserved appears uncapped for us: either #32656 was fixed between 1.18.21 and 1.18.31, or its trigger conditions are narrower than described. Worth checking which, since the workaround recommendation in this thread depends on it.

    Your other two points both match our experience:

    1. Stale server config: yes — our limit.input workaround did nothing until the server restarted; opencode debug config showed the resolved values, which misled us into thinking the file-level fix had taken effect.
    2. UI percentage desync: confirmed — the indicator never approaches 100% after the workaround, which is how we first noticed something was off.
  5. thdxr commented on Oct 11, 2026

    @thdxr
    Member

    Thanks for the report! OpenCode 2.0 is now out and development is focused on V2, so we're closing issues filed against OpenCode 1. Please upgrade (opencode upgrade --major, details: https://anoma.ly/notes/opencode-2-0), and if this is still relevant on 2.0, reopen this issue or open a new one with your 2.0 version.

    — from 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions