Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@
from openhands.sdk.context.view.properties.tool_loop_atomicity import (
ToolLoopAtomicityProperty,
)
from openhands.sdk.context.view.properties.tool_result_uniqueness import (
ToolResultUniquenessProperty,
)


ALL_PROPERTIES: list[ViewPropertyBase] = [
BatchAtomicityProperty(),
ToolCallMatchingProperty(),
ToolLoopAtomicityProperty(),
ToolResultUniquenessProperty(),
]
"""A list of all existing properties."""

Expand All @@ -20,5 +24,6 @@
"BatchAtomicityProperty",
"ToolCallMatchingProperty",
"ToolLoopAtomicityProperty",
"ToolResultUniquenessProperty",
"ALL_PROPERTIES",
]
21 changes: 21 additions & 0 deletions openhands-sdk/openhands/sdk/context/view/properties/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,27 @@ def enforce(
the current view to enforce the property.
"""

def transform(
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@OpenHands review this PR, is there a way to do this WITHOUT adding this new transform fucntion?

I remember there's a util function that transform the full list of events to LLM messages, could we do it there?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm on it! xingyaoww can track my progress at all-hands.dev

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

I addressed the review comment by simplifying the implementation as suggested. The changes have already been pushed (commit b7b1e6b3).

What Changed

Instead of adding a new transform() method to ViewPropertyBase and a new ToolResultUniquenessProperty class, I moved the duplicate observation deduplication directly into the existing events_to_messages() function.

Files Changed

  1. openhands-sdk/openhands/sdk/event/base.py - Added logic to events_to_messages() to deduplicate consecutive observations with the same tool_call_id, plus a helper function _select_best_observation()

  2. tests/sdk/event/test_events_to_messages.py - Added TestDuplicateObservationDeduplication test class with 7 tests covering the deduplication scenarios

  3. Reverted/Removed:

    • openhands-sdk/openhands/sdk/context/view/properties/base.py - reverted (no transform() method)
    • openhands-sdk/openhands/sdk/context/view/properties/__init__.py - reverted
    • openhands-sdk/openhands/sdk/context/view/view.py - reverted
    • openhands-sdk/openhands/sdk/context/view/properties/tool_result_uniqueness.py - deleted
    • tests/sdk/context/view/properties/test_tool_result_uniqueness.py - deleted

Why This Approach Is Better

  • Existing pattern: events_to_messages() already batches ActionEvents with the same llm_response_id - same pattern now handles observation deduplication
  • No new abstraction: No transform() method added to ViewPropertyBase
  • Right layer: Deduplication at message conversion is where the Anthropic API constraint matters
  • Net reduction: ~484 fewer lines of code (315 added, 799 removed)

self,
current_view_events: list[LLMConvertibleEvent], # noqa: ARG002
all_events: Sequence[Event], # noqa: ARG002
) -> dict[EventID, LLMConvertibleEvent]:
"""Transform events in the view by replacing them with modified versions.

This method allows properties to merge or modify events rather than just
removing them. The default implementation returns an empty dict (no transforms).

Args:
current_view_events: The sequence of events currently in the view.
all_events: A list of all Event objects in the conversation.

Returns:
A mapping from original EventID to the replacement LLMConvertibleEvent.
Events whose IDs appear as keys will be replaced with the corresponding
values. The replacement events should have new unique IDs.
"""
return {}

@abstractmethod
def manipulation_indices(
self,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
"""Property to ensure each tool_call_id has exactly one tool result.

LLM APIs (especially Anthropic) require that each tool_use has exactly one
corresponding tool_result. This property handles the edge case where multiple
observation events (e.g., AgentErrorEvent and ObservationEvent) are created
for the same tool_call_id, typically due to restarts or race conditions.

When both AgentErrorEvent and ObservationEvent exist for the same tool_call_id,
the error context is merged into the observation to preserve both pieces of
information for the LLM.
"""

from collections import defaultdict
from collections.abc import Sequence

from openhands.sdk.context.view.manipulation_indices import ManipulationIndices
from openhands.sdk.context.view.properties.base import ViewPropertyBase
from openhands.sdk.event import (
AgentErrorEvent,
Event,
EventID,
LLMConvertibleEvent,
ObservationBaseEvent,
ObservationEvent,
ToolCallID,
)
from openhands.sdk.llm import TextContent


def _create_merged_observation(
obs_event: ObservationEvent,
error_events: list[AgentErrorEvent],
) -> ObservationEvent:
"""Create a new ObservationEvent with error context merged into the observation.

The error messages from AgentErrorEvents are prepended to the observation content,
giving the LLM context about any issues that occurred during tool execution.

Args:
obs_event: The original ObservationEvent with the actual tool result.
error_events: List of AgentErrorEvents to merge (typically from restarts).

Returns:
A new ObservationEvent with merged content and a new unique ID.
"""
# Collect error messages
error_texts = [f"[Note: {error.error}]" for error in error_events]
error_prefix = "\n".join(error_texts) + "\n\n"

# Create new content list with error context prepended
original_content = list(obs_event.observation.content)
merged_content: list[TextContent] = [TextContent(text=error_prefix)]
merged_content.extend(original_content) # type: ignore[arg-type]
Comment thread
xingyaoww marked this conversation as resolved.
Outdated

# Create a new observation with merged content
# We need to preserve all fields from the original observation
obs_data = obs_event.observation.model_dump()
obs_data["content"] = merged_content

# Create the new observation using the same class as the original
merged_observation = obs_event.observation.__class__(**obs_data)
Comment thread
xingyaoww marked this conversation as resolved.
Outdated

# Create new ObservationEvent with a unique ID
# ID format: "{original_id}-merged" to ensure uniqueness
return ObservationEvent(
id=f"{obs_event.id}-merged",
Comment thread
xingyaoww marked this conversation as resolved.
Outdated
tool_name=obs_event.tool_name,
tool_call_id=obs_event.tool_call_id,
observation=merged_observation,
action_id=obs_event.action_id,
source=obs_event.source,
)


class ToolResultUniquenessProperty(ViewPropertyBase):
"""Each tool_call_id must have exactly one tool result.

When multiple observations exist for the same tool_call_id, this property:
1. Merges AgentErrorEvent content into ObservationEvent (if both exist)
2. Keeps only the merged/primary event and removes duplicates
3. Prefers ObservationEvent > other observations > AgentErrorEvent
"""

def transform(
self,
current_view_events: list[LLMConvertibleEvent],
all_events: Sequence[Event], # noqa: ARG002
) -> dict[EventID, LLMConvertibleEvent]:
"""Merge AgentErrorEvent content into ObservationEvent when both exist.

When an AgentErrorEvent and ObservationEvent share the same tool_call_id
(typically from a restart scenario), merge the error context into the
observation so the LLM has full context about what happened.
"""
# Group observations by tool_call_id
observations_by_tool_call: dict[ToolCallID, list[ObservationBaseEvent]] = (
defaultdict(list)
)

for event in current_view_events:
if isinstance(event, ObservationBaseEvent):
observations_by_tool_call[event.tool_call_id].append(event)

transforms: dict[EventID, LLMConvertibleEvent] = {}

for observations in observations_by_tool_call.values():
if len(observations) <= 1:
continue

# Find ObservationEvents and AgentErrorEvents
obs_events = [o for o in observations if isinstance(o, ObservationEvent)]
error_events = [o for o in observations if isinstance(o, AgentErrorEvent)]

# Only merge if we have both an ObservationEvent and AgentErrorEvent(s)
if obs_events and error_events:
# Use the last ObservationEvent as the base
base_obs = obs_events[-1]
# Create merged observation with error context
merged_event = _create_merged_observation(base_obs, error_events)
transforms[base_obs.id] = merged_event

return transforms

def enforce(
self,
current_view_events: list[LLMConvertibleEvent],
all_events: Sequence[Event], # noqa: ARG002
) -> set[EventID]:
"""Remove duplicate tool results for the same tool_call_id.

After transform() has merged error context into observations, this method
removes the remaining duplicate events (the original AgentErrorEvents and
any other duplicates).
"""
# Group observations by tool_call_id
observations_by_tool_call: dict[ToolCallID, list[ObservationBaseEvent]] = (
defaultdict(list)
)

for event in current_view_events:
if isinstance(event, ObservationBaseEvent):
observations_by_tool_call[event.tool_call_id].append(event)
Comment thread
xingyaoww marked this conversation as resolved.
Outdated

events_to_remove: set[EventID] = set()

for observations in observations_by_tool_call.values():
if len(observations) <= 1:
continue

# Multiple observations for same tool_call_id - need to pick one
# Priority: ObservationEvent > other observations > AgentErrorEvent
# If same priority, keep the later one (more recent)
obs_events = [o for o in observations if isinstance(o, ObservationEvent)]
error_events = [o for o in observations if isinstance(o, AgentErrorEvent)]
other_events = [
o
for o in observations
if not isinstance(o, (ObservationEvent, AgentErrorEvent))
]

# Determine which one to keep
if obs_events:
# Keep the last ObservationEvent, remove all others
to_keep = obs_events[-1]
elif other_events:
# Keep the last "other" event (UserRejectObservation, etc.)
to_keep = other_events[-1]
else:
# Only AgentErrorEvents, keep the last one
to_keep = error_events[-1]

# Mark all others for removal
for obs in observations:
if obs.id != to_keep.id:
events_to_remove.add(obs.id)

return events_to_remove

def manipulation_indices(
self,
current_view_events: list[LLMConvertibleEvent],
) -> ManipulationIndices:
"""Calculate manipulation indices for tool result uniqueness.

This property doesn't restrict manipulation - it only enforces
uniqueness when violations are detected.
"""
return ManipulationIndices.complete(current_view_events)
17 changes: 16 additions & 1 deletion openhands-sdk/openhands/sdk/context/view/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,27 @@ def enforce_properties(
Since enforcement is intended as a fallback to inductively maintaining the
properties via the associated manipulation indices, any time a property must be
enforced a warning is logged.

Properties can also transform events (e.g., merge multiple events into one)
via the transform() method. Transformations are applied before enforcement.
"""
for property in ALL_PROPERTIES:
# First apply any transformations (e.g., merging events)
transforms = property.transform(current_view_events, all_events)
if transforms:
logger.warning(
f"Property {property.__class__.__name__} transformed "
f"{len(transforms)} events."
)
current_view_events = [
transforms.get(event.id, event) for event in current_view_events
]

# Then enforce by removing events
events_to_forget = property.enforce(current_view_events, all_events)
if events_to_forget:
logger.warning(
f"Property {property.__class__} enforced, "
f"Property {property.__class__.__name__} enforced, "
f"{len(events_to_forget)} events dropped."
)
return View.enforce_properties(
Expand Down
Loading
Loading