Skip to content

compaction: model emits tool calls as literal text after compaction — tools never run, session returns no output #50814

Open

Description

@tczank

Summary

After an automatic compaction, the assistant stopped emitting structured tool parts and started writing tool calls as literal text ([Assistant tool call]: shell({...})) inside a normal text part. Those calls are never dispatched, so every tool returns nothing and the session appears to produce "no output" while the model claims it is running commands.

Environment

  • opencode version: 2.0.14 (install/channel: /usr/bin/opencode, latest)
  • OS: Linux 7.2.6-arch2-1 (linux x86_64, Arch)
  • Terminal: TERM_PROGRAM=tmux, TERM=xterm-256color, COLORTERM=truecolor
  • Shell: /bin/zsh
  • Model: mimo-v2.6-flash-free (provider opencode), agent build
  • Active plugins: none found (no opencode.json/opencode.jsonc, no .opencode/plugin(s)/, no ~/.config/opencode/opencode.json)

Reproduction

  1. Run a long build session on mimo-v2.6-flash-free until auto-compaction fires (5 fired in this session).
  2. After a compaction, ask the model to run a tool (e.g. shell).
  3. Observe that the reply contains [Assistant tool call]: shell({ ... }) as plain prose instead of a tool part.
  4. The tool never executes; the turn ends with no tool output.

Expected Behavior

Tool calls should be emitted as structured tool parts and dispatched, as they are before compaction.

Actual Behavior

Measured over session ses_f399a9a80ffefY7lDW1EZuZfhV (100 assistant messages, 5 compaction records):

Before compaction #5 (seq 1396, 2026-09-23 02:36:50):

  • 88 assistant messages contained structured tool parts — 154 tool parts total (shell 81, read 26, edit 18, grep 12, …), all status: completed.
  • 0 assistant messages contained the literal [Assistant tool call].

After that compaction:

  • 10 assistant messages contain literal pseudo-calls and zero tool parts: seq 1406, 1418, 1439, 1451, 1487, 1499, 1535, 1547, 1563, 1575.
  • Only 2 of ~18 post-compaction assistant messages produced a real tool part.

Example (seq 1535) — prose, then four fake calls that are never executed:

Works — clean result, no corruption. Resuming task #1, re-firing the four ground-truth calls that died:

[Assistant tool call]: shell({"command":"gh api repos/.../issues/27734 --jq '.body' | head -c 3500","timeout":30000})

[Assistant tool call]: shell({"command":"curl -s -m 3 http://127.0.0.1:8080/slots | ...","timeout":15000})
...

That message has tool_parts=0.

Additional Context

Likely mechanism: compaction's recent history serialization is copied by the model

Every compaction record has a summary string and a recent string (the serialized recent history re-fed to the model after compaction).

Counting occurrences of [Assistant tool call]:

compaction seq summary recent
448 0 32
731 0 3
911 0 10
1087 0 7
1396 0 13

[Assistant tool call]: ${name}(${input}) (paired with [Tool result]: ...) is OpenCode's own history-serialization format — the literal is present in the shipped binary:

if (a.state.status === "completed")
  return [`[Assistant tool call]: ${a.name}(${i})`, `[Tool result]: ${c5(i$(a.state.content))}`]

So OpenCode renders previously-completed tool calls as plain text when building the post-compaction prompt. The model then appears to imitate its own prior turns as shown to it, emitting that exact format as prose instead of a tool call.

Supporting detail: the corruption is self-sustaining. Restarting OpenCode did not clear it — seq 1524 immediately after the restart produced one proper tool part (shell → alive 12:15:57.441537874, exit 0), but the very next turn (seq 1535) went back to literal pseudo-calls, because the previously-corrupted text turns are still in history and keep re-priming the format.

Observed user-visible symptoms

  • "are all of them giving no output"
  • "they are still returning nothing"
  • "echo worked but the others do not, why"
  • "really nothing coming out of this"

The one call that worked after restart was the structured one; the others were literal text.

Caveat on causality

