Resolves #27 (part of #26). Findings from opencode.ai/docs (fetched 2026-09-03) plus a fresh clone of anomalyco/opencode @ b578b72 (v1.18.27): - No percentage-threshold config key exists; compaction.{auto,prune, reserved,tail_turns,preserve_recent_tokens} is the full global config surface (opencode.json top-level, not per-provider/model). - Trigger is usedTokens >= context - reservedBuffer, not a hardcoded 75%/95% cutoff — contradicts an unverified claim in a closed GitHub feature request (#11314). - Reasoning tokens (Qwen3's reasoning_content) are counted via the provider's usage.total_tokens in the normal path, but excluded from the fallback sum if a provider ever omits total_tokens. - No per-model/per-agent threshold override exists (confirmed by several closed-not-planned feature requests); the only per-model lever is each model's own limit.context/limit.output. - Mechanism is provider-agnostic: applies identically to a hand- declared @ai-sdk/openai-compatible provider (this repo's llamacpp setup) as to hosted providers, provided limit.context is set. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FCAUsjGNSoJTtK8hyLKg5m
23 KiB
OpenCode CLI: how auto-compact actually decides to trigger
Date: 2026-09-03
Scope: Resolves haylan/LLM-Server issue #27 (part of #26) — the config key(s), trigger
threshold/formula, reasoning-token accounting, and per-model tunability of OpenCode's
context-compaction behavior, and whether any of it is specific to hosted providers vs. the
@ai-sdk/openai-compatible path this repo's docs/research/opencode-cli-setup.md documents for
llama.cpp/litellm-style backends.
Freshness note: opencode-cli-setup.md (dated 2026-08-24) does not cover compaction at all
beyond one sentence ("§5: OpenCode's limit.context/limit.output fields ... don't change what the
server actually accepts ... may miscalculate when to compact/summarize"). This document supersedes
that gap. It is based on:
- Live docs fetches from https://opencode.ai/docs/ (2026-09-03), and
- A fresh
git cloneof https://github.com/anomalyco/opencode at commitb578b7261fc9ec4917fe272df5cc4bd8a056cd5d(2026-09-03T09:47:21+08:00 — same day as this research),package.jsonversion1.18.27. Source-code claims below are cited by file path in that clone and are the highest-confidence source in this doc — they're what actually ships, not a doc description or a third-party claim.
Confidence scheme
- High — read directly from the current source code in the repo, or a docs page quoted verbatim.
- Medium — inferred from source code behavior that isn't spelled out in a single line/comment (i.e., I traced call sites to confirm it, rather than reading one authoritative line).
- Low — plausible but not directly confirmed in the sources checked; flagged as an open question.
Verdict summary (answers to the four questions asked in #27)
| Question | Answer | Confidence |
|---|---|---|
| Config key(s) controlling threshold/behavior | Single global top-level compaction object in opencode.json: auto, prune, reserved, tail_turns, preserve_recent_tokens. No threshold percentage key exists. |
High |
Is it a hardcoded percentage of limit.context? |
No. It's usedTokens >= (contextLimit − reservedBuffer), where reservedBuffer defaults to min(20_000, min(model.limit.output, 32_768) or default), i.e. compaction reserves room for one more max-size reply, not a flat 75%/95% cutoff. Some closed GitHub feature requests describe it as "hardcoded 75%" — that claim is not what the current source does (see §2). |
High (source), contradicts a stale community claim (see §2) |
Does compaction count reasoning/reasoning_content tokens? |
Yes, in practice, via the provider's usage.total_tokens (which for llama.cpp/litellm includes every generated token, reasoning or not) — but the token bookkeeping OpenCode itself derives (tokens.output, tokens.reasoning) explicitly splits reasoning out of output, and the compaction trigger's own fallback arithmetic (used only if the provider omits total_tokens) omits tokens.reasoning entirely. See §3 for the exact mechanism and the one edge case where reasoning tokens could be undercounted. |
High (source), Medium (edge-case behavior when total_tokens is absent) |
| Per-model or single global behavior? | Global only. The compaction block is a top-level config key, not nested under provider.<id>.models.<id> or any per-model schema. It cannot be disabled or tuned for one model while enabled for another. The indirect lever is each model's own limit.context/limit.output (which you already set per-model for custom providers), since those numbers feed the same global formula per-model. Multiple GitHub feature requests (#11314, #11930, #8140, #16375) ask for per-model/per-agent configurability; all are open or closed-not-planned as of this check. |
High |
1. The config surface (verbatim from source + docs)
Global (or project) opencode.json:
{
"compaction": {
"auto": true,
"prune": false,
"reserved": 20000,
"tail_turns": null,
"preserve_recent_tokens": null
}
}
Field descriptions, quoted verbatim from the config schema
(packages/core/src/v1/config/config.ts, lines ~149–166 in the cloned repo):
auto— "Enable automatic compaction when context is full (default: true)"prune— "Enable pruning of old tool outputs (default: false)"tail_turns— "Maximum number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction. By default retention is limited only by the preserved token budget."preserve_recent_tokens— "Maximum number of tokens from recent turns to preserve verbatim after compaction"reserved— "Token buffer for compaction. Leaves enough window to avoid overflow during compaction."
Sources:
- https://opencode.ai/docs/config/ (fetched 2026-09-03) confirms
auto/prune/reservedwith the same defaults and descriptions; the docs page does not mentiontail_turnsorpreserve_recent_tokens— those two are documented only in the source schema, not (yet) on the public docs page. Confidence: High on all five keys existing and their defaults; the docs-vs-source gap on the last two is itself notable (docs page is behind the schema). packages/core/src/v1/config/config.ts(cloned repo, ~line 149) — schema + descriptions.
There is no threshold, percent, or similarly-named key anywhere in the config schema. I grepped
packages/core/src/v1/config/ and packages/core/src/config.ts for compaction and found only the
struct above — no percentage field exists in the current schema (High confidence; direct grep of
current source).
Not nested under provider/model. The compaction key sits at the top level of opencode.json,
a sibling of provider, model, agent, etc. — not inside
provider.<id>.models.<model-id> (the block this repo's opencode-cli-setup.md §3 documents for
declaring the llama.cpp provider). Confirmed by reading the full top-level config struct in
packages/core/src/v1/config/config.ts (~lines 95–170): compaction is a direct sibling of
provider, not a child of it.
There is a separate, easily-confused concept: agent.compaction (also in that same file, ~line
105) — this only lets you assign a different agent/model to perform the summarization step
itself (e.g. run the compaction LLM call on a cheaper model), not a per-model threshold
override. It does not change when compaction triggers.
2. The actual trigger formula (not a flat percentage)
Live code, packages/opencode/src/session/overflow.ts (the shipped/default compaction-trigger
path — see "which code path ships" note at the end of this section):
const COMPACTION_BUFFER = 20_000
export function usable(input: { cfg: ConfigV1.Info; model: Provider.Model; outputTokenMax?: number }) {
const context = input.model.limit.context
if (context === 0) return 0
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)
: Math.max(0, context - ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax))
}
export function isOverflow(input: {
cfg: ConfigV1.Info
tokens: SessionV1.Assistant["tokens"]
model: Provider.Model
outputTokenMax?: number
}) {
if (input.cfg.compaction?.auto === false) return false
if (input.model.limit.context === 0) return false
const count =
input.tokens.total || input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write
return count >= usable(input)
}
In plain terms:
- Compaction triggers when the running token count (
count) reaches or exceedsusable = contextLimit − reservedBuffer(or, if the model declares a separatelimit.input,usable = limit.input − reservedBufferinstead). reservedBufferdefaults tomin(20_000, maxOutputTokens), wheremaxOutputTokens = min(model.limit.output, 32_768) || 32_768(OUTPUT_TOKEN_MAXconstant,packages/opencode/src/provider/transform.ts—maxOutputTokens()at ~line 1468). It can be overridden withcompaction.reserved.- If
model.limit.contextis0or unset, compaction is silently disabled entirely — this matters for custom@ai-sdk/openai-compatibleproviders wherelimit.contextis a value the user must supply by hand (peropencode-cli-setup.md§3); omit it, and auto-compact never fires for that model. - Compaction is disabled outright if
compaction.auto === false.
This is not a fixed 75%/90%/95%-of-context cutoff. It's context − reservedOutputBuffer, which
in practice usually lands somewhere in the 80–95%+ range depending on the model's own
limit.output relative to limit.context — but it's a token-count subtraction, not a percentage
multiplication, and there is no config key to set a percentage.
On the "hardcoded 75%" claim: a closed GitHub feature request (https://github.com/anomalyco/opencode/issues/11314, "Feature Request: Configurable Context Compaction Threshold") asserts "Currently, OpenCode triggers context compaction at a hardcoded 75% threshold of a model's context window." That is a user's claim in a feature-request issue, not a maintainer statement or a source citation, and it does not match the formula actually in the current source (which is a token-count subtraction tied to output-buffer size, not a flat percentage). Treat that issue as evidence that users perceive/want configurability, not as an accurate description of the mechanism. A second, similarly-shaped request (https://github.com/anomalyco/opencode/issues/11930) instead describes the current default as "100% threshold" — the two community reports disagree with each other, which is itself a signal neither is a reliable description of internals; the source code is what should be trusted here. Confidence: High on the source-code formula; High that the 75%/100% claims in those two issues are user speculation, not verified facts — both are closed as "not planned" with no maintainer confirmation of the underlying mechanism in the content fetched.
Which code path ships: the repo contains two parallel implementations — the one quoted above
(packages/opencode/src/session/overflow.ts + packages/opencode/src/session/compaction.ts,
using ConfigV1/SessionV1 types) and a newer, structurally different one
(packages/core/src/session/compaction.ts + packages/core/src/session/runner/llm.ts, using
Config.Entry/SessionMessage types, with its own compactIfNeeded/compactAfterOverflow
functions and a buffer config field instead of reserved). Tracing the call path: the newer
runner is only reached when flags.experimentalNativeLlm is true, gated by the
OPENCODE_EXPERIMENTAL_NATIVE_LLM env var (packages/opencode/src/effect/runtime-flags.ts,
packages/opencode/src/session/llm.ts line ~226) — off by default. The overflow.ts/V1 path
quoted above is what ships by default in v1.18.27. If this repo's OpenCode setup ever sets
OPENCODE_EXPERIMENTAL_NATIVE_LLM=1 (or a future release flips the default), the newer engine's
compactIfNeeded() uses a structurally similar but not identical check:
estimatedPromptTokens <= context − max(output, config.buffer) — same shape (context minus a
reserved buffer, no percentage), different field name (buffer vs reserved). Confidence:
High on which path ships by default; Medium on exact behavior differences if the experimental
flag is ever turned on, since I did not exhaustively diff every line of the newer engine.
3. Reasoning/thinking-token accounting — the Qwen3.8 angle
This repo's litellm-config.yaml flags that Qwen3.8-27B (qwen3.8-27b-local) spends generation
tokens on reasoning_content before content:
# Qwen3 is a reasoning model — it spends output tokens on
# reasoning_content before ever writing content. ...
max_tokens: 16384
(/home/haylan/Projects/LLM-Server/litellm-config.yaml, qwen3.8-27b-local model block.)
OpenCode's own token bookkeeping (packages/opencode/src/session/session.ts, ~lines 340–375, the
function that turns a provider's raw usage object into the tokens struct stored on each
assistant message) does this:
const inputTokens = safe(input.usage.inputTokens ?? 0)
const outputTokens = safe(input.usage.outputTokens ?? 0)
const reasoningTokens = safe(input.usage.reasoningTokens ?? 0)
...
const total = input.usage.totalTokens
const tokens = {
total,
input: adjustedInputTokens,
output: safe(outputTokens - reasoningTokens), // reasoning is subtracted OUT of "output"
reasoning: reasoningTokens, // tracked as its own field
cache: { write: cacheWriteInputTokens, read: cacheReadInputTokens },
}
And the compaction trigger (overflow.ts, quoted in §2) computes:
const count = input.tokens.total || input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write
Two things follow:
tokens.reasoningis never added back in by the fallback sum (input + output + cache.read + cache.write) — that expression has no+ reasoningterm. If the compaction trigger ever fell back to this sum (i.e., the provider didn't returntotalTokens), reasoning tokens spent onreasoning_contentwould be excluded from the overflow calculation, undercounting real context usage.- In the normal case,
totalis used instead of the fallback sum, andtotal = usage.totalTokensstraight from the provider's raw response — computed by the provider/AI-SDK before OpenCode splitsoutputTokensintooutput/reasoning. Since the AI SDK's OpenAI-compatible adapter (and litellm/llama.cpp underneath it) counts every generated token — reasoning and content alike — as part ofcompletion_tokens/total_tokens,totaldoes include reasoning tokens in this normal path. So in practice, for a llama.cpp/litellm backend that reportsusage.total_tokenson every response (the OpenAI chat-completions spec requires this field), reasoning tokens are accounted for in the compaction trigger viatotal, not via the explicitreasoningfield.
The edge case that would matter for this repo: if litellm or llama.cpp's OpenAI-compatible endpoint
ever omitted usage.total_tokens from a response (malformed/incomplete usage block — this has
happened with some llama.cpp server versions/flags), OpenCode's fallback sum would silently
undercount by the full reasoning amount, delaying compaction past the point it should have
triggered and increasing risk of a hard context_length_exceeded — exactly the failure mode
reported by an unrelated user in
https://github.com/anomalyco/opencode/issues/8089 ("Auto-compaction enabled by default, but
context_length_exceeded errors still occur in agent workflows"), though that issue's cause was not
confirmed to be this specific gap (it involved OpenAI's GPT-5.2 and multi-agent/subagent workflows,
not a local llama.cpp backend, and the issue thread contains no maintainer diagnosis of root cause
in the content fetched).
Confidence: High on the source-code mechanics described (the output = outputTokens − reasoningTokens split, the total || sum fallback, and the missing + reasoning term in the
fallback). Medium on whether this repo's specific llama.cpp/litellm stack reliably returns
usage.total_tokens on every response for the qwen3.8-27b-local model — this was not verified
against a live request/response in this research pass (would need an empirical check: hit
http://localhost:8080/v1/chat/completions directly or via the litellm proxy and inspect the
usage block of an actual reasoning response). Recommend that empirical check as a fast follow if
this matters operationally.
4. Per-model tunability
Confirmed absent, both from the schema (§1) and from community feature requests asking for exactly this and not getting it:
- https://github.com/anomalyco/opencode/issues/11314 — "Feature Request: Configurable Context
Compaction Threshold" — requests a
compaction.thresholdwith "optional per-model overrides." Closed as not planned (per WebFetch of the issue). - https://github.com/anomalyco/opencode/issues/11930 — "Feature: Configurable compaction threshold and model (global + per-model)" — explicitly requests global and per-model threshold config. Closed as not planned, no maintainer reply visible in the content fetched.
- https://github.com/anomalyco/opencode/issues/8140 — "Feature Request: Configurable context limit and auto-compaction threshold" — same theme (title only confirmed via search; not individually fetched in this pass).
- https://github.com/anomalyco/opencode/issues/16375 — "[FEATURE]: Per-agent compaction config (disable compaction for specific agents)" — same theme, per-agent instead of per-model (title only confirmed via search; not individually fetched in this pass).
All four are open/closed-not-planned as of 2026-09-03 — i.e., as of this check, none of this has shipped: compaction remains a single global on/off + buffer-size knob, with no per-model or per-agent threshold override. Confidence: High that the feature doesn't exist in the schema (direct source read); Medium on the exact current status of #8140/#16375 specifically since only their titles were confirmed via search results, not their full issue bodies.
The one indirect per-model lever that does exist: since usable() (§2) reads
input.model.limit.context / input.model.limit.input / input.model.limit.output — all
per-model fields already documented in opencode-cli-setup.md §3/§5 for custom providers — setting
those numbers differently per model in the provider.<id>.models.<model-id>.limit block changes
where that model's compaction fires, without needing a dedicated per-model compaction key. This
is bookkeeping-hint tuning, not a first-class "compaction threshold" feature.
5. Is any of this specific to hosted/built-in providers vs. @ai-sdk/openai-compatible?
No. The entire trigger path (overflow.ts, compaction.ts) operates only on Provider.Model
(a normalized model descriptor with limit.context/limit.input/limit.output) and the message
tokens struct built from the SDK's generic usage object (session.ts, §3) — nothing in the
compaction code branches on model.api.npm or provider identity. The mechanism is provider-agnostic
by construction: any provider adapter that populates usage (inputTokens/outputTokens/totalTokens)
and any model entry that has a nonzero limit.context gets the same compaction behavior, including
a hand-declared @ai-sdk/openai-compatible provider block like this repo's llamacpp provider in
opencode-cli-setup.md §3. Confidence: High — read directly from the trigger/accounting source,
which takes no provider-specific branch.
The one place this repo needs to be careful about, restated from §2: for a custom
@ai-sdk/openai-compatible provider, limit.context (and ideally limit.output) must be set by
hand in opencode.json to match the real --ctx-size the llama.cpp container is launched with — if
left unset (limit.context defaults to 0 for an unrecognized custom model), compaction is
silently disabled for that model rather than silently misfiring.
Sources consulted (primary)
- https://opencode.ai/docs/ — nav/sitemap fetch (2026-09-03); confirms no dedicated "context management"/"compaction" page exists in the current docs nav — it lives only in the Config reference.
- https://opencode.ai/docs/config/ — fetched 2026-09-03; source of the
compaction.auto/prune/reserveddescriptions and defaults quoted in §1. - https://opencode.ai/docs/models/ — fetched 2026-09-03; confirms no compaction/context-limit
content on that page (only
reasoningEffort/thinkingkeys, unrelated to compaction). github.com/anomalyco/opencode@b578b7261fc9ec4917fe272df5cc4bd8a056cd5d(cloned 2026-09-03,package.jsonversion1.18.27) — primary source for all source-code claims:packages/opencode/src/session/overflow.ts— trigger formula (usable/isOverflow), §2packages/opencode/src/session/compaction.ts— shipped compaction service, tail-turn selection, pruning, §2/§4packages/opencode/src/session/session.ts(~lines 340–375) —usage→tokensmapping, reasoning-token split, §3packages/core/src/v1/config/config.ts(~lines 95–170) — config schema, field descriptions, §1packages/core/src/config/compaction.ts,packages/core/src/session/compaction.ts,packages/core/src/session/runner/llm.ts— the experimental/newer compaction engine, gated behindOPENCODE_EXPERIMENTAL_NATIVE_LLM, §2packages/opencode/src/effect/runtime-flags.ts,packages/opencode/src/session/llm.ts(~line 226) — confirms which engine ships by default, §2packages/opencode/src/provider/transform.ts(~line 1468,maxOutputTokens) — output-buffer sizing used in the reserved-token default, §2
- https://github.com/anomalyco/opencode/issues/11314 — "Configurable Context Compaction Threshold" (closed, not planned) — source of the "hardcoded 75%" community claim, §2/§4
- https://github.com/anomalyco/opencode/issues/11930 — "Configurable compaction threshold and model (global + per-model)" (closed, not planned) — source of the conflicting "100% threshold" claim, §2/§4
- https://github.com/anomalyco/opencode/issues/8089 — "Auto-compaction enabled by default, but context_length_exceeded errors still occur in agent workflows" (closed, not planned) — cited in §3 as a related-but-unconfirmed failure report
- https://github.com/anomalyco/opencode/issues/8140, #16375 — titles only, confirmed via
WebSearch, not individually fetched; §4 - This repo:
docs/research/opencode-cli-setup.md— structural template, and source of thelimit.context/limit.outputper-model config shape referenced throughout - This repo:
litellm-config.yaml—qwen3.8-27b-localmodel block,max_tokenscomment onreasoning_content, §3
Confidence summary
| Claim | Confidence |
|---|---|
compaction is a single global top-level config key (auto/prune/reserved/tail_turns/preserve_recent_tokens) |
High |
| No percentage-threshold config key exists | High |
Trigger formula is usedTokens >= context − reservedBuffer, not a flat percentage |
High |
| "Hardcoded 75%" (issue #11314) and "100% threshold" (issue #11930) are unverified community claims, not confirmed mechanism | High (that they're unverified/conflicting); the actual mechanism per source is definitive |
overflow.ts/V1 path ships by default; newer core engine is gated behind OPENCODE_EXPERIMENTAL_NATIVE_LLM |
High (default path); Medium (exact newer-engine behavior if enabled) |
Reasoning tokens counted via usage.total_tokens in the normal (non-fallback) path |
High |
Reasoning tokens excluded from the fallback sum if total_tokens is ever absent |
High (source); Medium (whether this repo's llama.cpp/litellm stack ever hits that fallback in practice) |
| No per-model/per-agent compaction threshold override exists; confirmed by rejected feature requests | High |
Compaction mechanism is provider-agnostic — applies identically to @ai-sdk/openai-compatible custom providers |
High |
limit.context unset/0 on a custom model silently disables compaction for it |
High |