openhands-sdk 1.7.0__py3-none-any.whl → 1.7.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openhands/sdk/agent/agent.py +31 -1
- openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +1 -2
- openhands/sdk/agent/utils.py +9 -4
- openhands/sdk/context/condenser/base.py +11 -6
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +167 -18
- openhands/sdk/context/condenser/no_op_condenser.py +2 -1
- openhands/sdk/context/condenser/pipeline_condenser.py +10 -9
- openhands/sdk/context/condenser/utils.py +149 -0
- openhands/sdk/context/skills/skill.py +85 -0
- openhands/sdk/context/view.py +234 -37
- openhands/sdk/conversation/conversation.py +6 -0
- openhands/sdk/conversation/impl/local_conversation.py +33 -3
- openhands/sdk/conversation/impl/remote_conversation.py +36 -0
- openhands/sdk/conversation/state.py +41 -1
- openhands/sdk/hooks/__init__.py +30 -0
- openhands/sdk/hooks/config.py +180 -0
- openhands/sdk/hooks/conversation_hooks.py +227 -0
- openhands/sdk/hooks/executor.py +155 -0
- openhands/sdk/hooks/manager.py +170 -0
- openhands/sdk/hooks/types.py +40 -0
- openhands/sdk/io/cache.py +85 -0
- openhands/sdk/io/local.py +39 -2
- openhands/sdk/llm/mixins/fn_call_converter.py +61 -16
- openhands/sdk/llm/mixins/non_native_fc.py +5 -1
- openhands/sdk/tool/schema.py +10 -0
- {openhands_sdk-1.7.0.dist-info → openhands_sdk-1.7.1.dist-info}/METADATA +1 -1
- {openhands_sdk-1.7.0.dist-info → openhands_sdk-1.7.1.dist-info}/RECORD +29 -21
- {openhands_sdk-1.7.0.dist-info → openhands_sdk-1.7.1.dist-info}/WHEEL +0 -0
- {openhands_sdk-1.7.0.dist-info → openhands_sdk-1.7.1.dist-info}/top_level.txt +0 -0
|
@@ -40,6 +40,9 @@ class Skill(BaseModel):
|
|
|
40
40
|
- None: Always active, for repository-specific guidelines
|
|
41
41
|
- KeywordTrigger: Activated when keywords appear in user messages
|
|
42
42
|
- TaskTrigger: Activated for specific tasks, may require user input
|
|
43
|
+
|
|
44
|
+
This model supports both OpenHands-specific fields and AgentSkills standard
|
|
45
|
+
fields (https://agentskills.io/specification) for cross-platform compatibility.
|
|
43
46
|
"""
|
|
44
47
|
|
|
45
48
|
name: str
|
|
@@ -74,6 +77,65 @@ class Skill(BaseModel):
|
|
|
74
77
|
description="Input metadata for the skill (task skills only)",
|
|
75
78
|
)
|
|
76
79
|
|
|
80
|
+
# AgentSkills standard fields (https://agentskills.io/specification)
|
|
81
|
+
description: str | None = Field(
|
|
82
|
+
default=None,
|
|
83
|
+
description=(
|
|
84
|
+
"A brief description of what the skill does and when to use it. "
|
|
85
|
+
"AgentSkills standard field (max 1024 characters)."
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
license: str | None = Field(
|
|
89
|
+
default=None,
|
|
90
|
+
description=(
|
|
91
|
+
"The license under which the skill is distributed. "
|
|
92
|
+
"AgentSkills standard field (e.g., 'Apache-2.0', 'MIT')."
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
compatibility: str | None = Field(
|
|
96
|
+
default=None,
|
|
97
|
+
description=(
|
|
98
|
+
"Environment requirements or compatibility notes for the skill. "
|
|
99
|
+
"AgentSkills standard field (e.g., 'Requires git and docker')."
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
metadata: dict[str, str] | None = Field(
|
|
103
|
+
default=None,
|
|
104
|
+
description=(
|
|
105
|
+
"Arbitrary key-value metadata for the skill. "
|
|
106
|
+
"AgentSkills standard field for extensibility."
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
allowed_tools: list[str] | None = Field(
|
|
110
|
+
default=None,
|
|
111
|
+
description=(
|
|
112
|
+
"List of pre-approved tools for this skill. "
|
|
113
|
+
"AgentSkills standard field (parsed from space-delimited string)."
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
@field_validator("allowed_tools", mode="before")
|
|
118
|
+
@classmethod
|
|
119
|
+
def _parse_allowed_tools(cls, v: str | list | None) -> list[str] | None:
|
|
120
|
+
"""Parse allowed_tools from space-delimited string or list."""
|
|
121
|
+
if v is None:
|
|
122
|
+
return None
|
|
123
|
+
if isinstance(v, str):
|
|
124
|
+
return v.split()
|
|
125
|
+
if isinstance(v, list):
|
|
126
|
+
return [str(t) for t in v]
|
|
127
|
+
raise SkillValidationError("allowed-tools must be a string or list")
|
|
128
|
+
|
|
129
|
+
@field_validator("metadata", mode="before")
|
|
130
|
+
@classmethod
|
|
131
|
+
def _convert_metadata_values(cls, v: dict | None) -> dict[str, str] | None:
|
|
132
|
+
"""Convert metadata values to strings."""
|
|
133
|
+
if v is None:
|
|
134
|
+
return None
|
|
135
|
+
if isinstance(v, dict):
|
|
136
|
+
return {str(k): str(val) for k, val in v.items()}
|
|
137
|
+
raise SkillValidationError("metadata must be a dictionary")
|
|
138
|
+
|
|
77
139
|
PATH_TO_THIRD_PARTY_SKILL_NAME: ClassVar[dict[str, str]] = {
|
|
78
140
|
".cursorrules": "cursorrules",
|
|
79
141
|
"agents.md": "agents",
|
|
@@ -129,6 +191,9 @@ class Skill(BaseModel):
|
|
|
129
191
|
"""Load a skill from a markdown file with frontmatter.
|
|
130
192
|
|
|
131
193
|
The agent's name is derived from its path relative to the skill_dir.
|
|
194
|
+
|
|
195
|
+
Supports both OpenHands-specific frontmatter fields and AgentSkills
|
|
196
|
+
standard fields (https://agentskills.io/specification).
|
|
132
197
|
"""
|
|
133
198
|
path = Path(path) if isinstance(path, str) else path
|
|
134
199
|
|
|
@@ -162,6 +227,23 @@ class Skill(BaseModel):
|
|
|
162
227
|
# Use name from frontmatter if provided, otherwise use derived name
|
|
163
228
|
agent_name = str(metadata_dict.get("name", skill_name))
|
|
164
229
|
|
|
230
|
+
# Extract AgentSkills standard fields (Pydantic validators handle
|
|
231
|
+
# transformation). Handle "allowed-tools" to "allowed_tools" key mapping.
|
|
232
|
+
allowed_tools_value = metadata_dict.get(
|
|
233
|
+
"allowed-tools", metadata_dict.get("allowed_tools")
|
|
234
|
+
)
|
|
235
|
+
agentskills_fields = {
|
|
236
|
+
"description": metadata_dict.get("description"),
|
|
237
|
+
"license": metadata_dict.get("license"),
|
|
238
|
+
"compatibility": metadata_dict.get("compatibility"),
|
|
239
|
+
"metadata": metadata_dict.get("metadata"),
|
|
240
|
+
"allowed_tools": allowed_tools_value,
|
|
241
|
+
}
|
|
242
|
+
# Remove None values to avoid passing unnecessary kwargs
|
|
243
|
+
agentskills_fields = {
|
|
244
|
+
k: v for k, v in agentskills_fields.items() if v is not None
|
|
245
|
+
}
|
|
246
|
+
|
|
165
247
|
# Get trigger keywords from metadata
|
|
166
248
|
keywords = metadata_dict.get("triggers", [])
|
|
167
249
|
if not isinstance(keywords, list):
|
|
@@ -188,6 +270,7 @@ class Skill(BaseModel):
|
|
|
188
270
|
source=str(path),
|
|
189
271
|
trigger=TaskTrigger(triggers=keywords),
|
|
190
272
|
inputs=inputs,
|
|
273
|
+
**agentskills_fields,
|
|
191
274
|
)
|
|
192
275
|
|
|
193
276
|
elif metadata_dict.get("triggers", None):
|
|
@@ -196,6 +279,7 @@ class Skill(BaseModel):
|
|
|
196
279
|
content=content,
|
|
197
280
|
source=str(path),
|
|
198
281
|
trigger=KeywordTrigger(keywords=keywords),
|
|
282
|
+
**agentskills_fields,
|
|
199
283
|
)
|
|
200
284
|
else:
|
|
201
285
|
# No triggers, default to None (always active)
|
|
@@ -208,6 +292,7 @@ class Skill(BaseModel):
|
|
|
208
292
|
source=str(path),
|
|
209
293
|
trigger=None,
|
|
210
294
|
mcp_tools=mcp_tools,
|
|
295
|
+
**agentskills_fields,
|
|
211
296
|
)
|
|
212
297
|
|
|
213
298
|
# Field-level validation for mcp_tools
|
openhands/sdk/context/view.py
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
1
4
|
from collections.abc import Sequence
|
|
5
|
+
from functools import cached_property
|
|
2
6
|
from logging import getLogger
|
|
3
7
|
from typing import overload
|
|
4
8
|
|
|
5
|
-
from pydantic import BaseModel
|
|
9
|
+
from pydantic import BaseModel, computed_field
|
|
6
10
|
|
|
7
11
|
from openhands.sdk.event import (
|
|
8
12
|
Condensation,
|
|
@@ -21,6 +25,48 @@ from openhands.sdk.event.types import ToolCallID
|
|
|
21
25
|
logger = getLogger(__name__)
|
|
22
26
|
|
|
23
27
|
|
|
28
|
+
class ActionBatch(BaseModel):
|
|
29
|
+
"""Represents a batch of ActionEvents grouped by llm_response_id.
|
|
30
|
+
|
|
31
|
+
This is a utility class used to help detect and manage batches of ActionEvents
|
|
32
|
+
that share the same llm_response_id, which indicates they were generated together
|
|
33
|
+
by the LLM. This is important for ensuring atomicity when manipulating events
|
|
34
|
+
in a View, such as during condensation.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
batches: dict[EventID, list[EventID]]
|
|
38
|
+
"""dict mapping llm_response_id to list of ActionEvent IDs"""
|
|
39
|
+
|
|
40
|
+
action_id_to_response_id: dict[EventID, EventID]
|
|
41
|
+
"""dict mapping ActionEvent ID to llm_response_id"""
|
|
42
|
+
|
|
43
|
+
action_id_to_tool_call_id: dict[EventID, ToolCallID]
|
|
44
|
+
"""dict mapping ActionEvent ID to tool_call_id"""
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def from_events(
|
|
48
|
+
events: Sequence[Event],
|
|
49
|
+
) -> ActionBatch:
|
|
50
|
+
"""Build a map of llm_response_id -> list of ActionEvent IDs."""
|
|
51
|
+
batches: dict[EventID, list[EventID]] = defaultdict(list)
|
|
52
|
+
action_id_to_response_id: dict[EventID, EventID] = {}
|
|
53
|
+
action_id_to_tool_call_id: dict[EventID, ToolCallID] = {}
|
|
54
|
+
|
|
55
|
+
for event in events:
|
|
56
|
+
if isinstance(event, ActionEvent):
|
|
57
|
+
llm_response_id = event.llm_response_id
|
|
58
|
+
batches[llm_response_id].append(event.id)
|
|
59
|
+
action_id_to_response_id[event.id] = llm_response_id
|
|
60
|
+
if event.tool_call_id is not None:
|
|
61
|
+
action_id_to_tool_call_id[event.id] = event.tool_call_id
|
|
62
|
+
|
|
63
|
+
return ActionBatch(
|
|
64
|
+
batches=batches,
|
|
65
|
+
action_id_to_response_id=action_id_to_response_id,
|
|
66
|
+
action_id_to_tool_call_id=action_id_to_tool_call_id,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
24
70
|
class View(BaseModel):
|
|
25
71
|
"""Linearly ordered view of events.
|
|
26
72
|
|
|
@@ -66,6 +112,161 @@ class View(BaseModel):
|
|
|
66
112
|
return event
|
|
67
113
|
return None
|
|
68
114
|
|
|
115
|
+
@computed_field # type: ignore[prop-decorator]
|
|
116
|
+
@cached_property
|
|
117
|
+
def manipulation_indices(self) -> list[int]:
|
|
118
|
+
"""Return cached manipulation indices for this view's events.
|
|
119
|
+
|
|
120
|
+
These indices represent boundaries between atomic units where events can be
|
|
121
|
+
safely manipulated (inserted or forgotten). An atomic unit is either:
|
|
122
|
+
- A tool loop: a sequence of batches starting with thinking blocks and
|
|
123
|
+
continuing through all subsequent batches until a non-batch event
|
|
124
|
+
- A batch of ActionEvents with the same llm_response_id and their
|
|
125
|
+
corresponding ObservationBaseEvents (when not part of a tool loop)
|
|
126
|
+
- A single event that is neither an ActionEvent nor an ObservationBaseEvent
|
|
127
|
+
|
|
128
|
+
Tool loops are identified by thinking blocks and must remain atomic to
|
|
129
|
+
preserve Claude API requirements that the final assistant message must
|
|
130
|
+
have thinking blocks when thinking is enabled.
|
|
131
|
+
|
|
132
|
+
The returned indices can be used for:
|
|
133
|
+
- Inserting new events: any returned index is safe
|
|
134
|
+
- Forgetting events: select a range between two consecutive indices
|
|
135
|
+
|
|
136
|
+
Consecutive indices define atomic units that must stay together:
|
|
137
|
+
- events[indices[i]:indices[i+1]] is an atomic unit
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Sorted list of indices representing atomic unit boundaries. Always
|
|
141
|
+
includes 0 and len(events) as boundaries.
|
|
142
|
+
"""
|
|
143
|
+
if not self.events:
|
|
144
|
+
return [0]
|
|
145
|
+
|
|
146
|
+
# Build mapping of llm_response_id -> list of event indices
|
|
147
|
+
batches: dict[EventID, list[int]] = {}
|
|
148
|
+
for idx, event in enumerate(self.events):
|
|
149
|
+
if isinstance(event, ActionEvent):
|
|
150
|
+
llm_response_id = event.llm_response_id
|
|
151
|
+
if llm_response_id not in batches:
|
|
152
|
+
batches[llm_response_id] = []
|
|
153
|
+
batches[llm_response_id].append(idx)
|
|
154
|
+
|
|
155
|
+
# Build mapping of tool_call_id -> observation indices
|
|
156
|
+
observation_indices: dict[ToolCallID, int] = {}
|
|
157
|
+
for idx, event in enumerate(self.events):
|
|
158
|
+
if (
|
|
159
|
+
isinstance(event, ObservationBaseEvent)
|
|
160
|
+
and event.tool_call_id is not None
|
|
161
|
+
):
|
|
162
|
+
observation_indices[event.tool_call_id] = idx
|
|
163
|
+
|
|
164
|
+
# For each batch, find the range of indices that includes all actions
|
|
165
|
+
# and their corresponding observations, and track if batch has thinking blocks
|
|
166
|
+
batch_ranges: list[tuple[int, int, bool]] = []
|
|
167
|
+
for llm_response_id, action_indices in batches.items():
|
|
168
|
+
min_idx = min(action_indices)
|
|
169
|
+
max_idx = max(action_indices)
|
|
170
|
+
|
|
171
|
+
# Check if this batch has thinking blocks (only first action has them)
|
|
172
|
+
first_action = self.events[min_idx]
|
|
173
|
+
has_thinking = (
|
|
174
|
+
isinstance(first_action, ActionEvent)
|
|
175
|
+
and len(first_action.thinking_blocks) > 0
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Extend the range to include all corresponding observations
|
|
179
|
+
for action_idx in action_indices:
|
|
180
|
+
action_event = self.events[action_idx]
|
|
181
|
+
if (
|
|
182
|
+
isinstance(action_event, ActionEvent)
|
|
183
|
+
and action_event.tool_call_id is not None
|
|
184
|
+
):
|
|
185
|
+
if action_event.tool_call_id in observation_indices:
|
|
186
|
+
obs_idx = observation_indices[action_event.tool_call_id]
|
|
187
|
+
max_idx = max(max_idx, obs_idx)
|
|
188
|
+
|
|
189
|
+
batch_ranges.append((min_idx, max_idx, has_thinking))
|
|
190
|
+
|
|
191
|
+
# Sort batch ranges by start index for tool loop detection
|
|
192
|
+
batch_ranges.sort(key=lambda x: x[0])
|
|
193
|
+
|
|
194
|
+
# Identify tool loops: A tool loop starts with a batch that has thinking
|
|
195
|
+
# blocks and continues through all subsequent batches until we hit a
|
|
196
|
+
# non-ActionEvent/ObservationEvent (like a user MessageEvent).
|
|
197
|
+
tool_loop_ranges: list[tuple[int, int]] = []
|
|
198
|
+
if batch_ranges:
|
|
199
|
+
i = 0
|
|
200
|
+
while i < len(batch_ranges):
|
|
201
|
+
min_idx, max_idx, has_thinking = batch_ranges[i]
|
|
202
|
+
|
|
203
|
+
# If this batch has thinking blocks, start a tool loop
|
|
204
|
+
if has_thinking:
|
|
205
|
+
loop_start = min_idx
|
|
206
|
+
loop_end = max_idx
|
|
207
|
+
|
|
208
|
+
# Continue through ALL subsequent batches until we hit
|
|
209
|
+
# a non-batch event
|
|
210
|
+
j = i + 1
|
|
211
|
+
while j < len(batch_ranges):
|
|
212
|
+
next_min, next_max, _ = batch_ranges[j]
|
|
213
|
+
|
|
214
|
+
# Check if there's a non-batch event between current
|
|
215
|
+
# and next batch
|
|
216
|
+
has_non_batch_between = False
|
|
217
|
+
for k in range(loop_end + 1, next_min):
|
|
218
|
+
event = self.events[k]
|
|
219
|
+
if not isinstance(
|
|
220
|
+
event, (ActionEvent, ObservationBaseEvent)
|
|
221
|
+
):
|
|
222
|
+
has_non_batch_between = True
|
|
223
|
+
break
|
|
224
|
+
|
|
225
|
+
if has_non_batch_between:
|
|
226
|
+
# Tool loop ends before this non-batch event
|
|
227
|
+
break
|
|
228
|
+
|
|
229
|
+
# Include this batch in the tool loop
|
|
230
|
+
loop_end = max(loop_end, next_max)
|
|
231
|
+
j += 1
|
|
232
|
+
|
|
233
|
+
tool_loop_ranges.append((loop_start, loop_end))
|
|
234
|
+
i = j
|
|
235
|
+
else:
|
|
236
|
+
i += 1
|
|
237
|
+
|
|
238
|
+
# Merge batch ranges that are part of tool loops
|
|
239
|
+
# Create a mapping of batch index ranges to whether they're in a tool loop
|
|
240
|
+
merged_ranges: list[tuple[int, int]] = []
|
|
241
|
+
|
|
242
|
+
if tool_loop_ranges:
|
|
243
|
+
# Add tool loop ranges as atomic units
|
|
244
|
+
merged_ranges.extend(tool_loop_ranges)
|
|
245
|
+
|
|
246
|
+
# Add non-tool-loop batch ranges
|
|
247
|
+
tool_loop_indices = set()
|
|
248
|
+
for loop_start, loop_end in tool_loop_ranges:
|
|
249
|
+
tool_loop_indices.update(range(loop_start, loop_end + 1))
|
|
250
|
+
|
|
251
|
+
for min_idx, max_idx, has_thinking in batch_ranges:
|
|
252
|
+
# Only add if not already covered by a tool loop
|
|
253
|
+
if min_idx not in tool_loop_indices:
|
|
254
|
+
merged_ranges.append((min_idx, max_idx))
|
|
255
|
+
else:
|
|
256
|
+
# No tool loops, just use regular batch ranges
|
|
257
|
+
merged_ranges = [(min_idx, max_idx) for min_idx, max_idx, _ in batch_ranges]
|
|
258
|
+
|
|
259
|
+
# Start with all possible indices (subtractive approach)
|
|
260
|
+
result_indices = set(range(len(self.events) + 1))
|
|
261
|
+
|
|
262
|
+
# Remove indices inside merged ranges (keep only boundaries)
|
|
263
|
+
for min_idx, max_idx in merged_ranges:
|
|
264
|
+
# Remove interior indices, keeping min_idx and max_idx+1 as boundaries
|
|
265
|
+
for idx in range(min_idx + 1, max_idx + 1):
|
|
266
|
+
result_indices.discard(idx)
|
|
267
|
+
|
|
268
|
+
return sorted(result_indices)
|
|
269
|
+
|
|
69
270
|
# To preserve list-like indexing, we ideally support slicing and position-based
|
|
70
271
|
# indexing. The only challenge with that is switching the return type based on the
|
|
71
272
|
# input type -- we can mark the different signatures for MyPy with `@overload`
|
|
@@ -88,35 +289,6 @@ class View(BaseModel):
|
|
|
88
289
|
else:
|
|
89
290
|
raise ValueError(f"Invalid key type: {type(key)}")
|
|
90
291
|
|
|
91
|
-
@staticmethod
|
|
92
|
-
def _build_action_batches(
|
|
93
|
-
events: Sequence[Event],
|
|
94
|
-
) -> tuple[
|
|
95
|
-
dict[EventID, list[EventID]], dict[EventID, EventID], dict[EventID, ToolCallID]
|
|
96
|
-
]:
|
|
97
|
-
"""Build a map of llm_response_id -> list of ActionEvent IDs.
|
|
98
|
-
|
|
99
|
-
Returns:
|
|
100
|
-
A tuple of:
|
|
101
|
-
- batches: dict mapping llm_response_id to list of ActionEvent IDs
|
|
102
|
-
- action_id_to_response_id: dict mapping ActionEvent ID to llm_response_id
|
|
103
|
-
- action_id_to_tool_call_id: dict mapping ActionEvent ID to tool_call_id
|
|
104
|
-
"""
|
|
105
|
-
batches: dict[EventID, list[EventID]] = {}
|
|
106
|
-
action_id_to_response_id: dict[EventID, EventID] = {}
|
|
107
|
-
action_id_to_tool_call_id: dict[EventID, ToolCallID] = {}
|
|
108
|
-
|
|
109
|
-
for event in events:
|
|
110
|
-
if isinstance(event, ActionEvent):
|
|
111
|
-
llm_response_id = event.llm_response_id
|
|
112
|
-
if llm_response_id not in batches:
|
|
113
|
-
batches[llm_response_id] = []
|
|
114
|
-
batches[llm_response_id].append(event.id)
|
|
115
|
-
action_id_to_response_id[event.id] = llm_response_id
|
|
116
|
-
action_id_to_tool_call_id[event.id] = event.tool_call_id
|
|
117
|
-
|
|
118
|
-
return batches, action_id_to_response_id, action_id_to_tool_call_id
|
|
119
|
-
|
|
120
292
|
@staticmethod
|
|
121
293
|
def _enforce_batch_atomicity(
|
|
122
294
|
events: Sequence[Event],
|
|
@@ -136,14 +308,14 @@ class View(BaseModel):
|
|
|
136
308
|
Updated set of event IDs that should be removed (including all
|
|
137
309
|
ActionEvents in batches where any ActionEvent was removed)
|
|
138
310
|
"""
|
|
139
|
-
|
|
311
|
+
action_batch = ActionBatch.from_events(events)
|
|
140
312
|
|
|
141
|
-
if not batches:
|
|
313
|
+
if not action_batch.batches:
|
|
142
314
|
return removed_event_ids
|
|
143
315
|
|
|
144
316
|
updated_removed_ids = set(removed_event_ids)
|
|
145
317
|
|
|
146
|
-
for llm_response_id, batch_event_ids in batches.items():
|
|
318
|
+
for llm_response_id, batch_event_ids in action_batch.batches.items():
|
|
147
319
|
# Check if any ActionEvent in this batch is being removed
|
|
148
320
|
if any(event_id in removed_event_ids for event_id in batch_event_ids):
|
|
149
321
|
# If so, remove all ActionEvents in this batch
|
|
@@ -171,7 +343,7 @@ class View(BaseModel):
|
|
|
171
343
|
observation_tool_call_ids = View._get_observation_tool_call_ids(events)
|
|
172
344
|
|
|
173
345
|
# Build batch info for batch atomicity enforcement
|
|
174
|
-
|
|
346
|
+
action_batch = ActionBatch.from_events(events)
|
|
175
347
|
|
|
176
348
|
# First pass: identify which events would NOT be kept based on matching
|
|
177
349
|
removed_event_ids: set[EventID] = set()
|
|
@@ -190,8 +362,10 @@ class View(BaseModel):
|
|
|
190
362
|
# due to batch atomicity
|
|
191
363
|
tool_call_ids_to_remove: set[ToolCallID] = set()
|
|
192
364
|
for action_id in removed_event_ids:
|
|
193
|
-
if action_id in action_id_to_tool_call_id:
|
|
194
|
-
tool_call_ids_to_remove.add(
|
|
365
|
+
if action_id in action_batch.action_id_to_tool_call_id:
|
|
366
|
+
tool_call_ids_to_remove.add(
|
|
367
|
+
action_batch.action_id_to_tool_call_id[action_id]
|
|
368
|
+
)
|
|
195
369
|
|
|
196
370
|
# Filter out removed events
|
|
197
371
|
result = []
|
|
@@ -242,8 +416,31 @@ class View(BaseModel):
|
|
|
242
416
|
else:
|
|
243
417
|
return True
|
|
244
418
|
|
|
419
|
+
def find_next_manipulation_index(self, threshold: int, strict: bool = False) -> int:
|
|
420
|
+
"""Find the smallest manipulation index greater than (or equal to) a threshold.
|
|
421
|
+
|
|
422
|
+
This is a helper method for condensation logic that needs to find safe
|
|
423
|
+
boundaries for forgetting events. Uses the cached manipulation_indices property.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
threshold: The threshold value to compare against
|
|
427
|
+
strict: If True, finds index > threshold. If False, finds index >= threshold
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
The smallest manipulation index that satisfies the condition, or the
|
|
431
|
+
threshold itself if no such index exists
|
|
432
|
+
"""
|
|
433
|
+
for idx in self.manipulation_indices:
|
|
434
|
+
if strict:
|
|
435
|
+
if idx > threshold:
|
|
436
|
+
return idx
|
|
437
|
+
else:
|
|
438
|
+
if idx >= threshold:
|
|
439
|
+
return idx
|
|
440
|
+
return threshold
|
|
441
|
+
|
|
245
442
|
@staticmethod
|
|
246
|
-
def from_events(events: Sequence[Event]) ->
|
|
443
|
+
def from_events(events: Sequence[Event]) -> View:
|
|
247
444
|
"""Create a view from a list of events, respecting the semantics of any
|
|
248
445
|
condensation events.
|
|
249
446
|
"""
|
|
@@ -14,6 +14,7 @@ from openhands.sdk.conversation.visualizer import (
|
|
|
14
14
|
ConversationVisualizerBase,
|
|
15
15
|
DefaultConversationVisualizer,
|
|
16
16
|
)
|
|
17
|
+
from openhands.sdk.hooks import HookConfig
|
|
17
18
|
from openhands.sdk.logger import get_logger
|
|
18
19
|
from openhands.sdk.secret import SecretValue
|
|
19
20
|
from openhands.sdk.workspace import LocalWorkspace, RemoteWorkspace
|
|
@@ -56,6 +57,7 @@ class Conversation:
|
|
|
56
57
|
conversation_id: ConversationID | None = None,
|
|
57
58
|
callbacks: list[ConversationCallbackType] | None = None,
|
|
58
59
|
token_callbacks: list[ConversationTokenCallbackType] | None = None,
|
|
60
|
+
hook_config: HookConfig | None = None,
|
|
59
61
|
max_iteration_per_run: int = 500,
|
|
60
62
|
stuck_detection: bool = True,
|
|
61
63
|
stuck_detection_thresholds: (
|
|
@@ -76,6 +78,7 @@ class Conversation:
|
|
|
76
78
|
conversation_id: ConversationID | None = None,
|
|
77
79
|
callbacks: list[ConversationCallbackType] | None = None,
|
|
78
80
|
token_callbacks: list[ConversationTokenCallbackType] | None = None,
|
|
81
|
+
hook_config: HookConfig | None = None,
|
|
79
82
|
max_iteration_per_run: int = 500,
|
|
80
83
|
stuck_detection: bool = True,
|
|
81
84
|
stuck_detection_thresholds: (
|
|
@@ -96,6 +99,7 @@ class Conversation:
|
|
|
96
99
|
conversation_id: ConversationID | None = None,
|
|
97
100
|
callbacks: list[ConversationCallbackType] | None = None,
|
|
98
101
|
token_callbacks: list[ConversationTokenCallbackType] | None = None,
|
|
102
|
+
hook_config: HookConfig | None = None,
|
|
99
103
|
max_iteration_per_run: int = 500,
|
|
100
104
|
stuck_detection: bool = True,
|
|
101
105
|
stuck_detection_thresholds: (
|
|
@@ -123,6 +127,7 @@ class Conversation:
|
|
|
123
127
|
conversation_id=conversation_id,
|
|
124
128
|
callbacks=callbacks,
|
|
125
129
|
token_callbacks=token_callbacks,
|
|
130
|
+
hook_config=hook_config,
|
|
126
131
|
max_iteration_per_run=max_iteration_per_run,
|
|
127
132
|
stuck_detection=stuck_detection,
|
|
128
133
|
stuck_detection_thresholds=stuck_detection_thresholds,
|
|
@@ -136,6 +141,7 @@ class Conversation:
|
|
|
136
141
|
conversation_id=conversation_id,
|
|
137
142
|
callbacks=callbacks,
|
|
138
143
|
token_callbacks=token_callbacks,
|
|
144
|
+
hook_config=hook_config,
|
|
139
145
|
max_iteration_per_run=max_iteration_per_run,
|
|
140
146
|
stuck_detection=stuck_detection,
|
|
141
147
|
stuck_detection_thresholds=stuck_detection_thresholds,
|
|
@@ -31,6 +31,7 @@ from openhands.sdk.event import (
|
|
|
31
31
|
UserRejectObservation,
|
|
32
32
|
)
|
|
33
33
|
from openhands.sdk.event.conversation_error import ConversationErrorEvent
|
|
34
|
+
from openhands.sdk.hooks import HookConfig, HookEventProcessor, create_hook_callback
|
|
34
35
|
from openhands.sdk.llm import LLM, Message, TextContent
|
|
35
36
|
from openhands.sdk.llm.llm_registry import LLMRegistry
|
|
36
37
|
from openhands.sdk.logger import get_logger
|
|
@@ -56,6 +57,7 @@ class LocalConversation(BaseConversation):
|
|
|
56
57
|
_stuck_detector: StuckDetector | None
|
|
57
58
|
llm_registry: LLMRegistry
|
|
58
59
|
_cleanup_initiated: bool
|
|
60
|
+
_hook_processor: HookEventProcessor | None
|
|
59
61
|
|
|
60
62
|
def __init__(
|
|
61
63
|
self,
|
|
@@ -65,6 +67,7 @@ class LocalConversation(BaseConversation):
|
|
|
65
67
|
conversation_id: ConversationID | None = None,
|
|
66
68
|
callbacks: list[ConversationCallbackType] | None = None,
|
|
67
69
|
token_callbacks: list[ConversationTokenCallbackType] | None = None,
|
|
70
|
+
hook_config: HookConfig | None = None,
|
|
68
71
|
max_iteration_per_run: int = 500,
|
|
69
72
|
stuck_detection: bool = True,
|
|
70
73
|
stuck_detection_thresholds: (
|
|
@@ -89,6 +92,7 @@ class LocalConversation(BaseConversation):
|
|
|
89
92
|
suffix their persistent filestore with this ID.
|
|
90
93
|
callbacks: Optional list of callback functions to handle events
|
|
91
94
|
token_callbacks: Optional list of callbacks invoked for streaming deltas
|
|
95
|
+
hook_config: Optional hook configuration to auto-wire session hooks
|
|
92
96
|
max_iteration_per_run: Maximum number of iterations per run
|
|
93
97
|
visualizer: Visualization configuration. Can be:
|
|
94
98
|
- ConversationVisualizerBase subclass: Class to instantiate
|
|
@@ -136,7 +140,20 @@ class LocalConversation(BaseConversation):
|
|
|
136
140
|
def _default_callback(e):
|
|
137
141
|
self._state.events.append(e)
|
|
138
142
|
|
|
139
|
-
|
|
143
|
+
self._hook_processor = None
|
|
144
|
+
hook_callback = None
|
|
145
|
+
if hook_config is not None:
|
|
146
|
+
self._hook_processor, hook_callback = create_hook_callback(
|
|
147
|
+
hook_config=hook_config,
|
|
148
|
+
working_dir=str(self.workspace.working_dir),
|
|
149
|
+
session_id=str(desired_id),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
callback_list = list(callbacks) if callbacks else []
|
|
153
|
+
if hook_callback is not None:
|
|
154
|
+
callback_list.insert(0, hook_callback)
|
|
155
|
+
|
|
156
|
+
composed_list = callback_list + [_default_callback]
|
|
140
157
|
# Handle visualization configuration
|
|
141
158
|
if isinstance(visualizer, ConversationVisualizerBase):
|
|
142
159
|
# Use custom visualizer instance
|
|
@@ -183,6 +200,10 @@ class LocalConversation(BaseConversation):
|
|
|
183
200
|
else:
|
|
184
201
|
self._stuck_detector = None
|
|
185
202
|
|
|
203
|
+
if self._hook_processor is not None:
|
|
204
|
+
self._hook_processor.set_conversation_state(self._state)
|
|
205
|
+
self._hook_processor.run_session_start()
|
|
206
|
+
|
|
186
207
|
with self._state:
|
|
187
208
|
self.agent.init_state(self._state, on_event=self._on_event)
|
|
188
209
|
|
|
@@ -458,16 +479,25 @@ class LocalConversation(BaseConversation):
|
|
|
458
479
|
|
|
459
480
|
def close(self) -> None:
|
|
460
481
|
"""Close the conversation and clean up all tool executors."""
|
|
461
|
-
|
|
482
|
+
# Use getattr for safety - object may be partially constructed
|
|
483
|
+
if getattr(self, "_cleanup_initiated", False):
|
|
462
484
|
return
|
|
463
485
|
self._cleanup_initiated = True
|
|
464
486
|
logger.debug("Closing conversation and cleaning up tool executors")
|
|
487
|
+
hook_processor = getattr(self, "_hook_processor", None)
|
|
488
|
+
if hook_processor is not None:
|
|
489
|
+
hook_processor.run_session_end()
|
|
465
490
|
try:
|
|
466
491
|
self._end_observability_span()
|
|
467
492
|
except AttributeError:
|
|
468
493
|
# Object may be partially constructed; span fields may be missing.
|
|
469
494
|
pass
|
|
470
|
-
|
|
495
|
+
try:
|
|
496
|
+
tools_map = self.agent.tools_map
|
|
497
|
+
except (AttributeError, RuntimeError):
|
|
498
|
+
# Agent not initialized or partially constructed
|
|
499
|
+
return
|
|
500
|
+
for tool in tools_map.values():
|
|
471
501
|
try:
|
|
472
502
|
executable_tool = tool.as_executable()
|
|
473
503
|
executable_tool.executor.close()
|
|
@@ -33,6 +33,12 @@ from openhands.sdk.event.conversation_state import (
|
|
|
33
33
|
ConversationStateUpdateEvent,
|
|
34
34
|
)
|
|
35
35
|
from openhands.sdk.event.llm_completion_log import LLMCompletionLogEvent
|
|
36
|
+
from openhands.sdk.hooks import (
|
|
37
|
+
HookConfig,
|
|
38
|
+
HookEventProcessor,
|
|
39
|
+
HookEventType,
|
|
40
|
+
HookManager,
|
|
41
|
+
)
|
|
36
42
|
from openhands.sdk.llm import LLM, Message, TextContent
|
|
37
43
|
from openhands.sdk.logger import DEBUG, get_logger
|
|
38
44
|
from openhands.sdk.observability.laminar import observe
|
|
@@ -430,6 +436,8 @@ class RemoteConversation(BaseConversation):
|
|
|
430
436
|
max_iteration_per_run: int
|
|
431
437
|
workspace: RemoteWorkspace
|
|
432
438
|
_client: httpx.Client
|
|
439
|
+
_hook_processor: HookEventProcessor | None
|
|
440
|
+
_cleanup_initiated: bool
|
|
433
441
|
|
|
434
442
|
def __init__(
|
|
435
443
|
self,
|
|
@@ -442,6 +450,7 @@ class RemoteConversation(BaseConversation):
|
|
|
442
450
|
stuck_detection_thresholds: (
|
|
443
451
|
StuckDetectionThresholds | Mapping[str, int] | None
|
|
444
452
|
) = None,
|
|
453
|
+
hook_config: HookConfig | None = None,
|
|
445
454
|
visualizer: (
|
|
446
455
|
type[ConversationVisualizerBase] | ConversationVisualizerBase | None
|
|
447
456
|
) = DefaultConversationVisualizer,
|
|
@@ -462,6 +471,7 @@ class RemoteConversation(BaseConversation):
|
|
|
462
471
|
a dict with keys: 'action_observation', 'action_error',
|
|
463
472
|
'monologue', 'alternating_pattern'. Values are integers
|
|
464
473
|
representing the number of repetitions before triggering.
|
|
474
|
+
hook_config: Optional hook configuration for session hooks
|
|
465
475
|
visualizer: Visualization configuration. Can be:
|
|
466
476
|
- ConversationVisualizerBase subclass: Class to instantiate
|
|
467
477
|
(default: ConversationVisualizer)
|
|
@@ -475,6 +485,8 @@ class RemoteConversation(BaseConversation):
|
|
|
475
485
|
self.max_iteration_per_run = max_iteration_per_run
|
|
476
486
|
self.workspace = workspace
|
|
477
487
|
self._client = workspace.client
|
|
488
|
+
self._hook_processor = None
|
|
489
|
+
self._cleanup_initiated = False
|
|
478
490
|
|
|
479
491
|
if conversation_id is None:
|
|
480
492
|
payload = {
|
|
@@ -570,6 +582,25 @@ class RemoteConversation(BaseConversation):
|
|
|
570
582
|
self.update_secrets(secret_values)
|
|
571
583
|
|
|
572
584
|
self._start_observability_span(str(self._id))
|
|
585
|
+
if hook_config is not None:
|
|
586
|
+
unsupported = (
|
|
587
|
+
HookEventType.PRE_TOOL_USE,
|
|
588
|
+
HookEventType.POST_TOOL_USE,
|
|
589
|
+
HookEventType.USER_PROMPT_SUBMIT,
|
|
590
|
+
HookEventType.STOP,
|
|
591
|
+
)
|
|
592
|
+
if any(hook_config.has_hooks_for_event(t) for t in unsupported):
|
|
593
|
+
logger.warning(
|
|
594
|
+
"RemoteConversation only supports SessionStart/SessionEnd hooks; "
|
|
595
|
+
"other hook types will not be enforced."
|
|
596
|
+
)
|
|
597
|
+
hook_manager = HookManager(
|
|
598
|
+
config=hook_config,
|
|
599
|
+
working_dir=os.getcwd(),
|
|
600
|
+
session_id=str(self._id),
|
|
601
|
+
)
|
|
602
|
+
self._hook_processor = HookEventProcessor(hook_manager=hook_manager)
|
|
603
|
+
self._hook_processor.run_session_start()
|
|
573
604
|
|
|
574
605
|
def _create_llm_completion_log_callback(self) -> ConversationCallbackType:
|
|
575
606
|
"""Create a callback that writes LLM completion logs to client filesystem."""
|
|
@@ -866,6 +897,11 @@ class RemoteConversation(BaseConversation):
|
|
|
866
897
|
The workspace owns the client and will close it during its own cleanup.
|
|
867
898
|
Closing it here would prevent the workspace from making cleanup API calls.
|
|
868
899
|
"""
|
|
900
|
+
if self._cleanup_initiated:
|
|
901
|
+
return
|
|
902
|
+
self._cleanup_initiated = True
|
|
903
|
+
if self._hook_processor is not None:
|
|
904
|
+
self._hook_processor.run_session_end()
|
|
869
905
|
try:
|
|
870
906
|
# Stop WebSocket client if it exists
|
|
871
907
|
if self._ws_client:
|