Skip to content
Closed
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
505 changes: 0 additions & 505 deletions openhands-sdk/openhands/sdk/context/view.py

This file was deleted.

121 changes: 121 additions & 0 deletions openhands-sdk/openhands/sdk/context/view/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# View

The `View` class is responsible for representing and manipulating the subset of events that will be provided to the agent's LLM on every step.

It is closely tied to the context condensation system, and works to ensure the resulting sequence of messages are well-formed and respect the structure expected by common LLM APIs.

## Architecture Overview

### Property-Based Design

The View maintains several **properties** (invariants) that must hold for the event sequence to be valid. Each property has two responsibilities:

1. **Validation**: Check that the property holds and filter/transform events to enforce it
2. **Manipulation Index Calculation**: Determine "safe boundaries" where events can be inserted or removed without violating the property

The final set of manipulation indices is computed by taking the **intersection** of the indices from all properties. This ensures that operations at those indices will respect all invariants simultaneously.

### Why This Matters

This design provides:
- **Modularity**: Each property is self-contained and independently testable
- **Composability**: New properties can be added without modifying existing ones
- **Clarity**: The interaction between properties is explicit (intersection)
- **Safety**: Manipulation operations are guaranteed to maintain all invariants

## Properties

The View maintains four core properties:

### 1. BatchAtomicityProperty

**Purpose**: Ensures that ActionEvents sharing the same `llm_response_id` form an atomic unit that cannot be split.

**Why It Exists**: When an LLM makes a single response containing multiple tool calls, those calls are semantically related. If any one is forgotten (e.g., during condensation), all must be forgotten together to maintain consistency.

**Validation Logic**:
- Groups ActionEvents by their `llm_response_id` field
- When any ActionEvent in a batch is marked for removal, adds all other ActionEvents from that batch to the removal set
- Uses `ActionBatch.from_events()` to build the mapping

**Manipulation Index Calculation**:
1. Build mapping: `llm_response_id` → list of ActionEvent indices
2. For each batch, find the min and max indices of all actions
3. Mark the range `[min, max]` as atomic (cannot insert/remove within)
4. Return all indices *outside* these atomic ranges

**Auxiliary Data**:
- `batches: dict[EventID, list[int]]` - Maps llm_response_id to action indices

**Example**:
```
Events: [E0, A1, A2, E3, A4] (A1, A2 share llm_response_id='batch1')
Atomic ranges: [1, 2]
Manipulation indices: {0, 3, 5} (can manipulate before/between/after, not within batch)
```

---

### 2. ToolLoopAtomicityProperty

**Purpose**: Ensures that "tool loops" (thinking blocks followed by tool calls) remain atomic units.

**Why It Exists**: Claude API requires that thinking blocks stay with their associated tool calls. A tool loop is:
- An initial batch containing thinking blocks (ActionEvents with non-empty `thinking_blocks`)
- All subsequent consecutive ActionEvent batches
- Terminated by the first non-ActionEvent/ObservationEvent

**Validation Logic**:
- Identifies batches that start with thinking blocks
- Extends the atomic unit through all consecutive ActionEvent/ObservationEvent batches
- Does not perform removal (relies on batch atomicity)

**Manipulation Index Calculation**:
1. Identify batches with thinking blocks (potential tool loop starts)
2. For each such batch, scan forward to find where the tool loop ends (first non-action/observation)
3. Mark entire range as atomic
4. Return all indices *outside* these tool loop ranges

**Auxiliary Data**:
- `batch_ranges: list[tuple[int, int, bool]]` - (min_idx, max_idx, has_thinking) for each batch
- `tool_loop_ranges: list[tuple[int, int]]` - Start and end indices of tool loops

**Example**:
```
Events: [E0, A1(thinking), O1, A2, E3]
Tool loop: [1, 3] (A1 with thinking → O1 → A2, stops at E3)
Manipulation indices: {0, 4, 5} (can only manipulate before loop or after)
```

---

### 3. ToolCallMatchingProperty

**Purpose**: Ensures that ActionEvents and ObservationEvents are properly paired via `tool_call_id`.

**Why It Exists**: LLM APIs expect tool calls to have corresponding observations. Orphaned actions or observations cause API errors.

**Validation Logic**:
1. Extract all `tool_call_id` values from ActionEvents
2. Extract all `tool_call_id` values from ObservationEvents (includes ObservationEvent, UserRejectObservation, AgentErrorEvent)
3. Keep ActionEvents only if their `tool_call_id` exists in observations
4. Keep ObservationEvents only if their `tool_call_id` exists in actions
5. Keep all other event types unconditionally

**Manipulation Index Calculation**:
- All indices are valid for this property (no restrictions on boundaries)
- Validation happens through filtering, not boundary restriction
- Returns `set(range(len(events) + 1))`

**Auxiliary Data**:
- `action_tool_call_ids: set[ToolCallID]` - Tool call IDs from actions
- `observation_tool_call_ids: set[ToolCallID]` - Tool call IDs from observations

**Example**:
```
Events: [A1(tc_1), O1(tc_1), A2(tc_2)]
A2 has no matching observation → filtered out
Result: [A1(tc_1), O1(tc_1)]
```

---
5 changes: 5 additions & 0 deletions openhands-sdk/openhands/sdk/context/view/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from openhands.sdk.context.view.manipulation_indices import ManipulationIndices
from openhands.sdk.context.view.view import View


__all__ = ["ManipulationIndices", "View"]
37 changes: 37 additions & 0 deletions openhands-sdk/openhands/sdk/context/view/manipulation_indices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
class ManipulationIndices(set[int]):
"""A set of indices where events can be safely manipulated.

This class extends set[int] to provide utility methods for finding
the next valid manipulation index given a threshold.
"""

