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 answer → memory_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
Summary
#3013removed 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
#3013does not catch: the model emits a well-formed tool call, and the leaked fragment is inside theanswerargument value.Since
#3013also 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:ReflectToolCallErroris raised only underif not saw_tool_call(reflect/agent.py, ~L919). In this failure modesaw_tool_callisTrue— the provider returns a validtool_callsentry fordone.answer = args.get("answer", "").strip()(~L1268).grep -c '_clean_done_answer|</answer>'onmainreturns0, so nothing downstream inspects the value.Net: a valid tool call whose
answerstring ends in tool-call syntax passes every check.Observed shape
The
answerargument value ends with a fragment resembling XML tool-call syntax (UUIDs redacted):Note this is inside the JSON string value of
answer, alongside a correctly populatedmemory_idsarray 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 intostructured_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-5reached through a LiteLLM proxy (not Vertex/gpt-oss):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:
A
saw_tool_callcheck cannot distinguish them, so closing this needs either validation of theanswervalue itself, or removal of the boundary the model is crossing.One possible direction
The leak occurs at the
answer→memory_idsparameter 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
donetool, 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 (adoneschema built with directives still carries a requireddirective_complianceafteranswer). Raising it as an option, not a recommendation.Happy to supply redacted trace payloads or test cases if useful.
Related