Skip to content

Bug: reasoning_content is replayed in the request history, causing tool-call markup leakage #56352

Open

Description

@qwop-os

opencode persists the model's reasoning_content (chain-of-thought) in the session history and sends it back in every subsequent request. DeepSeek's API states that reasoning_content is the current turn's CoT and must not be passed back.

Impact: with reasoning models (e.g. deepseek-v4.1-flash), the model imitates patterns from its own past reasoning - including malformed tool-call markup - so tool calls are silently lost (tool_calls == 0, finish_reason == "stop"). It is self-reinforcing: the more it leaks, the more that markup appears in reasoning.

A/B (one captured opencode request, 1011 messages / 4.78 MB, replayed to the same endpoint with a "write a ~120-line script" instruction appended):

variant leak rate
with reasoning_content 4 / 5
without reasoning_content 0 / 5
last 300 msgs, with 2 / 5
last 300 msgs, without 0 / 5

"Leak" is defined strictly as: markup inside delta.content and tool_calls == 0 and finish_reason == "stop".

Observed message structure in the captured request: assistant messages 480, of which reasoning_content present in 480.

Excluded by A/B: max_tokens (256k vs 128k -> 4/5 vs 4/5), system prompt content, temperature, tool_choice, parallel_tool_calls, thinking/reasoning_effort fields, and endpoint (official api.deepseek.com vs the opencode gateway). The dose-response (100 msgs 0/5 -> 300 msgs 2-3/5 -> 1009 msgs 4/5) is fully explained by the replayed CoT.

A different client (CodeBuddy) with the same model and the same real leaked-text corpus in a 1.13 MB history does not reproduce it (0/5).

Suggested fix: do not send reasoning_content back when building the request payload (keep it for UI display only); optionally gate behind a provider/model capability flag.

Reproduction: run a long session with a reasoning model, then ask it to write a long file - occasionally the tool call is emitted as text; re-sending the same request with reasoning_content stripped removes the failure (0/5 vs 4/5).

Environment: opencode 2.0.30 (Windows), providers opencode-go and official deepseek, model deepseek-v4.1-flash (variant max).

