Skip to content

toLLMMessages emits a string tool_use.input when streaming tool arguments are not valid JSON #56127

Open

Description

@Parker-Fawcett

Description

A tool call stuck in streaming state with unparseable arguments lowers to a string on the Anthropic wire, which the API rejects.

toolInput in packages/core/src/session/runner/to-llm-message.ts reads:

tool.state.status === "streaming"
  ? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input)
  : tool.state.input

When the raw argument text is not valid JSON, the fallback returns that text unchanged. lowerToolCall in packages/ai/src/protocols/anthropic-messages.ts then copies part.input onto the wire with no coercion, producing tool_use.input: "not-json". Anthropic answers 400 tool_use.input: Input should be an object.

I ran each persisted tool state through toLLMMessages and then the real Anthropic body lowerer:

streaming + bad json   -> input "not-json"          typeof=string
streaming + valid json -> input {"command":"pwd"}   typeof=object
running                -> input {"command":"pwd"}   typeof=object
error + clean object   -> input {"command":"pwd"}   typeof=object

Only the streaming row is wrong.

Plugins

None.

OpenCode version

dev at 582c974.

Steps to reproduce

  1. Have a tool call reach streaming state with argument text that is not valid JSON, for example not-json.
  2. Lower the message through toLLMMessages.
  3. Pass the result through the Anthropic body lowerer.
  4. tool_use.input is the raw string rather than an object.

Screenshot and/or share link

Not applicable.

Operating System

macOS (Darwin 24.3.0, arm64). Provider behavior is platform independent.

Terminal

Not relevant.

