anomalyco / anomalyco/opencode
keep.tokens is not honoured: the compaction split walk-back is unbounded and skips synthetic messages, so agent-driven sessions carry 234K past a 15K setting
@neriousy is already working on this.
Since Aug 18, 2026.
- Dominant language
- TypeScript
- Stars
- 209k
- Forks
- 27.5k
- PR merge metrics
- PR metrics pending
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:
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:
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.tokens
If 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 39be1595993de57a5a667c86e21ff788a80ebbbe)
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
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.