Skip to content

[bug] Tool results are fed back as the assistant's own prose and the tool_use turn is dropped — the format the model then imitated in #395 #398

Description

@serge-ivo

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 toolsresponse 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:

  1. Append the assistant turn with its tool_use blocks (ids intact), exactly as returned.
  2. Append a user turn of tool_result blocks, each carrying tool_use_id and the output,
    with is_error set on a failed call.
  3. 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.
  4. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions