Skip to content

fix(agent): recover from orphaned tool_use blocks with a one-shot retry - #53236

Closed
fsaad1984 wants to merge 5 commits into
NousResearch:mainfrom
fsaad1984:fix/orphaned-tool-use-recovery
Closed

fix(agent): recover from orphaned tool_use blocks with a one-shot retry#53236
fsaad1984 wants to merge 5 commits into
NousResearch:mainfrom
fsaad1984:fix/orphaned-tool-use-recovery

Conversation

@fsaad1984

Copy link
Copy Markdown

Problem

Anthropic returns HTTP 400 when a tool_use block is not immediately followed by its tool_result. Two root causes exist:

1. Context compression inserts messages between the pair. _strip_orphaned_tool_blocks (PR #52145) already fixes the wire payload but mutates api_messages — a shallow copy of the canonical list. The canonical list is unchanged, so the next API call rebuilds the same broken payload.

2. Interrupted cron/subagent sessions. Concrete reproduction: the approval guard blocks execute_code inside a cron job (no user present). The tool handler normally wraps the result in a tool_result message, but when the gateway reloads the transcript from disk after an interruption, the live history contains a bare tool_use as the last assistant block with no following tool_result. The HTTP 400 crash-loop follows.

Fix

Three-file change:

  • agent/error_classifier.py: new FailoverReason.orphaned_tool_use + detection in _classify_400. The Anthropic error message always contains both tool_use and tool_result, which is distinctive. retryable=True so the retry loop continues.
  • agent/turn_retry_state.py: orphaned_tool_use_retry_attempted flag — fires at most once per turn to prevent an infinite strip-and-retry loop.
  • agent/conversation_loop.py: recovery branch runs _strip_orphaned_tool_blocks against the canonical messages list (not just the wire payload) so the cleaned transcript is persisted and the retry sees a valid conversation.

Testing

  • Verified detection via _classify_400 unit test with the exact Anthropic error message
  • Full guardrails test suite: 40/40 ✅

Anthropic returns HTTP 400 when a tool_use block is not immediately
followed by its tool_result.  Two root causes exist:

1. Context compression inserts messages between the pair.
   _strip_orphaned_tool_blocks (PR NousResearch#52145) already fixes the *wire
   payload*, but it mutates api_messages — a shallow copy of the
   canonical messages list.  The canonical list is unchanged, so the
   *next* API call rebuilds the same broken payload and hits the same
   400 again.

2. A cron/subagent session is interrupted before the tool_result is
   appended.  Concrete reproduction: the approval guard blocks
   execute_code inside a cron job (no user present), the tool handler
   returns an error JSON which the tool_executor normally wraps in a
   tool_result message.  But in this case the gateway reloaded the
   session transcript from disk AFTER the interruption, finding
   disk=0 messages vs memory=37.  The live (correct) history was
   preserved, but a prior interrupted turn had left a bare tool_use as
   the last assistant block with no following user/tool_result turn.
   _strip_orphaned_tool_blocks never ran against the canonical list, so
   the next API call sent the broken transcript verbatim.

Fix — three-file change:

* agent/error_classifier.py: new FailoverReason.orphaned_tool_use +
  detection pattern in _classify_400.  The Anthropic error message
  always contains both 'tool_use' and 'tool_result', which is
  distinctive enough for a safe substring match.  retryable=True so
  the retry loop continues rather than aborting.

* agent/turn_retry_state.py: orphaned_tool_use_retry_attempted flag so
  the recovery branch fires at most once per turn (prevents an infinite
  strip-and-retry loop if stripping somehow fails to fix the issue).

* agent/conversation_loop.py: recovery branch that runs
  _strip_orphaned_tool_blocks against the canonical messages list
  (not just the wire payload) so the cleaned transcript is persisted
  and the retry sees a valid conversation.

Reproduction: long gateway session → tool call → execute_code blocked
by cron approval guard → gateway reload from disk finds stale/empty
transcript → live history preserved but contains orphaned tool_use →
HTTP 400 crash-loop.
@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint provider/anthropic Anthropic native Messages API P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Jun 26, 2026
…nd OpenAI-style canonical messages

The canonical messages list uses OpenAI-style role=tool/tool_calls,
not the Anthropic wire format that _strip_orphaned_tool_blocks expects.
The original fix stripped 0 entries because it passed the wrong list.

Now: (1) detect orphaned IDs from api_messages (Anthropic format),
(2) strip api_messages via _strip_orphaned_tool_blocks, (3) also clean
the canonical messages list by removing orphaned tool_calls entries
and their matching role=tool messages so the next api_messages rebuild
produces a valid transcript.
…ssages

api_messages at error-handler time is pre-conversion; the Anthropic adapter
converts tool_calls→tool_use internally. Detect orphaned IDs from canonical
messages (role=tool / tool_calls) instead of api_messages.
…l messages

The canonical messages pair IS present but adjacency breaks during
Anthropic adapter conversion (context compaction injects synthetic user
messages). Parse the IDs directly from the Anthropic 400 error string.

Also fix: used 'classified_err' (undefined) instead of 'api_error'.
@teknium1

Copy link
Copy Markdown
Contributor

Thanks for tracing the Anthropic adjacency failure and documenting the interrupted-session case.

Automated hermes-sweeper review found this behavior already implemented on current main:

  • agent/anthropic_adapter.py:2118 strips every tool_use whose matching tool_result is not in the immediately following user message; the conversion path invokes it at agent/anthropic_adapter.py:2448.
  • agent/agent_runtime_helpers.py:2592 also supplies a synthetic adjacent result for canonical assistant tool calls that have no result before each API request.
  • Commit 5881791adc596b9f6093c506bcfe99e3a53a5890 (fix(adapter): enforce tool_use/tool_result adjacency in _strip_orphaned_tool_blocks) is on main, is co-authored by @fsaad1984, and shipped in v2026.7.1. Existing coverage includes the late/non-adjacent case in tests/agent/test_anthropic_adapter.py:1013.

Closing as implemented on main.

@teknium1 teknium1 closed this Jul 15, 2026
@teknium1 teknium1 added the sweeper:implemented-on-main Sweeper: behavior already present on current main label Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists provider/anthropic Anthropic native Messages API sweeper:implemented-on-main Sweeper: behavior already present on current main sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants