The platform hands tool results back as the ASSISTANT's own prose
agent-think.ts:831, once a round of tools has run:
aiMessages.push({ role: "assistant", content: `I called tools:\n${toolResults.join("\n")}` });
aiMessages.push({ role: "user", content: `Continue based on the tool results above. …` });
Two things are wrong with that, and the second one is structural.
1. The model's tool_use turn is never added to the conversation at all. runAnthropic reads
tool_use blocks out of the response (user-ai.ts:186) and converts them to a Workers-AI-shaped
tool_calls array — and nothing puts them back. The next request carries no tool_use and no
tool_result. normalizeForAnthropic (:68) has no concept of either: it maps every role to
user/assistant and merges consecutive same-role turns.
So the exchange the provider sees is: request with tools → response with tool_use →
a request in which that never happened, plus an assistant paragraph narrating results.
2. Ground truth is stored in the one role that means "the model's own words". A tool_result
block is structurally the platform's. An assistant message is structurally the model's. After the
merge in normalizeForAnthropic they are one paragraph, and on the next turn nothing distinguishes
a real tool output from something the model asserted.
This is the likely cause of #395, not a neighbour of it
In #395 the model wrote its own <tool_call> / <tool_response> pairs into the reply, invented
their contents (a repo remote that contradicted the real one, three GitHub issues from a tool that
never ran), and the platform passed it through as fetched fact.
Look at what this loop teaches it, every single turn: tool results appear in the transcript as
assistant prose that begins "I called tools:". The model reproduced the format it was shown. It
had no structural signal that a result is not something an assistant writes, because in this
conversation it is exactly that.
Fixing #395 by stripping the markup treats the output. This is the input.
A third, smaller loss
Dropping the tool_use turn also drops the ARGUMENTS. The follow-up context reads
[repo_read_file]: <contents> with no path. Call one tool twice in a round with different arguments
— two files, two repos, two issue numbers — and the model cannot tell which result belongs to which
call. It has to infer from the content, and when it cannot, it guesses.
Suggested fix
Use the protocol the provider already offers, on the path that already sends tools:
- Append the assistant turn with its
tool_use blocks (ids intact), exactly as returned.
- Append a
user turn of tool_result blocks, each carrying tool_use_id and the output,
with is_error set on a failed call.
- Teach
normalizeForAnthropic about both so the merge cannot flatten them into prose — its
merge of consecutive same-role turns is what erases the boundary today.
- Keep the Workers-AI fallback path on its current prose shape if that provider needs it, but
branch: the fallback's limitation should not set the protocol for the provider that almost every
chat actually runs on (claude-sonnet-4-6, per CLAUDE.md).
Then a fabricated <tool_response> in the reply text becomes trivially detectable as well —
anything claiming to be a result outside a tool_result block is one the platform did not produce.
Files: workers/api/src/agent-think.ts:826-833, workers/api/src/lib/user-ai.ts:68-94,183-187.
Related: #395 (the fabrication this makes possible), #396 (the same loop, silently truncated).
The platform hands tool results back as the ASSISTANT's own prose
agent-think.ts:831, once a round of tools has run:Two things are wrong with that, and the second one is structural.
1. The model's
tool_useturn is never added to the conversation at all.runAnthropicreadstool_useblocks out of the response (user-ai.ts:186) and converts them to a Workers-AI-shapedtool_callsarray — and nothing puts them back. The next request carries notool_useand notool_result.normalizeForAnthropic(:68) has no concept of either: it maps every role touser/assistantand merges consecutive same-role turns.So the exchange the provider sees is: request with
tools→ response withtool_use→a request in which that never happened, plus an assistant paragraph narrating results.
2. Ground truth is stored in the one role that means "the model's own words". A
tool_resultblock is structurally the platform's. An
assistantmessage is structurally the model's. After themerge in
normalizeForAnthropicthey are one paragraph, and on the next turn nothing distinguishesa real tool output from something the model asserted.
This is the likely cause of #395, not a neighbour of it
In #395 the model wrote its own
<tool_call>/<tool_response>pairs into the reply, inventedtheir contents (a repo remote that contradicted the real one, three GitHub issues from a tool that
never ran), and the platform passed it through as fetched fact.
Look at what this loop teaches it, every single turn: tool results appear in the transcript as
assistant prose that begins "I called tools:". The model reproduced the format it was shown. It
had no structural signal that a result is not something an assistant writes, because in this
conversation it is exactly that.
Fixing #395 by stripping the markup treats the output. This is the input.
A third, smaller loss
Dropping the
tool_useturn also drops the ARGUMENTS. The follow-up context reads[repo_read_file]: <contents>with no path. Call one tool twice in a round with different arguments— two files, two repos, two issue numbers — and the model cannot tell which result belongs to which
call. It has to infer from the content, and when it cannot, it guesses.
Suggested fix
Use the protocol the provider already offers, on the path that already sends
tools:tool_useblocks (ids intact), exactly as returned.userturn oftool_resultblocks, each carryingtool_use_idand the output,with
is_errorset on a failed call.normalizeForAnthropicabout both so the merge cannot flatten them into prose — itsmerge of consecutive same-role turns is what erases the boundary today.
branch: the fallback's limitation should not set the protocol for the provider that almost every
chat actually runs on (
claude-sonnet-4-6, per CLAUDE.md).Then a fabricated
<tool_response>in the reply text becomes trivially detectable as well —anything claiming to be a result outside a
tool_resultblock is one the platform did not produce.Files:
workers/api/src/agent-think.ts:826-833,workers/api/src/lib/user-ai.ts:68-94,183-187.Related: #395 (the fabrication this makes possible), #396 (the same loop, silently truncated).