You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
toLLMMessages emits a string tool_use.input when streaming tool arguments are not valid JSON #56127
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:
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.
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.
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"
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:
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.
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.
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.
Activity
opencode-agent commentedon Oct 16, 2026
Thanks for the precise report. I reproduced this on
devat 582c974, which is also the latestdevright now.The test below builds an assistant message with a
shelltool part still instreamingstate, runs it throughtoLLMMessages, then through the Anthropic Messages request compiler:streamingwith inputnot-jsongivestool_use.input: "not-json"(a string)streamingwith input{"command":"pwd"}gives the object{"command":"pwd"}So the fallback in
toolInputlets 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)
Run it:
bun repro-56127.test.tsdrakeo338 commentedon Oct 16, 2026
I'm looking at this one and plan to open a fix shortly.
argszero commentedon Oct 17, 2026
I'd like to work on this. The report pins the defect to the
streamingbranch oftoolInputinpackages/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 astreamingpart throughtoLLMMessagesand the Anthropic request compiler in the same way as the reproduction above.adgeese commentedon Oct 20, 2026
Another production report of this bug, on v2.1.0 (latest, Linux/WSL2, served by OpenChamber). The provider is
@ai-sdk/openai-compatiblepointed at a LiteLLM proxy with an Anthropic upstream (claude-opus-5-5andclaude-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:
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
streamingstate. The overlap came from:shellorsubagentcall still runningwritestill streaming its argumentsWhile a tool part is streaming,
session_messagestores it as:{"type":"tool","name":"shell","state":{"status":"streaming","input":""}}So the raw argument text is
"", which is not valid JSON. ThetoolInputfallback into-llm-message.tssends it as is, and Anthropic getstool_use.input: "". That is the same path reproduced above. The failing assistant message is saved withcontent: [],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 notool_resultyet. If it is replayed with no result, Anthropic may still reject the request for an unmatchedtool_use.drakeo338 commentedon Oct 21, 2026
Releasing this one: I won't be opening the PR after all, so anyone is welcome to pick it up.
csj0oe commentedon Oct 21, 2026
Another reproduction on v2.0.31 (latest, Linux/WSL2), provider
github-copilot, modelclaude-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 afteropencode.jsonwas edited in another session (adding a local MCP server and"websearch": false, then runningopencode mcp list). The tool catalog changed, and each active session received repeated systeminstructionsnotices ("Code Mode tool catalog has changed" / "available skills have changed").Timeline from the session store (local time)
time.createdtime.ranmessages.184.content.0)writemessages.206.content.2)writeEach failing request was created < 1 s after a system
instructionsnotice row (16:29:06, 16:30:41). At that moment the referencedwritecall 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-flightstreamingtool call with partial JSON arguments, which is thedecodeToolInputfallback 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
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
streaminginput before lowering, an instructions or tool-catalog refresh should probably not start a new step while the current step is still streaming. Alternatively, in-flightstreamingtool calls could be excluded from the request.platelk commentedon Oct 29, 2026
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 servewith headlessopencode runclients; 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 withfinish = 'error'whose error containstool_use.input: Input should be an object), window 2026-10-22 → 2026-10-29 UTC:content.0, the first block of a replayed message. 10 sessions failed more than once; one failed 7 times, on every resume.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.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 finishedtool-calls, and in 50 of those it ended on a completed tool part. So the stringinputis produced when the request is built (consistent with thetoolInputfallback into-llm-message.tsdescribed above), not read back from the db.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
streamingwhen 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.
fix(core): reject JSON arrays in the failed-tool input fallback