diff --git a/openhands-sdk/openhands/sdk/context/view/properties/tool_call_matching.py b/openhands-sdk/openhands/sdk/context/view/properties/tool_call_matching.py index 5d1daf2da0..453f457ffc 100644 --- a/openhands-sdk/openhands/sdk/context/view/properties/tool_call_matching.py +++ b/openhands-sdk/openhands/sdk/context/view/properties/tool_call_matching.py @@ -77,13 +77,17 @@ def manipulation_indices( # tool calls -- these are any tool calls that have been introduced by an action # but not yet resolved by an observation. If there are any pending tool calls we # know we're between an action/observation pair. + action_tool_call_ids: set[ToolCallID] = set() pending_tool_call_ids: set[ToolCallID] = set() for index, event in enumerate(current_view_events): match event: case ActionEvent(): + action_tool_call_ids.add(event.tool_call_id) pending_tool_call_ids.add(event.tool_call_id) case ObservationBaseEvent(): + if event.tool_call_id not in action_tool_call_ids: + continue # Intentionally use remove(), not discard(): a second # observation-like event for the same tool_call_id means the # view has already violated the 1 action -> 1 result diff --git a/tests/sdk/context/view/properties/test_tool_call_matching.py b/tests/sdk/context/view/properties/test_tool_call_matching.py index 831a55f2e4..6716ca663a 100644 --- a/tests/sdk/context/view/properties/test_tool_call_matching.py +++ b/tests/sdk/context/view/properties/test_tool_call_matching.py @@ -6,6 +6,8 @@ from unittest.mock import create_autospec +import pytest + from openhands.sdk.context.view.manipulation_indices import ManipulationIndices from openhands.sdk.context.view.properties.tool_call_matching import ( ToolCallMatchingProperty, @@ -429,3 +431,34 @@ def test_empty_events(self) -> None: result = self.property.manipulation_indices(events) assert result == ManipulationIndices.complete(events) + + def test_orphan_observation_is_ignored(self) -> None: + """Test that an observation without a matching action is tolerated.""" + orphan = AgentErrorEvent( + error="The tool was interrupted during restart recovery.", + tool_name="test_tool", + tool_call_id="orphan_call", + ) + events: list[LLMConvertibleEvent] = [orphan] + + assert self.property.manipulation_indices( + events + ) == ManipulationIndices.complete(events) + + def test_duplicate_observation_still_raises(self) -> None: + """Test that duplicate observations remain a strict pairing violation.""" + action = create_autospec(ActionEvent, instance=True) + action.tool_call_id = "call_1" + action.id = "action_1" + action.llm_response_id = "response_1" + + observation = create_autospec(ObservationEvent, instance=True) + observation.tool_call_id = "call_1" + observation.id = "obs_1" + + duplicate = create_autospec(ObservationEvent, instance=True) + duplicate.tool_call_id = "call_1" + duplicate.id = "obs_2" + + with pytest.raises(KeyError): + self.property.manipulation_indices([action, observation, duplicate]) diff --git a/tests/sdk/context/view/test_view_manipulation_indices.py b/tests/sdk/context/view/test_view_manipulation_indices.py index d58b89d065..95067eddd5 100644 --- a/tests/sdk/context/view/test_view_manipulation_indices.py +++ b/tests/sdk/context/view/test_view_manipulation_indices.py @@ -6,6 +6,7 @@ """ from openhands.sdk.context.view import View +from openhands.sdk.event.llm_convertible import AgentErrorEvent from openhands.sdk.llm import ( ThinkingBlock, ) @@ -22,6 +23,17 @@ def test_empty_list() -> None: assert view.manipulation_indices == {0} +def test_orphan_observation_does_not_break_view_indices() -> None: + orphan = AgentErrorEvent( + error="The tool was interrupted during restart recovery.", + tool_name="test_tool", + tool_call_id="orphan_call", + ) + view = View(events=[orphan]) + + assert view.manipulation_indices == {0, 1} + + def test_single_message_event() -> None: """Test manipulation_indices with a single message event.""" events = [message_event("Event 0")]