def find_next(self, threshold: int, strict: bool = False) -> int:
"""Find the smallest manipulation index greater than (or equal to) a threshold.

This is a helper method for condensation logic that needs to find safe
boundaries for forgetting events.

Args:
threshold: The threshold value to compare against
strict: If True, finds index > threshold. If False, finds index >= threshold

Returns:
The smallest manipulation index that satisfies the condition

Raises:
ValueError: If no valid manipulation index exists that satisfies
the condition
"""
if strict:
valid_indices = [idx for idx in self if idx > threshold]
else:
valid_indices = [idx for idx in self if idx >= threshold]

if not valid_indices:
operator = ">" if strict else ">="
raise ValueError(
f"No manipulation index found {operator} {threshold}. "
f"Available indices: {sorted(self)}"
)

return min(valid_indices)
18 changes: 18 additions & 0 deletions openhands-sdk/openhands/sdk/context/view/properties/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from openhands.sdk.context.view.properties.base import ViewPropertyBase
from openhands.sdk.context.view.properties.batch_atomicity import (
BatchAtomicityProperty,
)
from openhands.sdk.context.view.properties.tool_call_matching import (
ToolCallMatchingProperty,
)
from openhands.sdk.context.view.properties.tool_loop_atomicity import (
ToolLoopAtomicityProperty,
)


__all__ = [
"ViewPropertyBase",
"BatchAtomicityProperty",
"ToolCallMatchingProperty",
"ToolLoopAtomicityProperty",
]
135 changes: 135 additions & 0 deletions openhands-sdk/openhands/sdk/context/view/properties/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
from abc import ABC, abstractmethod
from collections import defaultdict
from collections.abc import Sequence

from openhands.sdk.context.view.manipulation_indices import ManipulationIndices
from openhands.sdk.event.base import Event, LLMConvertibleEvent
from openhands.sdk.event.llm_convertible.action import ActionEvent
from openhands.sdk.event.types import EventID


class ViewPropertyBase(ABC):
"""Abstract base class for properties of a view.

Properties define rules that help maintain the integrity and coherence of the events
in the view. The properties are maintained via two strategies:

1. Enforcing the property by removing events that violate it.
2. Defining manipulation indices that restrict where the view can be manipulated.

In an ideal scenario, sticking to the manipulation indices should suffice to ensure
the property holds. Enforcement is only intended as a fallback mechanism to handle
edge cases, bad data, or unforeseen situations.
"""

@abstractmethod
def enforce(
self,
current_view_events: Sequence[LLMConvertibleEvent],
all_events: Sequence[Event],
) -> set[EventID]:
"""Enforce the property on a list of events.

Args:
current_view_events: A list of events currently in the view.
all_events: A list of all Event objects in the conversation. Useful for
properties that need to reference events outside the current view.

Returns:
A set of EventID objects to be removed from the current view to enforce the
property.
"""
pass

@abstractmethod
def manipulation_indices(
self,
current_view_events: Sequence[LLMConvertibleEvent],
all_events: Sequence[Event],
) -> ManipulationIndices:
"""Get manipulation indices for the property on a list of events.

Args:
current_view_events: A list of events currently in the view.
all_events: A list of all Event objects in the conversation. Useful for
properties that need to reference events outside the current view.

Returns:
A ManipulationIndices object defining where the view can be manipulated
while maintaining the property.
"""
pass

@staticmethod
def _build_batches(events: Sequence[Event]) -> dict[EventID, list[EventID]]:
"""Build mapping of llm_response_id to ActionEvent IDs.

Args:
events: Sequence of events to analyze

Returns:
Dictionary mapping llm_response_id to list of ActionEvent IDs
"""
batches: dict[EventID, list[EventID]] = defaultdict(list)
for event in events:
if isinstance(event, ActionEvent):
batches[event.llm_response_id].append(event.id)
return dict(batches)

@staticmethod
def _build_event_id_to_index(events: Sequence[Event]) -> dict[EventID, int]:
"""Build mapping of event ID to index.

Args:
events: Sequence of events to analyze

Returns:
Dictionary mapping event ID to index in the list
"""
return {event.id: idx for idx, event in enumerate(events)}

@staticmethod
def _get_batch_extent(
action_ids: list[EventID],
event_id_to_index: dict[EventID, int],
) -> tuple[int, int]:
"""Get the min and max indices for a batch of action IDs.

Args:
action_ids: List of ActionEvent IDs in the batch
event_id_to_index: Mapping of event IDs to indices

Returns:
Tuple of (min_index, max_index) for the batch
"""
indices = [event_id_to_index[aid] for aid in action_ids]
return min(indices), max(indices)

@staticmethod
def _build_manipulation_indices_from_atomic_ranges(
atomic_ranges: list[tuple[int, int]],
num_events: int,
) -> ManipulationIndices:
"""Build ManipulationIndices that exclude indices within atomic ranges.

Atomic ranges represent contiguous sequences of events that must remain
together. This method creates a set of valid manipulation indices that
excludes all indices that fall within these atomic ranges.

Args:
atomic_ranges: List of (start_idx, end_idx) tuples defining atomic ranges
num_events: Total number of events in the view

Returns:
ManipulationIndices with valid indices (excluding atomic ranges)
"""
# Start with all possible indices (including after the last event)
valid_indices = set(range(num_events + 1))

# Remove indices that fall within atomic ranges
for start_idx, end_idx in atomic_ranges:
# Cannot insert/remove within the atomic range (exclusive of start boundary)
for idx in range(start_idx + 1, end_idx + 1):
valid_indices.discard(idx)

return ManipulationIndices(valid_indices)
Loading
Loading