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.
Files changed (29) hide show
  1. openhands/sdk/agent/agent.py +31 -1
  2. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +1 -2
  3. openhands/sdk/agent/utils.py +9 -4
  4. openhands/sdk/context/condenser/base.py +11 -6
  5. openhands/sdk/context/condenser/llm_summarizing_condenser.py +167 -18
  6. openhands/sdk/context/condenser/no_op_condenser.py +2 -1
  7. openhands/sdk/context/condenser/pipeline_condenser.py +10 -9
  8. openhands/sdk/context/condenser/utils.py +149 -0
  9. openhands/sdk/context/skills/skill.py +85 -0
  10. openhands/sdk/context/view.py +234 -37
  11. openhands/sdk/conversation/conversation.py +6 -0
  12. openhands/sdk/conversation/impl/local_conversation.py +33 -3
  13. openhands/sdk/conversation/impl/remote_conversation.py +36 -0
  14. openhands/sdk/conversation/state.py +41 -1
  15. openhands/sdk/hooks/__init__.py +30 -0
  16. openhands/sdk/hooks/config.py +180 -0
  17. openhands/sdk/hooks/conversation_hooks.py +227 -0
  18. openhands/sdk/hooks/executor.py +155 -0
  19. openhands/sdk/hooks/manager.py +170 -0
  20. openhands/sdk/hooks/types.py +40 -0
  21. openhands/sdk/io/cache.py +85 -0
  22. openhands/sdk/io/local.py +39 -2
  23. openhands/sdk/llm/mixins/fn_call_converter.py +61 -16
  24. openhands/sdk/llm/mixins/non_native_fc.py +5 -1
  25. openhands/sdk/tool/schema.py +10 -0
  26. {openhands_sdk-1.7.0.dist-info → openhands_sdk-1.7.1.dist-info}/METADATA +1 -1
  27. {openhands_sdk-1.7.0.dist-info → openhands_sdk-1.7.1.dist-info}/RECORD +29 -21
  28. {openhands_sdk-1.7.0.dist-info → openhands_sdk-1.7.1.dist-info}/WHEEL +0 -0
  29. {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
@@ -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
- batches, action_id_to_response_id, _ = View._build_action_batches(events)
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
- _, _, action_id_to_tool_call_id = View._build_action_batches(events)
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(action_id_to_tool_call_id[action_id])
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]) -> "View":
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
- composed_list = (callbacks if callbacks else []) + [_default_callback]
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
- if self._cleanup_initiated:
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
- for tool in self.agent.tools_map.values():
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: