Skip to content

fix: deduplicate observations with same tool_call_id#2114

Draft
xingyaoww wants to merge 1 commit intomainfrom
fix/deduplicate-tool-result-observations
Draft

fix: deduplicate observations with same tool_call_id#2114
xingyaoww wants to merge 1 commit intomainfrom
fix/deduplicate-tool-result-observations

Conversation

@xingyaoww
Copy link
Collaborator

@xingyaoww xingyaoww commented Feb 18, 2026

Summary

Fixes Anthropic API error: "each tool_use must have a single result. Found multiple tool_result blocks with id: <tool_call_id>"

Root Cause Analysis

Investigation of trajectory fcbbaec269bc4903ad4027c3bef0bc57 revealed a complex interaction:

What Happened

  1. The agent ran a terminal command that started a nested agent-server (running examples/02_remote_agent_server/01_convo_with_local_agent_server.py)
  2. The nested server shared the same persistence directory as the outer conversation
  3. When the nested server started, it resumed the same conversation and found:
    • execution_status = RUNNING
    • An unmatched action (the terminal command that WAS STILL running in the outer context)
  4. The nested server created an AgentErrorEvent for the "interrupted" action
  5. The terminal command completed normally, creating an ObservationEvent
  6. Both events had the same tool_call_id

Timeline from Events

09:48:05.911 - Event 61: ActionEvent (terminal command starts)
09:48:16.554 - Event 62: Nested server resumes, sets status=ERROR
09:48:16.588 - Event 63: AgentErrorEvent (nested server thinks tool crashed)
09:48:18.847 - Event 65: ObservationEvent (terminal command completes)

The log output in Event 65 shows:

"Resumed conversation fcbbaec2-69bc-4903-ad40-27c3bef0bc57 from persistent storage"

This confirms the nested server resumed the same conversation that was actively running.

Why This Causes the API Error

When the events are converted to LLM messages via events_to_messages(), both AgentErrorEvent and ObservationEvent get converted to tool_result blocks with the same ID. The Anthropic API rejects this:

"each tool_use must have a single result. Found multiple tool_result blocks with id: toolu_01EvREYhc5WD2xswPAvEc8ir"

Solution

Added a _deduplicate_observations() method to the View class that:

  1. Identifies the best observation for each tool_call_id based on priority:
    • ObservationEvent (highest - contains actual tool result)
    • UserRejectObservation (user action)
    • AgentErrorEvent (lowest - error notification)
  2. Emits only one observation per tool_call_id, preserving event order

This is a defensive fix at the View level. The event_service cannot prevent this because:

  • At the time of creating the AgentErrorEvent, the observation hasn't arrived yet (it comes ~2 seconds later)
  • The nested server has no way to know the original tool is still running

Alternative Considerations

Other potential fixes (not implemented, for future consideration):

  • Documentation: Warn that nested servers should use different persistence directories
  • Architecture: Use process-level locking to prevent multiple servers from writing to the same conversation
  • State: Include AgentErrorEvent in get_unmatched_actions() to prevent duplicate error events

Testing

  • Added 6 new tests in test_view_duplicate_observations.py
  • All 87 existing view tests continue to pass
  • Pre-commit hooks pass

Fixes Anthropic API error: "each tool_use must have a single result.
Found multiple tool_result blocks with id: <tool_call_id>"

Root cause: When a server restart occurs while a tool is in progress,
the EventService creates an AgentErrorEvent for unmatched actions.
If the tool then completes, an ObservationEvent is also recorded.
Both events share the same tool_call_id, causing duplicate tool_result
blocks when the events are converted to LLM messages.

The fix adds a _deduplicate_observations() method to View that:
1. Identifies the best observation for each tool_call_id based on priority
   (ObservationEvent > UserRejectObservation > AgentErrorEvent)
2. Emits only one observation per tool_call_id, preserving event order

This ensures that when both an error event and a successful observation
exist for the same tool call (e.g., after a restart), only the most
informative one (typically the ObservationEvent with actual results)
is sent to the LLM.

Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions
Copy link
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-sdk/openhands/sdk/context
   view.py222597%227, 263, 416, 419, 514
TOTAL18256556069% 

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

Comments