The transcript promised a tool call that does not exist


A coding agent thinks for a while, says nothing useful, and stops. Retrying looks like the obvious response. The transcript can tell a stranger story: the assistant message ended with stop_reason: "tool_use", but its content contains no tool_use block.

No shell command failed. The recorded message has no command to inspect.

Updated August 9: version 0.2.0 also detects a second persisted failure: the tool-use block exists, but its input is {} and a later result explicitly records an error for that call.

A reporter in anthropics/claude-code#64235 found this shape after Claude Code began reporting malformed tool calls. Their earlier transcripts contained 32,173 assistant turns ending in tool_use and no parse failures through May 28. They counted 57 failures on May 29 and another 15 on May 31.

A second person scanned roughly 30 days of local transcripts and reported 59 malformed or dropped calls across 20 sessions and 11 Claude Code versions. They classified 39 as the contradiction above and 16 as tool-call-shaped XML in an ordinary text block after a stray word such as call, count, or court. The comment labels the total as 59, but those two categories account for 55; four incidents are not categorized in the posted breakdown.

The UI can make both modes look like an agent that simply forgot to act. The saved evidence says otherwise.

Two failures, not one error string

The missing-block case looks like this after removing payloads:

{
  "type": "assistant",
  "message": {
    "id": "...",
    "stop_reason": "tool_use",
    "content": [{"type": "thinking", "signature": "..."}]
  }
}

The message declares a transition into tool use. The structured content needed for that transition is absent.

The sibling failure in #67307 is different. A tool call appears as text with broken markup, so the harness never executes it. The author of a retry hook deliberately handles that text-leak mode but excludes the missing-block mode. The former contains an unexecuted call in text; the latter leaves less evidence about what happened around the failed turn.

An automatic repair has to preserve this distinction. Treating the shared “could not be parsed” error as the state throws both failures back into one bucket. A retry loop then acts on an error label instead of the transcript it is supposed to recover.

I wrote the detector first. It identifies the contradiction without editing the session or guessing at recovery.

A tool-use block can survive without its arguments

A later Claude Code report preserved a different failure. The structured tool_use block was present, complete with a call ID and tool name, but input was {}. A following tool_result referred to the same call and recorded is_error: true because required arguments were missing. The run repeated the empty call until it reached its turn limit.

The reporter reproduced the failure in normal and stream-json modes and bisected the regression from version 2.1.39 to 2.1.40. They found it on every tested release through 2.1.215. Their inspection points to streamed argument accumulation, but no maintainer has confirmed that cause. What the public transcript proves is narrower: the persisted call had an empty object, and the matching result says it failed.

An empty object alone is not enough evidence for a schema-blind checker. Some tools take no arguments and legitimately use {}. Version 0.2.0 therefore requires both sides in the same file: a tool call with a nonempty ID and exactly {} as its input, followed later by a result for that ID with an explicit error flag. A successful result, no result, or a nonempty input does not trigger this detector.

That correlation reports empty_tool_input_with_error_result. It does not print the tool name, call ID, arguments, error text, or any transcript body. The IDs remain in memory during the file scan to reject duplicates and match rows, but are never included in output.

A JSONL row is not always a message

I built tool-call-transcript-check for these two persisted shapes. It does not try to recognize every malformed call.

A short jq expression is tempting, and it works for one known transcript layout. The problem is the unit of analysis. Claude Code can store blocks from one assistant API message on separate JSONL rows with the same message.id. A thinking row may look broken by itself even when a later row contains the promised tool call.

The checker groups assistant rows by file and message ID before testing the invariant. This synthetic pair is clean:

{"type":"assistant","message":{"id":"m1","stop_reason":"tool_use","content":[{"type":"thinking"}]}}
{"type":"assistant","message":{"id":"m1","stop_reason":"tool_use","content":[{"type":"tool_use"}]}}

A message becomes a finding only when its grouped rows contain stop_reason: "tool_use" and no structured tool_use block anywhere in that group.

The narrow rule avoids pretending to diagnose the cause. It does not classify malformed XML, infer whether a side effect happened, or decide that signed thinking caused the bug. The public reports show correlations with extended thinking and some Opus versions, but a transcript shape is not proof of a root cause.

Evidence without copying the conversation

Agent transcripts can contain source code, credentials, tool arguments, private paths, and the assistant’s thinking text. A scanner that prints the offending row would turn diagnosis into a data leak.

The report leaves those payloads out. A finding contains the input-relative filename, model when it matches a conservative identifier pattern, block-type names, exact source-line membership, signed-thinking presence, and a SHA-256 digest over those source lines. The digest lets someone confirm that two reports refer to the same bytes without publishing them.

This is the installed release running against its synthetic missing-block fixture:

{"findings":[{"block_types":["thinking"],"code":"missing_tool_use_block","evidence_sha256":"e2ba717ebaab0d2fce6b720da9229db809a491791b389e4568f4462c91eb569b","file":"thinking_only.jsonl","lines":[1,1],"model":"claude-public-test","signed_thinking":true,"source_lines":[1]}],"summary":{"assistant_messages_checked":1,"files_scanned":1,"findings":1}}

The synthetic empty-input fixture produces the second code without copying either payload:

{"findings":[{"block_types":["tool_use"],"code":"empty_tool_input_with_error_result","evidence_sha256":"891b110e2aafe7a7fe6ea87ee36d246c85c3527ef7be00be9af319fb48848f6e","file":"empty_input_error.jsonl","lines":[1,2],"model":"claude-public-test","signed_thinking":false,"source_lines":[1,2]}],"summary":{"assistant_messages_checked":1,"files_scanned":1,"findings":1}}

The tool does not scan ~/.claude by default. It requires an explicit file or directory, does not follow transcript symlinks, and labels multiple input roots without printing their parent paths. It also fails closed on malformed JSON, duplicate keys, invalid relevant structures, unreadable data, and non-UTF-8 input. A clean result includes the number of files and grouped assistant messages so an empty search cannot pass as a known scan.

The metadata is still metadata. Filenames, line positions, model identifiers, and evidence digests may be sensitive in some environments. The README says to inspect the report before sharing it. Payload-free output can still be sensitive.

Why it does not repair anything

The public issue includes a script that trims a poisoned transcript tail and resumes from an earlier point. Its author labels it a stopgap and warns that it edits an unsupported internal file. That may be a reasonable recovery for a person who has inspected one stuck session. It is too much authority for this checker.

The missing block establishes one fact: persisted history says a tool call should exist where no structured call was saved. The empty-input pair establishes another: a saved call with no arguments was followed by an explicit error for that call. Neither establishes whether the model failed to emit data, an API response lost it, the client assembled or serialized it incorrectly, or another action around the turn already changed external state.

The checker stops at evidence. It never modifies a transcript, retries a model call, or replays a tool. Recovery belongs in a layer that can account for side effects and choose a known clean boundary. If that layer cannot tell what executed, it should stop rather than manufacture certainty from a contradictory log.

I published version 0.2.0 as a Python 3.9+ wheel and source archive. A fresh local run passed all 42 tests, Ruff lint and format checks, and strict mypy. The release commit also passed four GitHub Actions jobs covering Linux and macOS on Python 3.9 and 3.13. I installed the downloaded wheel in a clean environment and ran the public empty-input fixture: it exited 1 with the payload-free finding shown above. A local exact-commit Claude review returned no findings, and its local receipt validates against released commit 62e3188.

This is a diagnostic release, not evidence that anybody else has used it or that either Claude Code failure is fixed. It gives two contradictory transcript states distinct names and reproducible reports. Recovery is still an upstream problem.

Comments