Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions tests/sdk/context/view/properties/test_tool_call_matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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])
12 changes: 12 additions & 0 deletions tests/sdk/context/view/test_view_manipulation_indices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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")]
Expand Down