Activity

  1. opencode-agent commented on Oct 16, 2026

    @opencode-agent
    Contributor

    Thanks for the precise report. I reproduced this on dev at 582c974, which is also the latest dev right now.

    The test below builds an assistant message with a shell tool part still in streaming state, runs it through toLLMMessages, then through the Anthropic Messages request compiler:

    • streaming with input not-json gives tool_use.input: "not-json" (a string)
    • streaming with input {"command":"pwd"} gives the object {"command":"pwd"}

    So the fallback in toolInput lets the raw argument text through, and the Anthropic lowerer sends it as-is. One caveat: I didn't show a normal session getting here, because the runner settles stuck streaming/running tool calls (settleStaleToolCalls) before it builds a request. Related reports with the same 400: #55886 and #53865.

    The script below expects to sit at packages/core/test/ in a checkout with dependencies installed.

    Reproduction script (repro-56127.test.ts)
    import { expect, test } from "bun:test"
    import { Effect } from "effect"
    import { DateTime } from "effect"
    import { Auth } from "@opencode/ai/route"
    import { LLM } from "@opencode/ai"
    import * as AnthropicMessages from "../../ai/src/protocols/anthropic-messages.js"
    import { compileRequest } from "../../ai/src/route/client.js"
    import { Model } from "@opencode/core/model"
    import { Provider } from "@opencode/core/provider"
    import { SessionMessage } from "@opencode/core/session/message"
    import { toLLMMessages } from "@opencode/core/session/runner/to-llm-message"
    import { Agent } from "@opencode/core/agent"
    
    const created = DateTime.makeUnsafe(0)
    const ref = Model.Ref.make({ id: Model.ID.make("claude"), providerID: Provider.ID.make("anthropic") })
    const anthropic = AnthropicMessages.route
      .with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
      .model({ id: "claude-sonnet-4-5" })
    
    const lower = (state: SessionMessage.AssistantTool["state"]) => {
      const messages = toLLMMessages(
        [
          SessionMessage.User.make({ id: SessionMessage.ID.make("msg_u"), type: "user", text: "run pwd", time: { created } } as any),
          SessionMessage.Assistant.make({
            id: SessionMessage.ID.make("msg_a"),
            type: "assistant",
            agent: Agent.defaultID,
            model: ref,
            content: [SessionMessage.AssistantTool.make({ type: "tool", id: "call_1", name: "shell", state, time: { created } })],
            time: { created },
          }),
        ],
        ref,
      )
      return Effect.runPromise(compileRequest(LLM.request({ model: anthropic, messages })))
    }
    
    const toolUse = (body: any) =>
      body.messages.flatMap((m: any) => (Array.isArray(m.content) ? m.content : [])).find((c: any) => c.type === "tool_use")
    
    test("streaming + bad json", async () => {
      const p = await lower(SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "not-json" }))
      const tu = toolUse(p.body)
      console.log("streaming + bad json ->", JSON.stringify(tu.input), typeof tu.input)
      expect(typeof tu.input).toBe("object")
    })
    test("streaming + valid json", async () => {
      const p = await lower(SessionMessage.ToolStateStreaming.make({ status: "streaming", input: '{"command":"pwd"}' }))
      const tu = toolUse(p.body)
      console.log("streaming + valid json ->", JSON.stringify(tu.input), typeof tu.input)
      expect(typeof tu.input).toBe("object")
    })

    Run it: bun repro-56127.test.ts

  2. drakeo338 commented on Oct 16, 2026

    @drakeo338

    I'm looking at this one and plan to open a fix shortly.

  3. argszero commented on Oct 17, 2026

    @argszero
    Contributor

    I'd like to work on this. The report pins the defect to the streaming branch of toolInput in packages/core/src/session/runner/to-llm-message.ts, and I will fix it there so that an unparseable streaming argument set lowers to an object rather than the raw text, with a regression test that runs a streaming part through toLLMMessages and the Anthropic request compiler in the same way as the reproduction above.

  4. adgeese commented on Oct 20, 2026

    @adgeese

    Another production report of this bug, on v2.1.0 (latest, Linux/WSL2, served by OpenChamber). The provider is @ai-sdk/openai-compatible pointed at a LiteLLM proxy with an Anthropic upstream (claude-opus-5-5 and claude-sonnet-5-5).

    Frequency: 8 failures in 7 sessions on 2026-10-20, between 12:20 and 13:12 UTC. Each one ended the turn. The UI shows "The running turn was stopped before OpenCode could send the next message". The server log shows:

    level=ERROR message="Failed to drain Session" cause="AI.Error: ... invalid_request_error
    messages.127.content.0.tool_use.input: Input should be an object"
    

    Anthropic request IDs: req_011CgDUiwgHRNtmpVAYQ7s2t, req_011CgDUj6EEGxbsMmM6zhvuj, req_011CgDYLpxJv64HY88yvfA5Y, req_011CgDYN7ieQD2DnXBsYGY1K, req_011CgDYhYPVRCHcndtGVzf4Z (Opus); req_011CgDUq5cvnfemswwUEKENG, req_011CgDV1e5LsTPMZk7BG6fPG, req_011CgDV3mzu95EeARVR3sC5Y (Sonnet).

    Trigger: in all 8 cases, a second assistant message in the same session was still running when the failing request was sent. Its tool part was still in streaming state. The overlap came from:

    • a session resumed after "The server restarted while you were working" (3 failures within seconds of one restart)
    • a background shell or subagent call still running
    • a long write still streaming its arguments

    While a tool part is streaming, session_message stores it as:

    {"type":"tool","name":"shell","state":{"status":"streaming","input":""}}

    So the raw argument text is "", which is not valid JSON. The toolInput fallback in to-llm-message.ts sends it as is, and Anthropic gets tool_use.input: "". That is the same path reproduced above. The failing assistant message is saved with content: [], finish: "error", error.type: "provider.invalid-request".

    Workaround: a LiteLLM pre-call hook that rewrites any non-object tool_calls[].function.arguments. A call with a tool result gets "{}". A call with no result is dropped. Since we deployed it, these requests succeed. A live replay of the "" shape now returns 200.

    #57438 looks like it fixes this, because "" would lower to {}. One possible gap: a streaming call has no tool_result yet. If it is replayed with no result, Anthropic may still reject the request for an unmatched tool_use.

  5. drakeo338 commented on Oct 21, 2026

    @drakeo338

    Releasing this one: I won't be opening the PR after all, so anyone is welcome to pick it up.

  6. csj0oe commented on Oct 21, 2026

    @csj0oe

    Another reproduction on v2.0.31 (latest, Linux/WSL2), provider github-copilot, model claude-opus-5.5, Code Mode enabled, no plugins. This adds a concrete trigger: a mid-turn instructions/tool-catalog refresh starts a new request while the current step is still streaming a large tool call.

    What happened

    Two sessions failed 3 times within 2 minutes with 400 messages.N.content.M.tool_use.input: Input should be an object (Failed to drain Session, provider.invalid-request). It started right after opencode.json was edited in another session (adding a local MCP server and "websearch": false, then running opencode mcp list). The tool catalog changed, and each active session received repeated system instructions notices ("Code Mode tool catalog has changed" / "available skills have changed").

    Timeline from the session store (local time)

    Failing request (assistant msg created) Referenced tool call Tool call time.created Tool call time.ran
    16:29:06.587 (messages.184.content.0) write 16:29:03.679 16:29:12.825
    16:30:41.198 (messages.206.content.2) write 16:30:32.820 16:30:52.709

    Each failing request was created < 1 s after a system instructions notice row (16:29:06, 16:30:41). At that moment the referenced write call was still streaming its arguments: it executed 6–11 s later and completed normally. Its persisted input is a valid object. So the outgoing request lowered an in-flight streaming tool call with partial JSON arguments, which is the decodeToolInput fallback described in this issue.

    The third failure (another session, 16:28:31.909, messages.191.content.2, shell) came 55 ms after the referenced call completed. It may be the same race at the finalization boundary (as in #53865).

    Notes

    • All 126 persisted tool inputs in the affected session are objects, including errored and interrupted calls. History is clean, and the corruption exists only in the outgoing payload.
    • The next request after each failure succeeded.
    • An earlier occurrence (2026-10-20, messages.19.content.2) happened right after a burst of MCP connect failures and retries, which also changes the tool catalog.

    Suggested direction

    Besides coercing non-object streaming input before lowering, an instructions or tool-catalog refresh should probably not start a new step while the current step is still streaming. Alternatively, in-flight streaming tool calls could be excluded from the request.

  7. platelk commented on Oct 29, 2026

    @platelk

    Another data point, measured rather than reproduced, in case it helps whoever picks this up. OpenCode v2.1.3 / v2.2.0 on macOS, opencode serve with headless opencode run clients; provider: Claude through an OpenAI-compatible LiteLLM proxy in front of Anthropic's Messages API.

    Counted read-only from ~/.local/share/opencode/opencode.db (assistant messages with finish = 'error' whose error contains tool_use.input: Input should be an object), window 2026-10-22 → 2026-10-29 UTC:

    • 77 failed steps in 53 sessions (0.63 per hour of active lead sessions). All 77 are HTTP 400 at content.0, the first block of a replayed message. 10 sessions failed more than once; one failed 7 times, on every resume.
    • 61/77 (79 %) came within 10 minutes of a resume ("The server restarted while you were working…" or a client prompt after an interruption); 76/77 had a resume at some earlier point in the session.
    • 53/77 (69 %) had an earlier tool part with state.status = 'error', Tool execution interrupted: shell|execute (38 such parts in 29 sessions). Every one of the 53 failing sessions had either a resume or an interrupted tool before its first failure.
    • The stored history is clean. Of 331,003 stored tool parts, 331,002 have an object state.input, and all 7,266 tool parts stored before the first failure in the failing sessions are non-empty objects. In 51 of 53 sessions the last message before the failure finished tool-calls, and in 50 of those it ended on a completed tool part. So the string input is produced when the request is built (consistent with the toolInput fallback in to-llm-message.ts described above), not read back from the db.
    • A second trigger, as in the comment above: once the event stream to every client stalled for about a minute without the server restarting (messages kept being stored, longest gap 11 s). OpenCode then wrote the synthetic restart notice into 6 sessions at once, and 11 replay failures in 10 sessions followed within 30 minutes.

    We could not capture the outgoing body: debug logging never prints the provider payload. Three lab attempts (kill the client mid-tool, then resume) all resumed cleanly because the kill landed after the tool input had finished streaming. That fits the cause here: the call has to be still streaming when the next request is built.

    Workaround on our side for now: a supervisor detects this error and starts a fresh session from our own task record instead of resuming the poisoned one.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions