fix(agents): fail a generation whose answer is a tool call written as text - #870
Merged
Conversation
… text
A model that writes its tool invocation out as assistant text instead of
making one produced a generation indistinguishable from a real answer:
`finishReason: "stop"`, one step, no tool-call part, `status: completed`,
`error: null` — and the blob as `output.content`, with the tool never run.
```json
{"name": "get-fundamental-truth", "arguments": {}}
```
Reported on grok-4.5 / xai.responses at roughly 1 in 10 runs. It is a silent
data-integrity failure, not a visible one: on a board the card advanced, the
value was approved by the next column, published, indexed, and handed to the
next agent, which had nothing to work from and invented an unrelated subject.
Every column reported success.
The structured path already handles the same misbehavior correctly — an
`output_schema` violation fails loudly with OUTPUT_SCHEMA_VALIDATION_FAILED
(#867). This closes the schema-less path the same way.
- `findTextEncodedToolCall` reports a final assistant text that is entirely a
tool-call blob. Deliberately narrow, because a false positive fails a
generation that was fine: the text (fence stripped) must be exactly one JSON
object or an array of them, every key must be tool-call vocabulary, and the
name must be a tool bound to that agent. Prose around the JSON, one foreign
key, an unbound name, or an agent with no tools — all left alone.
- All three completion paths consult it: the initial turn (in
`buildCompletedGenerationResult`, after the trace is written so the offending
text survives, before the record is marked completed), the tool-outputs
continuation (via a new `recordContinuationFailure`, since that path has no
`try`/`catch` above it and would otherwise strand the generation in
`requires_action`), and a stream's `onEnd`, where the text cannot be recalled
but the generation and trace are still recorded `failed`.
- New `TEXT_ENCODED_TOOL_CALL` (502), `meta.tool_name` naming the tool.
- Agents with an `output_schema` are exempt — that path already validates the
model output, and its `content` is the serialized object.
`content` is unchanged: ai@7's `result.text` is already `steps.at(-1).text`,
so it never comes from an earlier step. `finalStepText` spells the same rule
out for the stream path, which only gets the step array.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mug6x5hctSkTRP6SPktU5q
Deploy Outputs
|
`Server Coverage` failed the global 100% function threshold: the stream
branch's failure recorder had no test, and `recordContinuationFailure`'s
`.catch()` swallow handler was an uncovered anonymous function.
- Two tests on the existing `isolateModules` onEnd harness: a stream ending on
a tool-call blob records `failed` on the generation and the trace, and an
ordinary streamed answer still completes. `recordTraceError` joins the
harness's `src/lib/traces` mock so the isolated module can resolve it.
- `recordContinuationFailure` settles its trace write with `Promise.allSettled`
instead of `.catch(() => {})` — the same idiom `recordGenerationFailure`
directly above already uses, with the same swallow semantics and no
anonymous handler to leave uncovered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mug6x5hctSkTRP6SPktU5q
This was referenced Aug 7, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #869.
A model that writes its tool invocation out as assistant text instead of making one produced a generation indistinguishable from a real answer —
finishReason: "stop", one step, no tool-call part,status: completed,error: null— with the blob asoutput.contentand the tool never run:Reported on grok-4.5 /
xai.responsesat roughly 1 in 10 runs. It is a silent data-integrity failure: on a board the card advanced, the value was approved by the next column, published, indexed, and handed to the next agent — which had nothing to work from and invented an unrelated subject. Every column reported success.The structured path already handles the same misbehavior correctly: an
output_schemaviolation fails loudly withOUTPUT_SCHEMA_VALIDATION_FAILED(#867). This closes the schema-less path the same way.What changed
textEncodedToolCall.ts—findTextEncodedToolCallreports a final assistant text that is entirely a tool-call blob. Deliberately narrow, because a false positive fails a generation that was fine. All three must hold: the text (after a wrapping markdown fence is stripped) is exactly one JSON object or an array of them; every key is tool-call vocabulary (name/tool/tool_name/function,arguments/args/parameters/input,id,type); and the name is a tool bound to that agent. Prose around the JSON, one key outside the vocabulary, an unbound tool name, or an agent with no tools — all left alone.buildCompletedGenerationResult— after the trace is written, so the offending text survives for post-mortem, and before the record is markedcompleted;recordContinuationFailureingenerationLifecycle(that path has notry/catchabove it, so a bare throw would strand the generation inrequires_actionwith nothing recorded);onEnd— the text has already gone down the wire and cannot be recalled, but the generation and its trace are still recordedfailed.TEXT_ENCODED_TOOL_CALL(502), upstream-caused likeAI_PROVIDER_ERROR, withmeta.tool_namenaming the tool alongside the usualgeneration_id/trace_id.output_schemaare exempt — that path validates the model output itself and already fails loudly, and itscontentis the serialized object.502descriptions on both generation endpoints inagents.yaml.contentresolution is unchanged, deliberately: ai@7'sresult.textissteps.at(-1).text, so it already comes from the final step and never from an earlier one.finalStepTextspells the same rule out for the stream path, which only receives the step array. The issue also suggested dropping a fallback totoolName— there is no such fallback anywhere insrc, so there was nothing to remove.Tests
Red/green throughout — the entry-point tests were confirmed failing against the unfixed path before the guard was wired in (3 detection tests red, both "leave it alone" tests green).
tests/unit/tests/lib/textEncodedToolCall.test.ts— the detector directly, over every improvised shape and every ordinary answer that must survive untouched (keep-list: pure function, large input space).tests/unit/tests/rest/agentGeneration.test.ts— the wiring at the entry point, against the local OpenAI-compatible stub: the blob fails with 502 and the generation is recordedfailed; the trace keeps the offending step; an ordinary answer from the same tool-bound agent still completes; an agent with no tools is left alone; anoutput_schemaagent is left to the schema validator; the tool-outputs continuation fails on it too.pnpm typecheck,pnpm eslint --fix, anddocs-lintpass. Full server suite: 176 suites, 4895 tests, all passing. New module coverage: 100% statements / 96% branches.Smoke tests were not extended: reproducing this needs a provider that misbehaves on demand, which the Ollama stack cannot do without adding a new capability to
ollamaToolChoiceProxy.mjs. They were also not runnable here (no Docker daemon in this environment).Open questions
🤖 Generated with Claude Code
https://claude.ai/code/session_01Mug6x5hctSkTRP6SPktU5q
Generated by Claude Code