Skip to content

reflect: leaked done-tool tail still persists when the model DOES emit a valid tool call (#3013 guard does not fire) #3048

Description

@gvilleg

Summary

#3013 removed the salvage logic and made reflect fail loudly when a transport can't produce a usable tool call. That correctly covers the Vertex AI gpt-oss case in the PR description.

There is a second, structurally different path that produces the same persisted corruption and which #3013 does not catch: the model emits a well-formed tool call, and the leaked fragment is inside the answer argument value.

Since #3013 also removed the last sanitisation step, that fragment is now written to mental model content verbatim.

Why the new guard doesn't fire

On current main:

  • ReflectToolCallError is raised only under if not saw_tool_call (reflect/agent.py, ~L919). In this failure mode saw_tool_call is True — the provider returns a valid tool_calls entry for done.
  • The answer is then taken as-is: answer = args.get("answer", "").strip() (~L1268).
  • grep -c '_clean_done_answer|</answer>' on main returns 0, so nothing downstream inspects the value.

Net: a valid tool call whose answer string ends in tool-call syntax passes every check.

Observed shape

The answer argument value ends with a fragment resembling XML tool-call syntax (UUIDs redacted):

...ordinary prose ending a sentence.</answer>
<parameter name="memory_ids">["<uuid>", "<uuid>", ...]

Note this is inside the JSON string value of answer, alongside a correctly populated memory_ids array in the same tool call. The model appears to blend two tool-call serialisations mid-generation rather than failing to tool-call.

The result is persisted into mental model content, and re-parsed into structured_content, so a single occurrence corrupts both surfaces.

Frequency and provider

Observed via LLM observability traces on a self-hosted deployment, provider anthropic/claude-haiku-4-5 reached through a LiteLLM proxy (not Vertex/gpt-oss):

  • 2026-07-22: 2 occurrences out of ~172 generations, both persisted, each corrupting one mental model
  • 2026-07-27: 3 occurrences with this exact shape out of ~830 generations (plus 2 rows with a different, partial JSON shape)

Low rate, but non-zero and recurring across weeks. Two mental models were corrupted with 133 and 49 leaked IDs respectively before it was noticed — the failure is silent, since the refresh reports success.

Why this is worth separating from #3013

The two failure modes need different remedies:

#3013's case this case
tool call produced no yes, well-formed
detectable at transport yes no
current behaviour raises persists silently

A saw_tool_call check cannot distinguish them, so closing this needs either validation of the answer value itself, or removal of the boundary the model is crossing.

One possible direction

The leak occurs at the answermemory_ids parameter boundary. The server already accumulates every retrieved ID during the loop and validates the model's returned IDs against that set, discarding anything unrecognised — so the model's contribution is a subset selection over data the backend already holds.

If those ID parameters were dropped from the done tool, this specific boundary would not exist. That is a design tradeoff rather than an obvious win: it costs model-attributed provenance, and it does not remove other sibling boundaries (a done schema built with directives still carries a required directive_compliance after answer). Raising it as an option, not a recommendation.

Happy to supply redacted trace payloads or test cases if useful.

Related

Metadata

Metadata

Assignees

No one assigned

    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