Activity

  1. opencode-agent commented on Oct 17, 2026

    @opencode-agent
    Contributor

    Thanks for the A/B numbers, they're very helpful.

    I checked the code for 2.0.30, and the request side matches what you describe. For any DeepSeek model (matched by provider, base URL or model ID), OpenCode sends reasoning_content back on every earlier assistant message, including turns before the latest user message. That's why all 480 assistant messages in your capture have it.

    I can't reproduce the leak itself here. It depends on how deepseek-v4.1-flash responds to that history, and testing that needs a real DeepSeek or OpenCode Go account, which this sandbox doesn't have. A maintainer with access will need to confirm it.

    One thing to consider for a fix: removing reasoning_content entirely probably isn't safe. DeepSeek needs it passed back inside a tool-call turn, and requests without it are rejected (see #55096 and #55104). A narrower option would be to drop it only for assistant messages before the latest user message. If you still have your A/B setup, could you replay your captured request that way (keep reasoning_content within the current turn, remove it from earlier turns) and share the leak rate? Please also say whether the API accepts that request. That would make the case much stronger.

    Related issues, most closely related first:

  2. qwop-os commented on Oct 17, 2026

    @qwop-os
    Author

    Tested the narrower variant as requested.

    Setup: one captured request (1011 messages / 4.78 MB) replayed against the same endpoint
    (opencode.ai/zen/go/v1, deepseek-v4.1-flash), with a "write a ~120-line script" instruction
    appended. 5 runs per variant.

    variant leak rate API errors
    keep reasoning_content everywhere (baseline) 4/5 0
    keep it only in the LAST assistant message (~current turn) 1/5 0
    strip it everywhere 0/5 0

    "Leak" is defined strictly as: markup inside delta.content and tool_calls == 0 and
    finish_reason == "stop". (A naive substring count gives false positives, because the model
    legitimately discusses those strings in reasoning_content.)

    Two notes:

    1. The API accepted both stripped variants - 0 errors. No 400 in our runs, including the variant
      that strips reasoning_content from assistant messages that carry tool_calls. So on this
      endpoint the "missing reasoning_content -> 400" case (session: upstream rejects with missing 'reasoning_content' on older assistant message after model/provider switch #55096) did not trigger. It may be
      specific to model/provider switches, as that issue's title suggests.

    2. Narrowing to "current turn only" reduces the leak substantially (4/5 -> 1/5) but does not fully
      remove it
      in our sample; stripping everywhere gave 0/5. If passing reasoning_content back
      inside a tool-call turn is required, the narrow fix looks like a reasonable compromise - though
      note the sample is small (n=5).

    One caveat worth flagging: #50206 mentions the same markup-as-text symptom being reported with
    other clients too. If that is accurate, replayed reasoning_content may not be the only trigger.
    In our environment the A/B above is a clean controlled comparison (same request, same endpoint, same
    model, only the reasoning_content fields differ), so it is at least a sufficient cause here.

  3. dvdred commented on Oct 27, 2026

    @dvdred

    A deterministic trigger for this leak: interrupting a tool call, then continuing the same session.

    This may be the reproduction case you're missing — you don't need to wait for a 1000-message session to hit it, and it doesn't depend on luck: interrupt a running shell tool, send any follow-up message in the same session, and the very next assistant turn leaks markup.

    Environment

    • opencode v2.2.0, Linux Mint 21.3 (kernel 6.8.0), node 22.22.3, Zed via opencode acp
    • provider A: custom @opencode/ai/providers/openai-compatible → local OpenAI-compatible proxy, deepseek-v4.1-flash
    • provider B: built-in opencode provider (zen), deepseek-v4.1-flash-free — same leak, after a server restart mid-session
    • provider config includes compatibility.reasoningField: "reasoning_content", so the reasoning channel is being parsed correctly; the stored parts in this session carry type: "reasoning" with native.reasoningField: "reasoning_content"

    Message sequence (from session_message, ~/.local/share/opencode/opencode.db)

    seq 544  assistant  finish=error      tool shell  status=error  error.type=aborted  content=<missing>
    seq 553  idle       outcome=interrupted
    seq 556  user       "ho fatto, procedi"          <- continuing the SAME session
    seq 557  assistant  finish=tool-calls  tool edit  status=error  (oldString not found)
    seq 572  assistant  finish=tool-calls  TEXT = "<|end▁of▁thinking|>"
    seq 581  assistant  finish=stop        TEXT = "\n<tool_call>\n{\"name\":\"edit\",\"path\":\"<repo>/tools/make_targets.py\",..."
    seq 586  idle       outcome=succeeded            <- turn ends, nothing executed
    

    The aborted tool part, verbatim (<repo> = project path):

    {
      "type": "tool",
      "id": "call_7041d03d85fd48f5a81d4d87",
      "name": "shell",
      "executed": false,
      "state": {
        "status": "error",
        "input": {
          "command": "for i in $(seq 1 20); do\n  used=$(ssh -o BatchMode=yes <host> 'nvidia-smi --query-gpu=memory.used --format=csv,noheader,nounits | sed -n \"6p\"' 2>/dev/null)\n  echo \"t=$((i*15))s gpu5=${used}MiB\"\n  if [ -n \"$used\" ] && [ \"$used\" -lt 2500 ]; then echo FREE; break; fi\n  sleep 15\ndone",
          "timeout": 360000
        },
        "error": { "type": "aborted", "message": "Tool execution interrupted" },
        "metadata": { "shellID": "sh_17f84d1ce0016qCKfSJbRL43Ld" }
      }
    }

    There is no content key under state: the interrupted tool call is persisted without any result. So on the next request the history contains an assistant turn with a tool call and no matching tool result — on top of a replayed reasoning trace from the interrupted turn (which is the reasoning_content-replay condition this issue describes). Both of #55096's ingredients are present.

    Steps to reproduce

    1. Ask the agent to run a long shell command (e.g. a poll loop of ~2 minutes).
    2. Interrupt the tool (stop button / esc / ACP session/cancel).
    3. Send any follow-up message in the same session.
    4. The next turn emits the reasoning terminator as text, then a raw tool-call JSON as text, finish: "stop", no tool executed.

    How often

    • 770 assistant messages in this install's session_message contain a tool part with "type":"aborted" — the missing-result condition is common, not exotic.
    • In the last 40 sessions, 4 messages leak the DeepSeek marker set (<end_of_thinking> / <tool_call>). Three follow an interruption (aborted shell, aborted tool + edit error, server restart mid-session); the fourth shows the degradation starting on its own: finish: "stop" with the model narrating a tool call instead of making one ("Let me read TASK-tmp.md and check the project state."), then after the user asks "why do you stop?" the next turns leak the terminator - same shape as Code Mode: model writes tool call markup as plain text — call is dropped and the step ends idle #57344/compaction: model emits tool calls as literal text after compaction — tools never run, session returns no output #50814. A GLM 5.3 session leaks a different marker - see below. So the interrupt is the reliable trigger, not the only one.
    • Control: same model, same proxy, clean 5-tool-call task (read 3 files → write summary → re-read) → correct finish values, no markup. Plugins disabled → identical result. So it is not a plugin, not the tool loop, and not the model in isolation.

    Not DeepSeek-only

    Same provider, different model, same outcome — which is why I don't think replayed reasoning_content alone explains it:

    • session ses_e805f62bdffeM5YPytW0TMxs6j, model opencode-go/glm-5.3 via a custom openai-compatible provider, 69 messages
    • an interrupted read tool part: status=error, error.type="unknown", no content (same shape as the shell part above, different error type)
    • from seq 510 on the turn never converges: 8 consecutive assistant turns announcing the same intent ("Ora eseguo davvero...", "Esecuzione reale, ora:", "Breaking the loop - executing now: ..."), finish: "tool-calls" each time, with a raw closing-delimiter fragment in the visible text: Breaking the loop - executing now: responseOra eseguo davvero: grep sulla documentazione API...
    • the last turn is finish: "error" with no tool parts, followed by idle outcome=interrupted

    So on GLM the corruption shows up as a repetition loop plus a delimiter fragment in content (see also #55584), on DeepSeek as <end_of_thinking> / <tool_call>. Different marker, same shape: a turn interrupted mid-tool-call is never normalized, and everything after it degrades.

    Why I think this is the same bug and not a separate one

    The leak text (<|end▁of▁thinking|>, <tool_call>{...}) is the model continuing its own interrupted turn's template, exactly the imitation effect described here: what it imitates is the replayed CoT of a turn that ended mid-tool-call. The interrupt path adds a second defect: the aborted tool part is stored with no result at all, so the pair assistant(tool_call) / tool(result) is never closed — see also #50214, where MessageV2.toModelMessages is reported to treat incomplete tool parts as silently breaking model context, and #55096, where the same interrupt leads to a 400 instead.

    Suggested fix (combined)

    1. When a tool part ends aborted/error, persist a result body so the tool pair is always complete before the request is built ([FEATURE]: Sanitize interrupted running/pending tool parts on subagent resume #50214), and
    2. stop replaying reasoning_content for assistant messages before the latest user message (Bug: reasoning_content is replayed in the request history, causing tool-call markup leakage #56352), keeping it only within the current turn as the maintainer bot suggested — that variant already measured 1/5 vs 4/5 in the A/B above.

    Either one alone leaves the session able to re-enter the bad state; together they cover both the malformed pair and the imitation source.

    Workaround

    After interrupting a tool, abandon the session and start a new one. Continuing it always re-triggers the leak, because the interrupted turn stays in history.

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

Metadata

Metadata

Assignees

No one assigned

    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