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
|
@@ -94,6 +94,18 @@ class ConversationState(OpenHandsModel):
|
|
|
94
94
|
description="List of activated knowledge skills name",
|
|
95
95
|
)
|
|
96
96
|
|
|
97
|
+
# Hook-blocked actions: action_id -> blocking reason
|
|
98
|
+
blocked_actions: dict[str, str] = Field(
|
|
99
|
+
default_factory=dict,
|
|
100
|
+
description="Actions blocked by PreToolUse hooks, keyed by action ID",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Hook-blocked messages: message_id -> blocking reason
|
|
104
|
+
blocked_messages: dict[str, str] = Field(
|
|
105
|
+
default_factory=dict,
|
|
106
|
+
description="Messages blocked by UserPromptSubmit hooks, keyed by message ID",
|
|
107
|
+
)
|
|
108
|
+
|
|
97
109
|
# Conversation statistics for LLM usage tracking
|
|
98
110
|
stats: ConversationStats = Field(
|
|
99
111
|
default_factory=ConversationStats,
|
|
@@ -167,7 +179,9 @@ class ConversationState(OpenHandsModel):
|
|
|
167
179
|
Else: create fresh (agent required), persist base, and return.
|
|
168
180
|
"""
|
|
169
181
|
file_store = (
|
|
170
|
-
LocalFileStore(persistence_dir
|
|
182
|
+
LocalFileStore(persistence_dir, cache_limit_size=max_iterations)
|
|
183
|
+
if persistence_dir
|
|
184
|
+
else InMemoryFileStore()
|
|
171
185
|
)
|
|
172
186
|
|
|
173
187
|
try:
|
|
@@ -276,6 +290,32 @@ class ConversationState(OpenHandsModel):
|
|
|
276
290
|
f"State change callback failed for field {name}", exc_info=True
|
|
277
291
|
)
|
|
278
292
|
|
|
293
|
+
def block_action(self, action_id: str, reason: str) -> None:
|
|
294
|
+
"""Persistently record a hook-blocked action."""
|
|
295
|
+
self.blocked_actions = {**self.blocked_actions, action_id: reason}
|
|
296
|
+
|
|
297
|
+
def pop_blocked_action(self, action_id: str) -> str | None:
|
|
298
|
+
"""Remove and return a hook-blocked action reason, if present."""
|
|
299
|
+
if action_id not in self.blocked_actions:
|
|
300
|
+
return None
|
|
301
|
+
updated = dict(self.blocked_actions)
|
|
302
|
+
reason = updated.pop(action_id)
|
|
303
|
+
self.blocked_actions = updated
|
|
304
|
+
return reason
|
|
305
|
+
|
|
306
|
+
def block_message(self, message_id: str, reason: str) -> None:
|
|
307
|
+
"""Persistently record a hook-blocked user message."""
|
|
308
|
+
self.blocked_messages = {**self.blocked_messages, message_id: reason}
|
|
309
|
+
|
|
310
|
+
def pop_blocked_message(self, message_id: str) -> str | None:
|
|
311
|
+
"""Remove and return a hook-blocked message reason, if present."""
|
|
312
|
+
if message_id not in self.blocked_messages:
|
|
313
|
+
return None
|
|
314
|
+
updated = dict(self.blocked_messages)
|
|
315
|
+
reason = updated.pop(message_id)
|
|
316
|
+
self.blocked_messages = updated
|
|
317
|
+
return reason
|
|
318
|
+
|
|
279
319
|
@staticmethod
|
|
280
320
|
def get_unmatched_actions(events: Sequence[Event]) -> list[ActionEvent]:
|
|
281
321
|
"""Find actions in the event history that don't have matching observations.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenHands Hooks System - Event-driven hooks for automation and control.
|
|
3
|
+
|
|
4
|
+
Hooks are event-driven scripts that execute at specific lifecycle events
|
|
5
|
+
during agent execution, enabling deterministic control over agent behavior.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from openhands.sdk.hooks.config import HookConfig, HookDefinition, HookMatcher
|
|
9
|
+
from openhands.sdk.hooks.conversation_hooks import (
|
|
10
|
+
HookEventProcessor,
|
|
11
|
+
create_hook_callback,
|
|
12
|
+
)
|
|
13
|
+
from openhands.sdk.hooks.executor import HookExecutor, HookResult
|
|
14
|
+
from openhands.sdk.hooks.manager import HookManager
|
|
15
|
+
from openhands.sdk.hooks.types import HookDecision, HookEvent, HookEventType
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"HookConfig",
|
|
20
|
+
"HookDefinition",
|
|
21
|
+
"HookMatcher",
|
|
22
|
+
"HookExecutor",
|
|
23
|
+
"HookResult",
|
|
24
|
+
"HookManager",
|
|
25
|
+
"HookEvent",
|
|
26
|
+
"HookEventType",
|
|
27
|
+
"HookDecision",
|
|
28
|
+
"HookEventProcessor",
|
|
29
|
+
"create_hook_callback",
|
|
30
|
+
]
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Hook configuration loading and management."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from openhands.sdk.hooks.types import HookEventType
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HookType(str, Enum):
|
|
19
|
+
"""Types of hooks that can be executed."""
|
|
20
|
+
|
|
21
|
+
COMMAND = "command" # Shell command executed via subprocess
|
|
22
|
+
PROMPT = "prompt" # LLM-based evaluation (future)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HookDefinition(BaseModel):
|
|
26
|
+
"""A single hook definition."""
|
|
27
|
+
|
|
28
|
+
type: HookType = HookType.COMMAND
|
|
29
|
+
command: str
|
|
30
|
+
timeout: int = 60
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HookMatcher(BaseModel):
|
|
34
|
+
"""Matches events to hooks based on patterns.
|
|
35
|
+
|
|
36
|
+
Supports exact match, wildcard (*), and regex (auto-detected or /pattern/).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
matcher: str = "*"
|
|
40
|
+
hooks: list[HookDefinition] = Field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
# Regex metacharacters that indicate a pattern should be treated as regex
|
|
43
|
+
_REGEX_METACHARACTERS = set("|.*+?[]()^$\\")
|
|
44
|
+
|
|
45
|
+
def matches(self, tool_name: str | None) -> bool:
|
|
46
|
+
"""Check if this matcher matches the given tool name."""
|
|
47
|
+
# Wildcard matches everything
|
|
48
|
+
if self.matcher == "*" or self.matcher == "":
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
if tool_name is None:
|
|
52
|
+
return self.matcher in ("*", "")
|
|
53
|
+
|
|
54
|
+
# Check for explicit regex pattern (enclosed in /)
|
|
55
|
+
is_regex = (
|
|
56
|
+
self.matcher.startswith("/")
|
|
57
|
+
and self.matcher.endswith("/")
|
|
58
|
+
and len(self.matcher) > 2
|
|
59
|
+
)
|
|
60
|
+
if is_regex:
|
|
61
|
+
pattern = self.matcher[1:-1]
|
|
62
|
+
try:
|
|
63
|
+
return bool(re.fullmatch(pattern, tool_name))
|
|
64
|
+
except re.error:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
# Auto-detect regex: if matcher contains metacharacters, treat as regex
|
|
68
|
+
if any(c in self.matcher for c in self._REGEX_METACHARACTERS):
|
|
69
|
+
try:
|
|
70
|
+
return bool(re.fullmatch(self.matcher, tool_name))
|
|
71
|
+
except re.error:
|
|
72
|
+
# Invalid regex, fall through to exact match
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
# Exact match
|
|
76
|
+
return self.matcher == tool_name
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class HookConfig(BaseModel):
|
|
80
|
+
"""Configuration for all hooks, loaded from .openhands/hooks.json."""
|
|
81
|
+
|
|
82
|
+
hooks: dict[str, list[HookMatcher]] = Field(default_factory=dict)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def load(
|
|
86
|
+
cls, path: str | Path | None = None, working_dir: str | Path | None = None
|
|
87
|
+
) -> "HookConfig":
|
|
88
|
+
"""Load config from path or search .openhands/hooks.json locations.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
path: Explicit path to hooks.json file. If provided, working_dir is ignored.
|
|
92
|
+
working_dir: Project directory for discovering .openhands/hooks.json.
|
|
93
|
+
Falls back to cwd if not provided.
|
|
94
|
+
"""
|
|
95
|
+
if path is None:
|
|
96
|
+
# Search for hooks.json in standard locations
|
|
97
|
+
base_dir = Path(working_dir) if working_dir else Path.cwd()
|
|
98
|
+
search_paths = [
|
|
99
|
+
base_dir / ".openhands" / "hooks.json",
|
|
100
|
+
Path.home() / ".openhands" / "hooks.json",
|
|
101
|
+
]
|
|
102
|
+
for search_path in search_paths:
|
|
103
|
+
if search_path.exists():
|
|
104
|
+
path = search_path
|
|
105
|
+
break
|
|
106
|
+
|
|
107
|
+
if path is None:
|
|
108
|
+
return cls()
|
|
109
|
+
|
|
110
|
+
path = Path(path)
|
|
111
|
+
if not path.exists():
|
|
112
|
+
return cls()
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
with open(path) as f:
|
|
116
|
+
data = json.load(f)
|
|
117
|
+
return cls.from_dict(data)
|
|
118
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
119
|
+
# Log warning but don't fail - just return empty config
|
|
120
|
+
logger.warning(f"Failed to load hooks from {path}: {e}")
|
|
121
|
+
return cls()
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def from_dict(cls, data: dict[str, Any]) -> "HookConfig":
|
|
125
|
+
"""Create HookConfig from a dictionary."""
|
|
126
|
+
hooks_data = data.get("hooks", {})
|
|
127
|
+
hooks: dict[str, list[HookMatcher]] = {}
|
|
128
|
+
|
|
129
|
+
for event_type, matchers in hooks_data.items():
|
|
130
|
+
if not isinstance(matchers, list):
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
hooks[event_type] = []
|
|
134
|
+
for matcher_data in matchers:
|
|
135
|
+
if isinstance(matcher_data, dict):
|
|
136
|
+
# Parse hooks within the matcher
|
|
137
|
+
hook_defs = []
|
|
138
|
+
for hook_data in matcher_data.get("hooks", []):
|
|
139
|
+
if isinstance(hook_data, dict):
|
|
140
|
+
hook_defs.append(HookDefinition(**hook_data))
|
|
141
|
+
|
|
142
|
+
hooks[event_type].append(
|
|
143
|
+
HookMatcher(
|
|
144
|
+
matcher=matcher_data.get("matcher", "*"),
|
|
145
|
+
hooks=hook_defs,
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return cls(hooks=hooks)
|
|
150
|
+
|
|
151
|
+
def get_hooks_for_event(
|
|
152
|
+
self, event_type: HookEventType, tool_name: str | None = None
|
|
153
|
+
) -> list[HookDefinition]:
|
|
154
|
+
"""Get all hooks that should run for an event."""
|
|
155
|
+
event_key = event_type.value
|
|
156
|
+
matchers = self.hooks.get(event_key, [])
|
|
157
|
+
|
|
158
|
+
result: list[HookDefinition] = []
|
|
159
|
+
for matcher in matchers:
|
|
160
|
+
if matcher.matches(tool_name):
|
|
161
|
+
result.extend(matcher.hooks)
|
|
162
|
+
|
|
163
|
+
return result
|
|
164
|
+
|
|
165
|
+
def has_hooks_for_event(self, event_type: HookEventType) -> bool:
|
|
166
|
+
"""Check if there are any hooks configured for an event type."""
|
|
167
|
+
return event_type.value in self.hooks and len(self.hooks[event_type.value]) > 0
|
|
168
|
+
|
|
169
|
+
def to_dict(self) -> dict[str, Any]:
|
|
170
|
+
"""Convert to dictionary format for serialization."""
|
|
171
|
+
hooks_dict = {k: [m.model_dump() for m in v] for k, v in self.hooks.items()}
|
|
172
|
+
return {"hooks": hooks_dict}
|
|
173
|
+
|
|
174
|
+
def save(self, path: str | Path) -> None:
|
|
175
|
+
"""Save hook configuration to a JSON file."""
|
|
176
|
+
path = Path(path)
|
|
177
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
|
|
179
|
+
with open(path, "w") as f:
|
|
180
|
+
json.dump(self.to_dict(), f, indent=2)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Hook integration for conversations."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from openhands.sdk.event import ActionEvent, Event, MessageEvent, ObservationEvent
|
|
6
|
+
from openhands.sdk.hooks.config import HookConfig
|
|
7
|
+
from openhands.sdk.hooks.manager import HookManager
|
|
8
|
+
from openhands.sdk.hooks.types import HookEventType
|
|
9
|
+
from openhands.sdk.logger import get_logger
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from openhands.sdk.conversation.state import ConversationState
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HookEventProcessor:
|
|
19
|
+
"""Processes events and runs hooks at appropriate points.
|
|
20
|
+
|
|
21
|
+
Call set_conversation_state() after creating Conversation for blocking to work.
|
|
22
|
+
|
|
23
|
+
Note on persistence: HookEvent/HookResult are ephemeral (for hook script I/O).
|
|
24
|
+
If hook execution traces need to be persisted (e.g., for observability), create
|
|
25
|
+
a HookExecutionObservation inheriting from Observation and emit it through the
|
|
26
|
+
event stream, rather than modifying these hook classes.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
hook_manager: HookManager,
|
|
32
|
+
original_callback: Any = None,
|
|
33
|
+
):
|
|
34
|
+
self.hook_manager = hook_manager
|
|
35
|
+
self.original_callback = original_callback
|
|
36
|
+
self._conversation_state: ConversationState | None = None
|
|
37
|
+
|
|
38
|
+
def set_conversation_state(self, state: "ConversationState") -> None:
|
|
39
|
+
"""Set conversation state for blocking support."""
|
|
40
|
+
self._conversation_state = state
|
|
41
|
+
|
|
42
|
+
def on_event(self, event: Event) -> None:
|
|
43
|
+
"""Process an event and run appropriate hooks."""
|
|
44
|
+
# Run PreToolUse hooks for action events
|
|
45
|
+
if isinstance(event, ActionEvent) and event.action is not None:
|
|
46
|
+
self._handle_pre_tool_use(event)
|
|
47
|
+
|
|
48
|
+
# Run PostToolUse hooks for observation events
|
|
49
|
+
if isinstance(event, ObservationEvent):
|
|
50
|
+
self._handle_post_tool_use(event)
|
|
51
|
+
|
|
52
|
+
# Run UserPromptSubmit hooks for user messages
|
|
53
|
+
if isinstance(event, MessageEvent) and event.source == "user":
|
|
54
|
+
self._handle_user_prompt_submit(event)
|
|
55
|
+
|
|
56
|
+
# Call original callback
|
|
57
|
+
if self.original_callback:
|
|
58
|
+
self.original_callback(event)
|
|
59
|
+
|
|
60
|
+
def _handle_pre_tool_use(self, event: ActionEvent) -> None:
|
|
61
|
+
"""Handle PreToolUse hooks. Blocked actions are marked in conversation state."""
|
|
62
|
+
if not self.hook_manager.has_hooks(HookEventType.PRE_TOOL_USE):
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
tool_name = event.tool_name
|
|
66
|
+
tool_input = {}
|
|
67
|
+
|
|
68
|
+
# Extract tool input from action
|
|
69
|
+
if event.action is not None:
|
|
70
|
+
try:
|
|
71
|
+
tool_input = event.action.model_dump()
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.debug(f"Could not extract tool input: {e}")
|
|
74
|
+
|
|
75
|
+
should_continue, results = self.hook_manager.run_pre_tool_use(
|
|
76
|
+
tool_name=tool_name,
|
|
77
|
+
tool_input=tool_input,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if not should_continue:
|
|
81
|
+
reason = self.hook_manager.get_blocking_reason(results)
|
|
82
|
+
logger.warning(f"Hook blocked action {tool_name}: {reason}")
|
|
83
|
+
|
|
84
|
+
# Mark this action as blocked in the conversation state
|
|
85
|
+
# The Agent will check this and emit a rejection instead of executing
|
|
86
|
+
if self._conversation_state is not None:
|
|
87
|
+
block_reason = reason or "Blocked by hook"
|
|
88
|
+
self._conversation_state.block_action(event.id, block_reason)
|
|
89
|
+
else:
|
|
90
|
+
logger.warning(
|
|
91
|
+
"Cannot block action: conversation state not set. "
|
|
92
|
+
"Call processor.set_conversation_state(conversation.state) "
|
|
93
|
+
"after creating the Conversation."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def _handle_post_tool_use(self, event: ObservationEvent) -> None:
|
|
97
|
+
"""Handle PostToolUse hooks after an action completes."""
|
|
98
|
+
if not self.hook_manager.has_hooks(HookEventType.POST_TOOL_USE):
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
# O(1) lookup of corresponding action from state events
|
|
102
|
+
action_event = None
|
|
103
|
+
if self._conversation_state is not None:
|
|
104
|
+
try:
|
|
105
|
+
idx = self._conversation_state.events.get_index(event.action_id)
|
|
106
|
+
event_at_idx = self._conversation_state.events[idx]
|
|
107
|
+
if isinstance(event_at_idx, ActionEvent):
|
|
108
|
+
action_event = event_at_idx
|
|
109
|
+
except KeyError:
|
|
110
|
+
pass # action not found
|
|
111
|
+
|
|
112
|
+
if action_event is None:
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
tool_name = event.tool_name
|
|
116
|
+
tool_input: dict[str, Any] = {}
|
|
117
|
+
tool_response: dict[str, Any] = {}
|
|
118
|
+
|
|
119
|
+
# Extract tool input from action
|
|
120
|
+
if action_event.action is not None:
|
|
121
|
+
try:
|
|
122
|
+
tool_input = action_event.action.model_dump()
|
|
123
|
+
except Exception as e:
|
|
124
|
+
logger.debug(f"Could not extract tool input: {e}")
|
|
125
|
+
|
|
126
|
+
# Extract structured tool response from observation
|
|
127
|
+
if event.observation is not None:
|
|
128
|
+
try:
|
|
129
|
+
tool_response = event.observation.model_dump()
|
|
130
|
+
except Exception as e:
|
|
131
|
+
logger.debug(f"Could not extract tool response: {e}")
|
|
132
|
+
|
|
133
|
+
results = self.hook_manager.run_post_tool_use(
|
|
134
|
+
tool_name=tool_name,
|
|
135
|
+
tool_input=tool_input,
|
|
136
|
+
tool_response=tool_response,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Log any hook errors
|
|
140
|
+
for result in results:
|
|
141
|
+
if result.error:
|
|
142
|
+
logger.warning(f"PostToolUse hook error: {result.error}")
|
|
143
|
+
|
|
144
|
+
def _handle_user_prompt_submit(self, event: MessageEvent) -> None:
|
|
145
|
+
"""Handle UserPromptSubmit hooks before processing a user message."""
|
|
146
|
+
if not self.hook_manager.has_hooks(HookEventType.USER_PROMPT_SUBMIT):
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
# Extract message text
|
|
150
|
+
message = ""
|
|
151
|
+
if event.llm_message and event.llm_message.content:
|
|
152
|
+
from openhands.sdk.llm import TextContent
|
|
153
|
+
|
|
154
|
+
for content in event.llm_message.content:
|
|
155
|
+
if isinstance(content, TextContent):
|
|
156
|
+
message += content.text
|
|
157
|
+
|
|
158
|
+
should_continue, additional_context, results = (
|
|
159
|
+
self.hook_manager.run_user_prompt_submit(message=message)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if not should_continue:
|
|
163
|
+
reason = self.hook_manager.get_blocking_reason(results)
|
|
164
|
+
logger.warning(f"Hook blocked user message: {reason}")
|
|
165
|
+
|
|
166
|
+
# Mark this message as blocked in the conversation state
|
|
167
|
+
# The Agent will check this and skip processing the message
|
|
168
|
+
if self._conversation_state is not None:
|
|
169
|
+
block_reason = reason or "Blocked by hook"
|
|
170
|
+
self._conversation_state.block_message(event.id, block_reason)
|
|
171
|
+
else:
|
|
172
|
+
logger.warning(
|
|
173
|
+
"Cannot block message: conversation state not set. "
|
|
174
|
+
"Call processor.set_conversation_state(conversation.state) "
|
|
175
|
+
"after creating the Conversation."
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# TODO: Inject additional_context into the message
|
|
179
|
+
if additional_context:
|
|
180
|
+
logger.info(f"Hook injected context: {additional_context[:100]}...")
|
|
181
|
+
|
|
182
|
+
def is_action_blocked(self, action_id: str) -> bool:
|
|
183
|
+
"""Check if an action was blocked by a hook."""
|
|
184
|
+
if self._conversation_state is None:
|
|
185
|
+
return False
|
|
186
|
+
return action_id in self._conversation_state.blocked_actions
|
|
187
|
+
|
|
188
|
+
def is_message_blocked(self, message_id: str) -> bool:
|
|
189
|
+
"""Check if a message was blocked by a hook."""
|
|
190
|
+
if self._conversation_state is None:
|
|
191
|
+
return False
|
|
192
|
+
return message_id in self._conversation_state.blocked_messages
|
|
193
|
+
|
|
194
|
+
def run_session_start(self) -> None:
|
|
195
|
+
"""Run SessionStart hooks. Call after conversation is created."""
|
|
196
|
+
results = self.hook_manager.run_session_start()
|
|
197
|
+
for r in results:
|
|
198
|
+
if r.error:
|
|
199
|
+
logger.warning(f"SessionStart hook error: {r.error}")
|
|
200
|
+
|
|
201
|
+
def run_session_end(self) -> None:
|
|
202
|
+
"""Run SessionEnd hooks. Call before conversation is closed."""
|
|
203
|
+
results = self.hook_manager.run_session_end()
|
|
204
|
+
for r in results:
|
|
205
|
+
if r.error:
|
|
206
|
+
logger.warning(f"SessionEnd hook error: {r.error}")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def create_hook_callback(
|
|
210
|
+
hook_config: HookConfig | None = None,
|
|
211
|
+
working_dir: str | None = None,
|
|
212
|
+
session_id: str | None = None,
|
|
213
|
+
original_callback: Any = None,
|
|
214
|
+
) -> tuple[HookEventProcessor, Any]:
|
|
215
|
+
"""Create a hook-enabled event callback. Returns (processor, callback)."""
|
|
216
|
+
hook_manager = HookManager(
|
|
217
|
+
config=hook_config,
|
|
218
|
+
working_dir=working_dir,
|
|
219
|
+
session_id=session_id,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
processor = HookEventProcessor(
|
|
223
|
+
hook_manager=hook_manager,
|
|
224
|
+
original_callback=original_callback,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
return processor, processor.on_event
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Hook executor - runs shell commands with JSON I/O."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from openhands.sdk.hooks.config import HookDefinition
|
|
10
|
+
from openhands.sdk.hooks.types import HookDecision, HookEvent
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HookResult(BaseModel):
|
|
14
|
+
"""Result from executing a hook.
|
|
15
|
+
|
|
16
|
+
Exit code 0 = success, exit code 2 = block operation.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
success: bool = True
|
|
20
|
+
blocked: bool = False
|
|
21
|
+
exit_code: int = 0
|
|
22
|
+
stdout: str = ""
|
|
23
|
+
stderr: str = ""
|
|
24
|
+
decision: HookDecision | None = None
|
|
25
|
+
reason: str | None = None
|
|
26
|
+
additional_context: str | None = None
|
|
27
|
+
error: str | None = None
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def should_continue(self) -> bool:
|
|
31
|
+
"""Whether the operation should continue after this hook."""
|
|
32
|
+
if self.blocked:
|
|
33
|
+
return False
|
|
34
|
+
if self.decision == HookDecision.DENY:
|
|
35
|
+
return False
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class HookExecutor:
|
|
40
|
+
"""Executes hook commands with JSON I/O."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, working_dir: str | None = None):
|
|
43
|
+
self.working_dir = working_dir or os.getcwd()
|
|
44
|
+
|
|
45
|
+
def execute(
|
|
46
|
+
self,
|
|
47
|
+
hook: HookDefinition,
|
|
48
|
+
event: HookEvent,
|
|
49
|
+
env: dict[str, str] | None = None,
|
|
50
|
+
) -> HookResult:
|
|
51
|
+
"""Execute a single hook."""
|
|
52
|
+
# Prepare environment
|
|
53
|
+
hook_env = os.environ.copy()
|
|
54
|
+
hook_env["OPENHANDS_PROJECT_DIR"] = self.working_dir
|
|
55
|
+
hook_env["OPENHANDS_SESSION_ID"] = event.session_id or ""
|
|
56
|
+
hook_env["OPENHANDS_EVENT_TYPE"] = event.event_type
|
|
57
|
+
if event.tool_name:
|
|
58
|
+
hook_env["OPENHANDS_TOOL_NAME"] = event.tool_name
|
|
59
|
+
|
|
60
|
+
if env:
|
|
61
|
+
hook_env.update(env)
|
|
62
|
+
|
|
63
|
+
# Serialize event to JSON for stdin
|
|
64
|
+
event_json = event.model_dump_json()
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
# Execute the hook command
|
|
68
|
+
result = subprocess.run(
|
|
69
|
+
hook.command,
|
|
70
|
+
shell=True,
|
|
71
|
+
cwd=self.working_dir,
|
|
72
|
+
env=hook_env,
|
|
73
|
+
input=event_json,
|
|
74
|
+
capture_output=True,
|
|
75
|
+
text=True,
|
|
76
|
+
timeout=hook.timeout,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Parse the result
|
|
80
|
+
hook_result = HookResult(
|
|
81
|
+
success=result.returncode == 0,
|
|
82
|
+
blocked=result.returncode == 2,
|
|
83
|
+
exit_code=result.returncode,
|
|
84
|
+
stdout=result.stdout,
|
|
85
|
+
stderr=result.stderr,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Try to parse JSON from stdout
|
|
89
|
+
if result.stdout.strip():
|
|
90
|
+
try:
|
|
91
|
+
output_data = json.loads(result.stdout)
|
|
92
|
+
if isinstance(output_data, dict):
|
|
93
|
+
# Parse decision
|
|
94
|
+
if "decision" in output_data:
|
|
95
|
+
decision_str = output_data["decision"].lower()
|
|
96
|
+
if decision_str == "allow":
|
|
97
|
+
hook_result.decision = HookDecision.ALLOW
|
|
98
|
+
elif decision_str == "deny":
|
|
99
|
+
hook_result.decision = HookDecision.DENY
|
|
100
|
+
hook_result.blocked = True
|
|
101
|
+
|
|
102
|
+
# Parse other fields
|
|
103
|
+
if "reason" in output_data:
|
|
104
|
+
hook_result.reason = str(output_data["reason"])
|
|
105
|
+
if "additionalContext" in output_data:
|
|
106
|
+
hook_result.additional_context = str(
|
|
107
|
+
output_data["additionalContext"]
|
|
108
|
+
)
|
|
109
|
+
if "continue" in output_data:
|
|
110
|
+
if not output_data["continue"]:
|
|
111
|
+
hook_result.blocked = True
|
|
112
|
+
|
|
113
|
+
except json.JSONDecodeError:
|
|
114
|
+
# Not JSON, that's okay - just use stdout as-is
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
return hook_result
|
|
118
|
+
|
|
119
|
+
except subprocess.TimeoutExpired:
|
|
120
|
+
return HookResult(
|
|
121
|
+
success=False,
|
|
122
|
+
exit_code=-1,
|
|
123
|
+
error=f"Hook timed out after {hook.timeout} seconds",
|
|
124
|
+
)
|
|
125
|
+
except FileNotFoundError as e:
|
|
126
|
+
return HookResult(
|
|
127
|
+
success=False,
|
|
128
|
+
exit_code=-1,
|
|
129
|
+
error=f"Hook command not found: {e}",
|
|
130
|
+
)
|
|
131
|
+
except Exception as e:
|
|
132
|
+
return HookResult(
|
|
133
|
+
success=False,
|
|
134
|
+
exit_code=-1,
|
|
135
|
+
error=f"Hook execution failed: {e}",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def execute_all(
|
|
139
|
+
self,
|
|
140
|
+
hooks: list[HookDefinition],
|
|
141
|
+
event: HookEvent,
|
|
142
|
+
env: dict[str, str] | None = None,
|
|
143
|
+
stop_on_block: bool = True,
|
|
144
|
+
) -> list[HookResult]:
|
|
145
|
+
"""Execute multiple hooks in order, optionally stopping on block."""
|
|
146
|
+
results: list[HookResult] = []
|
|
147
|
+
|
|
148
|
+
for hook in hooks:
|
|
149
|
+
result = self.execute(hook, event, env)
|
|
150
|
+
results.append(result)
|
|
151
|
+
|
|
152
|
+
if stop_on_block and result.blocked:
|
|
153
|
+
break
|
|
154
|
+
|
|
155
|
+
return results
|