Comps 1–4 also carried literals in recent without an immediate imitation cascade, so recent serialization alone may not be sufficient — a threshold, the specific boundary turn, or model-side drift may also be involved. But recent is the only place this exact string enters the prompt, and the first cascade begins immediately after compaction #5.

Suggested directions

  • Serialize completed tool calls in recent as structured tool parts (or with an explicit non-imitatable marker) rather than as plain prose that is indistinguishable from model output.
  • Post-compaction, detect assistant text matching ^\[Assistant tool call\]: and either re-hydrate it into a real tool part or surface a clear error instead of silently returning nothing.
  • When an assistant turn contains no tool parts but the model's stop reason indicates a tool call, log/flag it rather than completing the turn cleanly.

Diagnosed by reading ~/.local/share/opencode/opencode.db (session_v2, session_message) read-only; no session data was modified.

Activity

  1. FahadBinHussain commented on Oct 15, 2026

    @FahadBinHussain

    still reproduces on 2.0.23 (windows, tui) — a few details that might help:

    • the turn doesn't look broken anywhere: it ends idle/succeeded, no log error, resume works, the stored outcome says succeeded. the only trace is the [Assistant tool call]: edit({...}) string sitting inside the message's text part, rendered raw in the tui. nothing warns you the call was never dispatched.
    • real damage from one such turn: the edit was meant to remove a duplicate const text declaration in an html file. it never ran, so the page's whole inline script stayed dead (SyntaxError: Identifier 'text' has already been declared) while the session reported success.
    • cleanup that worked, if anyone else hits this: make the edit by hand, verify it, then drop the phantom turn from the thread with the revert api — POST /api/session/<id>/revert/stage with body {"messageID":"<msg id>","files":false}, then POST /api/session/<id>/revert/commit (removes that message and everything after it).

    finding affected turns in sqlite:

    SELECT seq FROM session_message
    WHERE session_id = '<id>'
      AND data LIKE '%Assistant tool call%'
      AND type <> 'compaction';

    the type <> 'compaction' exclusion matters — compaction summaries mention tool calls legitimately, so a plain LIKE matches every summary row too.

  2. FahadBinHussain commented on Oct 15, 2026

    @FahadBinHussain

    second hit, same class, with a nastier detail: this phantom turn didn't only fake the call — it fabricated the [Tool result]: block too. it quoted a real-shaped result (HTTP 200, 966283 bytes, logged-in markers) for a probe whose output file never existed on disk; a real rerun of the same probe returned a 13.8 KB loggedOut:true body instead. everything the turn "concluded" from that result was built on nothing.

    so spotting the call text isn't enough — after finding one, verify its quoted output against disk (artifact exists? size match?) before trusting any analysis in that turn. the compaction immediately before it was stale as well ("mutations not yet run" after a real logged run had already executed and failed its keep-assertion), so the run logs/artifacts were the only reliable source.

  3. thymikee commented on Oct 17, 2026

    @thymikee

    Same on 2.0.32 (macOS) with a different model, callstack/Apex through @ai-sdk/openai-compatible (vLLM behind LiteLLM). In our case the pseudo-calls ended up in the reasoning part, not the text part. Right after a compaction, the model wrote [Assistant tool call]: shell({...}), then an invented [Tool result]: Tool execution cancelled. and even a [User]: ... line, all inside reasoning. The step finished with finish=stop, no tool part and empty text, so the session went idle as if done.

    Across our worker sessions from 2026-10-16 to 10-17, 37 compaction records contain the [Assistant tool call] format. Two assistant steps, in one session, copied it, both shortly after a compaction. So a check that only scans text parts for the literal will miss this; the reasoning part needs it too.

  4. FahadBinHussain commented on Oct 19, 2026

    @FahadBinHussain

    on the part-type note: +1 for scanning the whole message instead of filtering on parts. ours is a raw data LIKE '%Assistant tool call%' over the stored message row, so it catches the literal wherever it landed - text or reasoning. that's how ours surfaced: pseudo-call in the text part right after the The response was cut off. Continue from where you left off. marker from a continuation, then a fabricated [Tool result]: for a probe whose output file never existed, and the step still closed idle/succeeded with no tool part. same end shape you describe, just via a different path (continuation after a cutoff instead of pure compaction).

  5. GZZXWZA commented on Oct 20, 2026

    @GZZXWZA

    Independent confirmation on 2.0.31 (Windows), plus a second failure mode after revert

    Environment:

    • opencode 2.0.31 (channel: latest), Windows 11 Home 10.0.26200 x64, desktop client
    • Models: stepfun-ai/step-5-preview via a custom @ai-sdk/openai-compatible provider (NVIDIA NIM endpoint), and mimo/mimo-v2.6-flash — both affected, so not model-specific
    • Active plugins: none
    • Evidence below was verified directly against the session SQLite DB (session_message rows), not just the TUI rendering

    1. Same root cause, DB-level confirmation

    Session ses_eca4fc28dffdfCI4tEPJ6I7UhI: 3,854 assistant messages, 3,990 structured tool parts before the incident. Last real tool part at seq 37562 (2026-10-19 16:11:39 local).

    Then exactly 2 assistant messages contain literal [Assistant tool call]: shell({...}) in the text part with zero tool parts, finish: "stop", cost: 0:

    • seq 37573 (16:16:29, input 109,248 tokens) — includes a fabricated [Tool result]: block claiming "23 files, 41,857 chars". I searched the entire DB: that number appears in no stored tool output anywhere in the session. The real measurement of the same directory is 9 files / 210,189 chars, so the fake result is off by ~5x on both counts, and downstream conclusions were built on it.
    • seq 37601 (16:18:36, right after a model switch to mimo-v2.6-flash) — the stored reasoning says "The last command failed/was interrupted. Let me re-run the python command that got cut off", i.e. the new model took the previous pseudo-call for real history and re-emitted it. Zero tool parts again.

    DB-wide: 16 assistant messages across 5 sessions contain the literal; every one sits shortly after a compaction, and each of those sessions' compaction records carries [Assistant tool call] in its serialized recent block (this session: compaction records at seq 37478, 33414, 25624). Matches the mechanism described above.

    2. New failure mode after a revert: empty <conversation-checkpoint> replies, still marked succeeded

    • The user reverted the session (revert removed seq 37485–37608, i.e. the polluted turns). The compaction record itself (seq 37478) remains and is the context base.
    • 4 minutes later, asked about the compaction summary, the model answered by emitting the compaction serialization itself: a full <conversation-checkpoint><summary>…</summary><recent-context>…</recent-context></checkpoint> block (8,694 output tokens, finish=stop, 0 tool parts). <conversation-checkpoint> appears in no stored compaction record, instruction file, or config on this machine (checked all 80 compaction rows + instruction_blob + kv), so the model reproduced the server-side serialization scaffold as a user-visible answer.
    • The next 4 user turns (16:51 → 17:00) were each answered with an empty <conversation-checkpoint><summary>(no content)</summary></checkpoint> — no answer, no tools, nothing. All 4 stored as finish=stop with idle outcome=succeeded.
    • Net effect: the session is dead (0 real tool calls after the revert) but reports success everywhere; the only visible signal is template text in the TUI.
    • Worse: the self-emitted checkpoint re-asserted the fabricated stats from failure mode 1 ("23 files / 41,857 chars → 10–14K tokens per turn"), promoting a hallucinated number into a checkpoint-shaped "authoritative" summary that then feeds later turns.

    3. Recovery dead-ends worth noting

    • Reverting to just-after the compaction keeps the polluted compaction record as the base; the model relapses.
    • /compact is not a user message, so it cannot be targeted by revert/undo.
    • POST /api/session/{id}/fork copies projected history before a message, so any fork point after the first polluted compaction carries the pollution into the child session. In this session that means forking cannot produce a clean continuation at all.

    Suggestions (beyond what's already proposed in this issue)

    1. Represent prior tool calls in the post-compaction prompt structurally, or at least in a format that doesn't read like the assistant's own output syntax.
    2. Add a cheap integrity check: if an assistant message's text/reasoning contains [Assistant tool call]:, [Tool result]:, or <conversation-checkpoint> while the message has zero tool parts, fail the step (or at least surface a warning in the TUI and log) instead of closing idle/succeeded.
    3. Consider making the compaction serialization visibly non-imitable (e.g. a distinct wrapper the system prompt explicitly forbids the model from producing), since the model demonstrably role-flips into "summarizer" once the topic touches compaction.

    Happy to provide the full DB rows (seq 37573, 37601, 37655, 37677–37713) if useful.

  6. FahadBinHussain commented on Oct 20, 2026

    @FahadBinHussain

    useful numbers here. from the other side: we reverted our two hits (2.0.23, mimo) back on 10-15, and both sessions still carry the [Assistant tool call] text in their compaction rows today - 60 in one, 321 in the other, since later summaries keep serializing the recent block. neither has produced a phantom-shaped assistant message since (literal + no tool part + finish=stop); every literal hit in them is a normal tool-bearing turn that quotes the text. one stayed in active use through today. so a seeded compaction is a standing risk, not an automatic death sentence.

    the <conversation-checkpoint> echo fits that: it's the exact wrapper the v2 compaction serialization uses. once the model reproduces it as an answer, the summarizer voice is in the history and later compactions can re-serialize it. if that starts, a fresh session with a hand-rolled handoff looks like the only clean exit - reverting keeps the seeded base, and as you found /compact can't be targeted and forks copy the pollution.

  7. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the detailed analysis and the per-compaction counts, they help a lot.

    The code works the way you describe. When a summary compaction keeps recent history, earlier tool calls are written out as plain text ([Assistant tool call]: name(input) / [Tool result]: ...) inside a <recent-context> block. Text that only looks like a tool call is never run, and the turn ends with no error.

    What we couldn't test here is the part that depends on the model: whether mimo-v2.6-flash-free really starts copying that format after a compaction. Our sandbox only has a simulated model and no provider access, so this needs a maintainer with access to the model to check.

    In the meantime, things that may help:

    • Start a new session, since the copied text stays in the old session's history.
    • Use a different model for long sessions.

    If you can, it would also help to know whether it happens with another model after a compaction.

  8. facebey commented on Nov 9, 2026

    @facebey

    Additional data point from a Windows session on v2.2.6 — still reproduces on the current version.

    Environment:

    • opencode v2.2.6, Windows 11 (win32 x64), TUI
    • Custom provider (private endpoint), custom model QWEN3.8-Flash-Next-Vision
    • Session: very long (several hours, ~20k tokens), one automatic compaction (summary checkpoint at the top of context)

    Observed pattern (slightly different from the earlier reports):

    • Built-in tools (read / edit / write / shell) kept executing normally on adjacent turns, before and after the failures.
    • The leakage hit MCP tool calls specifically (here engram_mem_save / engram_mem_search): the last call of the turn appears in the transcript as raw serialization — a tool_call / function= / parameter= tag block identical to the compacted-summary serialization — inside the text part, and the tool is never dispatched. The turn ends normally, no error, resume works.
    • The leaked markup is in tag form (tool_call / function= / parameter=), not the [Assistant tool call]: name(input) prose seen in earlier reports — same mechanism as @opencodejr's explanation (the model imitates serialized calls present in the compacted history), a different surface shape.
    • Three consecutive leaked turns, interleaved with turns where the same tools worked fine — it is not a whole-session breakage, it is per-turn model imitation triggered by the compacted history.

    User-visible cost: silently dropped writes. Our memory-save calls never executed across three attempts; the user only noticed by seeing the raw XML in the TUI transcript.

    Possible mitigation to explore: make the compaction serialization harder to imitate — e.g. a distinct marker for historical tool-call text (different from the live tool-call markup), and/or an explicit instruction injected in the compacted context ("never re-emit historical tool calls as text; use the tool-call format").

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