Deterministic 400 invalid_request_error from an orphaned tool_use_id is classified retryable and never repaired: session re-sends the identical rejected request for 6+ hours #1534
etafund
started this conversation in
Bug reports
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Summary
A single orphaned
tool_use_idin a session's message history makes every subsequent Anthropic request fail with a deterministic HTTP 400invalid_request_error, and prime-agent classifies that 400 as retryable on the first failure and never repairs the offendingtool_use/tool_resultpairing. The result observed in the field: the identical rejected payload was re-sent 28 times across 14 turn attempts over 6 h 18 m, and the session only stopped burning requests when its worker was killed externally.Environment
0.7.1at time of incident;0.7.2currently installedv22.22.1anthropic-messagesclaude-fable-5,claude-opus-5(two independent sessions, same day)retryoverride in~/.prime/agent/settings.json)The retry classifier is textually identical in the
0.7.1and0.7.2shipped bundles (_isRetryableError,_isStructuredPermanentProviderFailureand_isStructuredPermanentProviderRetryExhaustedall match), so this is not fixed in0.7.2.Observed behavior
Exact API error
Verbatim provider response body, as captured in the
causefield of theai.provider/provider stream failurerecord in~/.prime/agent/logs/agent.jsonl:Verbatim prime-agent log record (one of 28;
requestIdredacted,pid/modedropped):{"provider":"anthropic","model":"claude-fable-5","api":"anthropic-messages","kind":"invalid_request","providerErrorType":"invalid_request_error","status":400,"requestId":"req_<redacted>","message":"Provider rejected the request (invalid_request_error, 400): messages.464.content.2: unexpected `tool_use_id` found in `tool_result` blocks: toolu_01JEKX4JYYoWzqYmRQ4oJLjd. Each `tool_result` block must have a corresponding `tool_use` block in the previous message. [request_id: req_<redacted>]","ts":"2026-08-07T19:17:24.566Z","level":"error","component":"ai.provider","msg":"provider stream failure"}Every one of the 28 records carries a distinct
request_id, meaning these are 28 real HTTP round trips, not 28 log lines for one request. All 28 name the same offsetmessages.464.content.2and the sametoolu_01JEKX4JYYoWzqYmRQ4oJLjd— the payload never changed between attempts.Timeline
Each turn attempt = 1 initial request + 1 auto-retry ~2.4 s later, then the turn ends in error. Nothing repairs state, so the next wakeup of the session repeats the pair.
2026-08-07T19:17:24.566Z→2026-08-08T01:35:57.879Z= 6 h 18 m 33 s.SIGTERMat2026-08-08T02:08:02Z.claude-opus-5with a different orphan (toolu_01RR3tGsdrhhMWAqtLLYMPWHatmessages.4.content.2), 4 rejected requests across 2 turn attempts — so this is not specific to one long session or one model lane.Expected behavior
400 invalid_request_erroris deterministic and non-retryable. Re-sending an identical body must not happen; the retry should be suppressed entirely, not merely capped on the second failure.tool_use_id-pairing 400 identifies a self-inflicted, locally repairable defect in the outgoing message list. prime-agent should repair the conversation state (drop thetool_resultblock(s) with no survivingtool_use, or synthesize the missingtool_use) and retry once with the corrected payload.Root cause hypothesis
Two independent defects compound.
(a) The retry classifier does not consider HTTP status class.
_isRetryableError(message)returnstruefor any assistant message withstopReason === "error"and a non-emptyerrorMessage, except for four carve-outs: context overflow, thefauxprovider's queue exhaustion, anagent_lifecycle_failurediagnostic, and_isStructuredPermanentProviderRetryExhausted(message). There is no check onstatus, no 4xx-vs-5xx distinction._isStructuredPermanentProviderFailuredoes already recognisekind === "invalid_request"(alongside"auth"and"refusal") as permanent — the classification exists and is correct. But the only consumer inside_isRetryableErroris_isStructuredPermanentProviderRetryExhausted, which gates it behindthis._retryAttempt > 0. So the first occurrence of a known-permanent failure is unconditionally treated as transient. Net effect: exactly one wasted retry of a deterministic 400 per turn — visible as the 2.4 s pairs above.(b) There is no conversation-state repair path.
_handleRetryableErrorprepares for the retry by dropping the trailing assistant message:It never inspects
tool_use/tool_resultpairing, so the rebuilt request is byte-identical in the part the API rejected. Nothing else in the turn lifecycle repairs it either, and_retryAttemptresets between turns, so the permanent-failure knowledge from (a) is discarded — every later wakeup starts the same 2-request cycle from scratch. The session is wedged for as long as it exists.Likely producer of the orphan, and the feedback loop. #984 (closed
not_planned) describestransformMessagesdropping assistant messages withstopReasonerror/abortedwhile leaving theirtoolResultmessages in place, which orphans exactly this way. That closes a loop: an errored turn orphans its tool results → every subsequent request is a deterministic 400 → those turns also end instopReason: "error"→ the orphan is never cleaned up. A single transient failure mid-tool-call permanently poisons the session. Fixing #984 removes one producer; the retry/repair defects here are what turn a one-off orphan into an unbounded, unattended request loop, so they are worth fixing independently.Reproduction sketch
Deterministic, no flaky timing needed.
tool_resultblock whosetool_use_iddoes not match anytool_useblock in the preceding assistant message. Either:toolResult.toolCallIdto a nonexistent id (e.g.toolu_00000000000000000000000000); or~/.prime/agent/logs/agent.jsonl: twocomponent: "ai.provider",msg: "provider stream failure"records withstatus: 400,kind: "invalid_request", distinctrequestIds, ~2.4 s apart, both naming the sametool_use_id.Suggested fix
_isRetryableError, returnfalsefor_isStructuredPermanentProviderFailure(message)regardless of_retryAttempt— that is, use_isStructuredPermanentProviderFailuredirectly, not the_retryAttempt > 0-gated_isStructuredPermanentProviderRetryExhausted. Keep a status-class guard as a backstop: any provider stream failure withstatusin400..499other than408/429— and other than401/403, which are deliberately retried once via the existing auth-refresh path — is non-retryable.tool_use_id-mismatch 400): scan the outgoing message list and drop everytool_resultblock whosetool_use_idhas no matchingtool_usein the preceding assistant message; symmetrically, synthesize atool_resultfor anytool_usewith no result. Repair once, resend once, and only then fail.Acceptance criteria
kind: "invalid_request"/status: 400produces exactly one HTTP request._isRetryableErrorreturnsfalseon the first such failure; noauto_retry_startis emitted for it.tool_use_id/tool_resultpairing error triggers a state-repair pass; the retried request contains notool_resultblock without a matchingtool_use, and the session makes forward progress.2 × cyclesas observed here._isRetryableErrorreturnsfalsefor a first-occurrence message carrying aprovider_stream_failurediagnostic withkind: "invalid_request", and forstatus400/404/422; stilltruefor 408, 429, 500, 502, 503, 529; the existing 401/403 auth-refresh retry path is unchanged.toolResultwith an unmatchedtoolCallIdis normalized to a list the Anthropic Messages API accepts.Related: #984 (
transformMessagesorphans tool results when the parent assistant turn was aborted or errored) — closednot_planned; that path is a plausible producer of the malformed state reported here.Addendum (further investigation)
Follow-up reading of the current
mainsources refines the three points below. All references are to functions in repo-relative TypeScript sources.1. The classification is already correct; the gate in front of it is not.
classifyStreamFailureinpackages/ai/src/utils/stream-failure.tsmaps HTTP400to the kind"invalid_request", so a rejected request reaches the session already labelled structurally permanent._isStructuredPermanentProviderFailureinpackages/coding-agent/src/core/agent-session.tshonours that label for"auth","invalid_request"and"refusal". The defect is the gate:_isRetryableErrorconsults that predicate only indirectly, through_isStructuredPermanentProviderRetryExhausted, which additionally requires_retryAttempt > 0. On the first failure of a dispatch the retry counter is zero, so the permanence signal is discarded and a structurally-permanent failure is always retried exactly once per dispatch. That is bounded within a single turn, but it is not bounded across the lifetime of a wedged session: every scheduled re-dispatch starts from_retryAttempt === 0again, so the same one-wasted-retry pattern repeats, and the total compounds into dozens of wasted, guaranteed-to-fail API calls for a payload whose rejection is already known to be deterministic.2.
_isRetryableErroris default-allow. Its final statement returnstrue; permanence has to be proven by one of a small set of carve-outs (context overflow, thefauxprovider's queue exhaustion, anagent_lifecycle_failurediagnostic, and the retry-exhausted check above). Nothing in that set covers the kind"unknown", whichclassifyStreamFailureassigns whenever a provider error type and status cannot be matched to a known class, so every"unknown"failure falls through to retryable unconditionally. That is not a rare residual bucket: in one deployment's failure records,"unknown"was by far the largest single class of retried errors. Inverting the default (retry only what is positively known to be transient) would also cover the case above as a side effect.3. The resend path still performs no pairing repair.
_handleRetryableErrorinpackages/coding-agent/src/core/agent-session.tsprepares the retry by dropping the trailing assistant message and nothing else. It never inspectstool_use/tool_resultpairing before the re-send, so the part of the payload the API actually rejected is reconstructed unchanged and a single malformedtool_use_idwedges the session permanently. This is the same shape addressed by PR #991 (fix(ai): drop tool results whose parent tool call was aborted), which was closed without merging. It also complements issue #1384 ("Bound transcript repair, compaction, and autonomous recovery"): that issue scopes the state-repair half, repairing interrupted tool-result ordering, whereas the retry-classifier half described in points 1 and 2 appears to be tracked nowhere. Repair alone would leave permanent failures being retried; a corrected classifier alone would leave the wedged transcript unrepaired. Both halves are needed for a wedged session to either recover or fail terminally.Provenance: this report was first filed as issue #1490, which the intake automation closed as
not_plannedtwelve seconds after submission under the vouched-reporter rule, before any human review. It is re-submitted here because Discussions is the intake channel the project documents for reports from unvouched reporters.🤖 Generated with Claude Code
All reactions