Skip to content

fix(provider): Anthropic normalize splits tool-call from tool-result causing dangling tool_use error #25774

Closed as not planned

Description

@codeg-old

Description

In packages/opencode/src/provider/transform.ts, normalizeMessages() splits assistant turns where text trails tool-call blocks for Anthropic / Vertex Anthropic models. The current split filter groups only tool-call parts together and treats tool-result as non-tool content, so an assistant turn shaped like:

[tool-call, tool-result, text]

becomes:

msg A: [tool-result, text]
msg B: [tool-call]

After provider/SDK merge this still presents tool_use blocks without a paired tool_result immediately after, so Anthropic returns:

tool_use ids were found without tool_result blocks immediately after

OpenCode version

1.14.33 (also reproduces against current dev).

Steps to reproduce

  1. Use a Claude / Vertex Anthropic model.
  2. Have an assistant turn whose content ends up shaped like [tool-call, tool-result, text] or [tool-call, text, tool-result] (this can happen during reconstruction or when tool-result is co-located with the originating tool-call).
  3. Send another user turn so the assistant turn is replayed in normalizeMessages().
  4. The provider rejects the request with tool_use ids were found without tool_result blocks immediately after.

The fix is small: keep tool-call and tool-result paired during the split (treat both as "tool" parts).

Operating System

macOS 26.0.1 / 25.4.0 (Apple Silicon).

Activity

  1. viiqswim commented on Jun 29, 2026

    @viiqswim

    Really good writeup. The normalizeMessages split is exactly the kind of thing a plugin can't catch, since it happens after the hooks have already run.

    While the transform.ts fix is pending, I put together a plugin that at least stops sessions from getting permanently wedged:

    https://github.com/viiqswim/opencode-compaction-guard

    "plugin": ["opencode-compaction-guard@latest"]

    It does two things: heals pending/running tool calls into error results before each request (covers the interrupted-tool case), and reverts the failed turn if the 400 still comes back (debounced, reversible with /unrevert).

    To be straight about the limit here: the split you're describing lands after convertToModelMessages/normalizeMessages, which is past where the transform hook runs, so for that exact path it relies on the revert rather than preventing it. It's a stopgap until the real fix ships. Approaches are from #8497 and #1662.

  2. sonny-travix-dev commented on Jul 23, 2026

    @sonny-travix-dev

    Confirming this root cause is alive in 1.18.4 (this issue was filed against 1.14.33). Adding a second, independent producer of the orphaned tool_use — the compaction / summary request — plus the "permanently stuck" amplification, since they compound.

    compaction.ts slices the summary input on message boundaries by token budget alone — no tool_use/tool_result pairing check

    In packages/opencode/src/session/compaction.ts (v1.18.4), splitTurn() picks a cut point inside a turn purely by budget:

    // L105-L121
    function splitTurn(input: { messages; turn; model; budget }) {
      ...
      for (let start = input.turn.start + 1; start < input.turn.end; start++) {
        const size = yield* input.estimate({ messages: input.messages.slice(start, input.turn.end), model: input.model })
        if (size > input.budget) continue
        return { start, id: input.messages[start].info.id }   // cut point — no pairing check
      }
    }

    and select() takes the head as a raw prefix:

    // L234-L237
    if (!keep || keep.start === 0) return { head: input.messages, tail_start_id: undefined }
    return { head: input.messages.slice(0, keep.start), tail_start_id: keep.id }

    The head is then cloned, the transform hook fires, it's converted, and a summary user prompt is appended:

    // L349-L351
    const msgs = structuredClone(selected.head)
    yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
    const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true, ... })

    When the history contains a run of consecutive assistant tool_use turns with no user turn between them (e.g. an agent dispatching several task/subagent tool calls back-to-back), the cut point can land inside that run. The head then ends on an assistant tool_use, and the request becomes [..., assistant(tool_use), user(summary prompt)] — the tool_use is immediately followed by summary text instead of a tool_result:

    messages.2: `tool_use` ids were found without `tool_result` blocks immediately after: toolu_XXXX.
    

    Two things worth flagging:

    1. The offending tool part is a completed task tool with a real output — so the "pending/running → synthesize output-error" guard in message-v2.ts does not catch it. The pairing was broken by the compaction slice, not by an unfinished tool.
    2. For the compaction path the boundary is fixed by splitTurn() before the hook runs (L350 operates on the already-sliced head), and the orphan only materializes in toModelMessagesEffect + the appended summary prompt — so a transform-hook plugin can't repair it, matching @viiqswim's point above about normalizeMessages happening after the hooks.

    Why it's non-recoverable

    The compaction 400 is isRetryable: false. If anything auto-retries compaction (opencode's own post-compaction auto-continue, or an agent framework's idle continuation), the session deadlocks: retry → same 400 → new failed compaction message → retry. In one real session I saw 8 consecutive failed compactions ~7s apart, the run frozen, the session unrecoverable — same shape as #27594. (I filed the continuation-loop fail-safe separately on the agent layer: code-yeongyu/oh-my-openagent#6303, but the trigger is here.)

    So the fix proposed in this issue (keep tool-call/tool-result paired in normalizeMessages) is necessary but not sufficient: splitTurn() / select() in compaction.ts need the same pairing-aware boundary, otherwise the summary request keeps emitting orphaned tool_use independent of normalizeMessages().

    Env: opencode 1.18.4, macOS 14.2.1 (arm64), Anthropic Claude models.

  3. hacker-h commented on Aug 2, 2026

    @hacker-h

    Confirmed on OpenCode 1.17.10, macOS, using Anthropic Claude.

    Auto-compaction triggered at 103% context usage and failed with tool_use IDs missing immediately following tool_result blocks. Retrying produced the same error and left the session unusable.

    I checked the local session database: both referenced tool calls were marked completed and had stored outputs. This suggests compaction/request construction separated valid tool calls from their results, rather than the tools being interrupted.

    The logs show the failure occurred in agent=compaction.

  4. github-actions commented on Oct 1, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

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