Repository navigation
Description
Description
The docs describe keep.tokens (default 15000) as "Tokens from newest context to retain alongside
summary". In agent-driven sessions the actual figure is routinely 5–15× that.
select() accumulates from the tail until it exceeds keep.tokens, then walks backwards to the
nearest user message:
opencode/packages/core/src/session/compaction.ts
Lines 178 to 212 in 39be159
| const select = ( | |
| messages: readonly SessionMessage.Info[], | |
| tokens: number, | |
| ): { readonly head: string; readonly recent: string } | undefined => { | |
| const conversation = messages | |
| .filter((message) => message.type !== "compaction" && message.type !== "system") | |
| .flatMap((message) => { | |
| const text = serialize(message) | |
| return text ? [{ message, text }] : [] | |
| }) | |
| if (conversation.length === 0) return undefined | |
| let total = 0 | |
| let split = conversation.length | |
| for (let index = conversation.length - 1; index >= 0; index--) { | |
| const next = total + Token.estimate(conversation[index].text) | |
| if (split < conversation.length && next > tokens) break | |
| total = next | |
| split = index | |
| } | |
| while (split > 0 && conversation[split].message.type !== "user") split-- | |
| if (split === 0) { | |
| const latestUser = conversation.findLastIndex((item) => item.message.type === "user") | |
| if (latestUser > 0) split = latestUser | |
| } | |
| return { | |
| head: conversation | |
| .slice(0, split) | |
| .map((item) => item.text) | |
| .join("\n\n"), | |
| recent: conversation | |
| .slice(split) | |
| .map((item) => item.text) | |
| .join("\n\n"), | |
| } | |
| } |
while (split > 0 && conversation[split].message.type !== "user") split--keep.tokens only decides where that walk starts. The walk itself has no bound, so everything back
to the last user turn ends up in recent and is carried forward verbatim. Interactive sessions have a
user turn every few messages; agent-driven ones — subagents, long tool loops, autonomous runs — do
not, and the walk crosses hundreds of messages.
Synthetic messages are already user messages everywhere else. to-llm-message.ts lowers them to
role: "user" on the wire:
opencode/packages/core/src/session/runner/to-llm-message.ts
Lines 246 to 247 in 39be159
| case "synthetic": | |
| return [Message.make({ id: message.id, role: "user", content: message.text })] |
but select() runs against the pre-lowering SessionMessage.Info, where the type is "synthetic",
so the walk-back steps straight over every one. Two views of the same message disagree about what it
is, and that disagreement is what makes the walk long. shell messages have the same mismatch — also
lowered to role: "user", also skipped.
Measured impact
169 completed compactions from a local opencode.db, bucketed by tokens carried forward in recent
(chars/4):
| carried forward | compactions |
|---|---|
| <15K (the configured target) | 46 |
| 15–40K | 56 |
| 40–80K | 39 |
| 80–150K | 17 |
| >150K | 11 |
Worst case: 234,632 tokens carried forward, with 243 assistant messages between the last user
message and the compaction. That session holds 2,985 assistant messages against 45 user messages
(66:1) and 495 synthetics — roughly 11 usable anchors per user turn, none of them used.
The excess is re-read on every request until the next compaction. Over the seven sessions with ≥5
compactions, tokens carried above the 15K target × requests in the window comes to ~0.71 Gtok of
avoidable re-read:
| session | windows | avg carried | reqs/window | excess re-read |
|---|---|---|---|---|
| A | 18 | 91,024 | 160 | 0.20 Gtok |
| B | 20 | 84,446 | 136 | 0.15 Gtok |
| C | 19 | 64,832 | 161 | 0.15 Gtok |
| D | 26 | 45,848 | 140 | 0.12 Gtok |
It compounds: planContent feeds the previous recent back into the next compaction prompt, so an
overshoot inflates the next compaction's cold input too.
Expected behaviour
keep.tokens is a hard upper bound on the estimated serialized recent context:
Token.estimate(recent) <= keep.tokensIf the newest serialized message or logical unit alone exceeds the budget, retaining no recent tail is valid. The implementation must never walk backwards without a bound simply to find a role anchor.
Suggested fix
The fix should be expressed in terms of the retained-budget invariant:
- Select the newest serialized messages or logical units without exceeding
keep.tokens. - If role-aware boundaries are preserved, classify every message that lowers to a user-role provider message consistently, including
syntheticandshell. - Do not use an unbounded walk-back. If no valid boundary fits, split at the budget or retain no recent tail.
Plugins
No response
OpenCode version
0.0.0-beta-17498 (source verified at 39be159)
Steps to reproduce
- Leave
compaction.keep.tokensat its default of 15000. - Run an agent session that executes many tool calls without intervening user messages — a subagent
fan-out or an autonomous loop. - Let auto-compaction fire.
- Inspect the resulting compaction message's
recentfield. It contains everything since the last
user turn, which can be an order of magnitude overkeep.tokens.
Screenshot and/or share link
No response
Operating System
No response
Terminal
No response
Activity
Danielkgr commentedon Sep 26, 2026
Still reproducing on the current v2 release line. Measured case from 2.0.15; the pattern survives the 26 September
v2refactor.Measured case. OpenCode 2.0.15,
packages/core/src/session/compaction.ts,compaction.keep.tokens: 16384. An automatic compaction reduced context from approximately 107k to 82k tokens, but the checkpointrecentfield retained 229,085 characters, roughly 57k tokens at characters / 4. ThefindTailStartlogic is byte-identical at tag v2.0.18 (source-verified only; no runtime reproduction on that version).opencode/packages/core/src/session/compaction.ts
Line 349 in cd9a14a
opencode/packages/core/src/session/compaction.ts
Line 361 in cd9a14a
Case pattern. The history handed to
findTailStartis the transcript since the previous checkpoint. The budget walk picked a tail, then the unboundedwhileloop rewound its start to a user message. The previous compaction had fired mid assistant/tool sequence, so no user message existed after the previous checkpoint. With no anchor, the rewind reached the transcript start and the fallback retained everything since the previous checkpoint. Trigger: two automatic compactions during one long assistant/tool sequence with no user message in between.Reproduction status. Replay of this exact checkpoint through the real request pipeline in an isolated standalone instance confirmed the oversized tail survives into the following model request. Replay verification only; no from-scratch reproduction of a new oversized checkpoint.
Workaround. A custom local plugin trims the checkpoint
recenttext in a context hook. It is deployed locally as mitigation. None of the core fix below is implemented, upstream or locally.Proposed fix. Following this issue's budget invariant: keep the budget-selected boundary as the fallback. Rewind to a user message only when the resulting tail stays within
keep.tokens; otherwise start at an assistant-step boundary. A v2 assistant message carries its own tool calls and results, so retaining whole messages keeps each step intact. Define an explicit policy for an individually oversized step instead of absorbing older steps. Note that the currentrecentStartonv2(a64bc361) still retains everything when no user message exists since the previous checkpoint.Regression coverage. User message within budget, distant user message, no user message since the previous checkpoint, consecutive automatic compactions, and an individually oversized assistant step.
tpischke commentedon Sep 27, 2026
Still reproducing on 2.0.18, with two further consequences for agent-driven sessions that I don't think are covered above.
Setup: OpenCode 2.0.18,
compaction: { auto: true, keep: { tokens: 16000 }, buffer: 20000 }, a local model with a 232klimit.context. An orchestrator session dispatches subagents (child sessions) for multi-hour implementation work and re-uses an implementer for its fix rounds. The numbers below come from the session DB (session_message, typecompaction) and the server's request log.1.
keep.tokensis ignored: recent = the whole current turn (as reported)A child session got a follow-up user message at 17:25. It then ran 12 assistant turns and 70 tool calls, with no further user message, until auto-compaction at 17:53:
findTailStart(v2.0.18 L348-350) went all the way to the 17:25 user message;recent= 367,935 chars (~100k tokens) againstkeep.tokens: 16000;2. The summary describes the previous task and marks the active one as done
Because everything since the last user message goes to
recent, the summarizer only sees what came before the current task. Here that was the child's earlier, finished assignment. The resulting checkpoint reads:The work actually in progress is present only in the verbatim
recent. The model resumes with a checkpoint that says the work is finished, followed by 100k tokens that say otherwise. We had two orchestrator turns on Sep 26 that reported results the agent never received, and this is the pattern I would suspect for them.3. A fresh child session's first compaction keeps nothing
When the only user message is the dispatch at conversation index 0, the walk-back stops at
start = 0,latestUseris0, and with no previous summaryfindTailStartreturnsmessages.length(L350-361). Everything is summarized and nothing is retained verbatim, including the task currently in progress. Observed: the same child's first compaction (10:18) hasrecent: "".So for a subagent, the retained tail is either the entire current turn or nothing.
keep.tokensnever takes part.4. The subagent's role does not survive
The dispatch message opens with
You are a subagent spawned by another session.The summarizer is instructed to "Leave out instructions and setup the assistant was given rather than told by the user" (L366), and neither checkpoint of that session mentions it. The checkpoint instead says that questions were "posed to the operator" and that it should "act on it if the operator answers", although a child session has no operator to answer. The follow-up message had to state separately that a child session must never call thequestiontool, because an unanswered call aborts the dispatch.Suggestions
keep.tokensback, split at an assistant-message boundary instead (keeping each tool call with its result). Or countsyntheticmessages as boundaries, as the issue proposes.keep.tokensinstead of retaining nothing.1sfb commentedon Sep 28, 2026
Hitting this on 2.0.18 too, with a local model (qwen3.8-27b via Ollama, n_ctx 262144, keep.tokens 15000, buffer 65536). For us it isn't just extra tokens. It killed a session on the 26th and nearly killed another today.
The setup is one prompt followed by an hour or more of autonomous work, so the walk-back always goes all the way to that first message.
Today's run, from session_message:
So the first compaction actually made the context bigger (~200k -> 248k), and we got through with about 2k tokens to spare. The second compaction only worked because there was no user message after the first checkpoint, so it hit the fallback.
On the 26th it went the other way. The checkpoint kept 919k chars of recent, and the next request was 382,090 tokens (exceed_context_size_error, n_ctx 262144). The overflow compaction then failed with "Nothing to compact yet", because the only thing after the checkpoint is its own recent text, and that doesn't count as compactable. Dead session.
The rewind is still there on v2 after the refactor:
opencode/packages/core/src/session/compaction.ts
Line 716 in 39e1ce5
Looking at the new overflow path, I don't think it would have saved the 26th either. The old recent plus everything after it is one "exchange" (no user message in between), so there's nothing to drop. I haven't tested that though.
Side note: about 90% of that 776k was
[Assistant reasoning]text, which never reached the model in the live requests (Ollama dropsreasoning_contentfrom earlier assistant messages). Written out as plain text it counts in full, which is why compaction grew the context instead of shrinking it. Opened #51818 for that.runsonmypc commentedon Oct 4, 2026
@neriousy I see this is assigned to you. I reproduced the retention overflow on current v2 with an offline runner test: a 15k keep budget retained 76,099 characters and prevented continuation after switching to a smaller model. I'm preparing a focused draft PR that advances to a complete user exchange within the allowance, or summarizes everything if none fits. Tests cover zero retention, separator rounding, assistant-only history after a checkpoint, and continuation. This addresses the retained-text ceiling; total summary/reply budgeting and existing oversized checkpoints remain separate. Please flag any overlapping work already underway.
nkoynov commentedon Oct 7, 2026
Data point from 2.0.24 in an agent-driven session (default
compaction:keep.tokens15000, ceiling 900K on a 1M model), plus a test of #53233 and a second loss it doesn't cover.1. The walk-back keeps the whole autonomous turn. After the last user message (09:08) the agent worked alone until auto compaction at 12:39.
recentwas 2,685,658 characters (~670K tokens: 1 user message, 460 assistant texts, 1,577 tool calls), so the summary covered only 06:49–09:08. A standing instruction from the 09:08 message never reached a summary.2. The next compaction dropped that tail unsummarized. At 18:32 an overflow compaction ran
flattenAndDropOldest. Its first exchange was the previousrecentplus everything up to the next user-role message (12:40–15:28, another 1.52M characters of assistant/tool records), and it was dropped as one block. The 09:08 instruction has been missing from every summary since.#53233 on v2.0.24 applies cleanly, and the compaction (19) and runner (223) tests pass. VM A/B with the ceiling lowered to 70K (one short exchange, then a standing rule plus 164 file reads in one turn), two runs per build:
recentafter the first compactionWhat #53233 doesn't cover (offline runner scenarios, same result with and without it): on the overflow path, an instruction followed by a few hundred assistant steps before the next user message is still dropped as one exchange (
[1 older exchange omitted]). Starting new groups at assistant-message boundaries, and summarizing the oldest groups (or at least naming what was dropped) instead of omitting them, would close that.superbiche commentedon Oct 13, 2026
Another data point from 2.0.24, plus a different take on the fix than #53233 (my #54649, closed as its duplicate).
Worker sessions here get one message and then run ~50 tool turns. Over 9 consecutive compactions on two of them, 7 kept 1–3×
keep.tokens. The worst kept 434k characters ofrecentand summarized only 61k. The next compaction, 16 minutes later, had no user message since the checkpoint, so the last branch ofrecentStartkept everything again and the summarizer re-read the whole previousrecent(449k-character input).On the fix: #53233 keeps only whole exchanges that fit, so in a single-prompt run the tail is always empty and the agent resumes with only the summary. For an autonomous worker the most useful verbatim context is its last few tool calls and results, which is exactly what goes. #54649 keeps the user message as the start when it is within another
keepof the cut; otherwise the kept part starts at the cut, measured in characters with separators so it stays withinkeep. A tool call never gets split from its result, since both live in one assistant message. What #54649 does not cover, and #53233 does: a newest entry larger thankeepalone is still kept whole.Branch: https://github.com/superbiche/opencode/tree/compaction-recent-bound. I'm happy to fold the "summarize an oversized newest entry" part of #53233 into it, or to bring the cut-point tail to #53233, whichever the maintainers prefer.
Disclosure: this comment, the measurements and the fix were drafted with AI assistance; I reviewed them and the decisions are mine.
@superbiche · user · drafted with Claude Opus 5.5, reviewed before posting; the voice and decisions are mine.
opencodejr commentedon Oct 28, 2026
Thanks for the detailed report. I reproduced this on the latest
dev(ced73dc0f). I couldn't install the exact beta build you reported, so I tested againstdevonly.The script below builds a session with one user turn, then 60 assistant tool steps of about 2K tokens each, with a
syntheticmessage after every fifth step. It then runs a compaction with the defaultkeepof 15,000 tokens. The result:keepIDpoints at the user message, so 73 of the 74 messages are kept as-is. Sent to the model, that is about 122K tokens against the 15K setting.This matches your reading of
recentStart()inpackages/core/src/session/compaction.ts.keeponly sets where the search for a user message starts, and nothing limits how far back it goes. I've passed this to the maintainers.Reproduction script (issue-43250.test.ts)
Run it:
bun issue-43250.test.tswoonaval commentedon Nov 3, 2026
Same root cause, still present on v2.2.5 (2026-10-30), and reproducible offline.
Independent data point from a single OpenCode server (1M-context model,
compaction: { auto: true, keep: { tokens: 12000 }, buffer: 700000 }):opencode.db, the first post-compaction request (tokens.input + cache.read + cache.write) is typically 30–50k tokens (median 35.9k), minimum 13.2k (empty tail = fixed prompt ~11k + summary ~2k), maximum 106.3k.metadata.keepIDpointed at a user message 80.5 min earlier (122 messages kept verbatim).usermessage, the fallback keeps the entire window.round(len/4)per scored message, then rewind to the lasttype: "user"message, with the no-anchor fallback — driven bykeep.tokens: 12000alone, reproduces the recordedmetadata.keepIDfor 38 / 38 compactions since 2026-11-01. Confirmskeepsets only where the search starts, not the retained size.PR #53233 fixes it. I built the branch (
runsonmypc/opencode@8456cc2, basedev= v2.2.5), ran its tests (44/44 pass), and put it on the same server. Two consecutive auto-compactions of the same session, same model:23,206new +10,112cached)metadata.keepID= a user message ~2.5 min earliermetadata.keepID— the oversized latest exchange is summarizedThe pre-compaction context was ~299k in both cases, so the only variable is the checkpoint; the oversized checkpoint also disappears from the record. Thanks — this is the fix.
Trasen commentedon Nov 9, 2026
Data point from v2.1.3, proxy-served Qwen (OpenAI-compatible), 262144 window
Most reports here are Ollama/local — mine is a 262144-context Qwen served through an OpenAI-compatible proxy, default compaction (keep.tokens: 15000, default buffer). Setup: one driving prompt, then a multi-hour autonomous loop (no user messages until the end).
From opencode.db (session_message) for the dead session:
1,076 assistant messages vs 13 user messages; 6 successful auto-compactions
Final checkpoint's keepID sits 249 messages (~1.5 MB of raw data) before the end, against the 15k setting
Timeline:
Two observations that match the thread but weren't stated together:
The trigger was skipped by one big step. The last measured step sat ~550 tokens under the default-buffer trigger (~222,822); the next step added ~40k (tool output + reasoning), jumping straight past the hard limit. Step-boundary triggering means one large step can leapfrog the trigger entirely.
Once the oversized tail is established, user-side mitigation is impossible. With the keep window swallowing the whole conversation, the compaction floor (fixed overhead + summary + retained tail + latest exchange) exceeds the window, so removing/reverting user content can't help — the shrink ladder bottoms out immediately. This is what makes the failure feel like "compaction stopped working" rather than "context grew too big".
Happy to share more measurements if useful.