openhands-sdk 1.5.0__py3-none-any.whl → 1.7.2__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/__init__.py +9 -1
- openhands/sdk/agent/agent.py +35 -12
- openhands/sdk/agent/base.py +53 -7
- openhands/sdk/agent/prompts/model_specific/anthropic_claude.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/google_gemini.j2 +1 -0
- openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +2 -0
- openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5.j2 +3 -0
- openhands/sdk/agent/prompts/self_documentation.j2 +15 -0
- openhands/sdk/agent/prompts/system_prompt.j2 +29 -1
- openhands/sdk/agent/utils.py +18 -4
- openhands/sdk/context/__init__.py +2 -0
- openhands/sdk/context/agent_context.py +42 -10
- openhands/sdk/context/condenser/base.py +11 -6
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +169 -20
- 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/prompts/prompt.py +40 -2
- openhands/sdk/context/prompts/templates/system_message_suffix.j2 +3 -3
- openhands/sdk/context/skills/__init__.py +2 -0
- openhands/sdk/context/skills/skill.py +152 -1
- openhands/sdk/context/view.py +287 -27
- openhands/sdk/conversation/base.py +17 -0
- openhands/sdk/conversation/conversation.py +19 -0
- openhands/sdk/conversation/exceptions.py +29 -4
- openhands/sdk/conversation/impl/local_conversation.py +126 -9
- openhands/sdk/conversation/impl/remote_conversation.py +152 -3
- openhands/sdk/conversation/state.py +42 -1
- openhands/sdk/conversation/stuck_detector.py +81 -45
- openhands/sdk/conversation/types.py +30 -0
- openhands/sdk/event/llm_convertible/system.py +16 -20
- 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/llm.py +3 -2
- openhands/sdk/llm/message.py +4 -3
- openhands/sdk/llm/mixins/fn_call_converter.py +61 -16
- openhands/sdk/llm/mixins/non_native_fc.py +5 -1
- openhands/sdk/llm/utils/model_features.py +64 -24
- openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
- openhands/sdk/llm/utils/verified_models.py +6 -4
- openhands/sdk/logger/logger.py +1 -1
- openhands/sdk/tool/schema.py +10 -0
- openhands/sdk/tool/tool.py +2 -2
- openhands/sdk/utils/async_executor.py +76 -67
- openhands/sdk/utils/models.py +1 -1
- openhands/sdk/utils/paging.py +63 -0
- {openhands_sdk-1.5.0.dist-info → openhands_sdk-1.7.2.dist-info}/METADATA +3 -3
- {openhands_sdk-1.5.0.dist-info → openhands_sdk-1.7.2.dist-info}/RECORD +56 -41
- {openhands_sdk-1.5.0.dist-info → openhands_sdk-1.7.2.dist-info}/WHEEL +0 -0
- {openhands_sdk-1.5.0.dist-info → openhands_sdk-1.7.2.dist-info}/top_level.txt +0 -0
|
@@ -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
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Hook manager - orchestrates hook execution within conversations."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from openhands.sdk.hooks.config import HookConfig
|
|
6
|
+
from openhands.sdk.hooks.executor import HookExecutor, HookResult
|
|
7
|
+
from openhands.sdk.hooks.types import HookEvent, HookEventType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HookManager:
|
|
11
|
+
"""Manages hook execution for a conversation."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
config: HookConfig | None = None,
|
|
16
|
+
working_dir: str | None = None,
|
|
17
|
+
session_id: str | None = None,
|
|
18
|
+
):
|
|
19
|
+
self.config = config or HookConfig.load(working_dir=working_dir)
|
|
20
|
+
self.executor = HookExecutor(working_dir=working_dir)
|
|
21
|
+
self.session_id = session_id
|
|
22
|
+
self.working_dir = working_dir
|
|
23
|
+
|
|
24
|
+
def _create_event(
|
|
25
|
+
self,
|
|
26
|
+
event_type: HookEventType,
|
|
27
|
+
tool_name: str | None = None,
|
|
28
|
+
tool_input: dict[str, Any] | None = None,
|
|
29
|
+
tool_response: dict[str, Any] | None = None,
|
|
30
|
+
message: str | None = None,
|
|
31
|
+
metadata: dict[str, Any] | None = None,
|
|
32
|
+
) -> HookEvent:
|
|
33
|
+
"""Create a hook event with common fields populated."""
|
|
34
|
+
return HookEvent(
|
|
35
|
+
event_type=event_type,
|
|
36
|
+
tool_name=tool_name,
|
|
37
|
+
tool_input=tool_input,
|
|
38
|
+
tool_response=tool_response,
|
|
39
|
+
message=message,
|
|
40
|
+
session_id=self.session_id,
|
|
41
|
+
working_dir=self.working_dir,
|
|
42
|
+
metadata=metadata or {},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def run_pre_tool_use(
|
|
46
|
+
self,
|
|
47
|
+
tool_name: str,
|
|
48
|
+
tool_input: dict[str, Any],
|
|
49
|
+
) -> tuple[bool, list[HookResult]]:
|
|
50
|
+
"""Run PreToolUse hooks. Returns (should_continue, results)."""
|
|
51
|
+
hooks = self.config.get_hooks_for_event(HookEventType.PRE_TOOL_USE, tool_name)
|
|
52
|
+
if not hooks:
|
|
53
|
+
return True, []
|
|
54
|
+
|
|
55
|
+
event = self._create_event(
|
|
56
|
+
HookEventType.PRE_TOOL_USE,
|
|
57
|
+
tool_name=tool_name,
|
|
58
|
+
tool_input=tool_input,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
results = self.executor.execute_all(hooks, event, stop_on_block=True)
|
|
62
|
+
|
|
63
|
+
# Check if any hook blocked the operation
|
|
64
|
+
should_continue = all(r.should_continue for r in results)
|
|
65
|
+
|
|
66
|
+
return should_continue, results
|
|
67
|
+
|
|
68
|
+
def run_post_tool_use(
|
|
69
|
+
self,
|
|
70
|
+
tool_name: str,
|
|
71
|
+
tool_input: dict[str, Any],
|
|
72
|
+
tool_response: dict[str, Any],
|
|
73
|
+
) -> list[HookResult]:
|
|
74
|
+
"""Run PostToolUse hooks after a tool completes."""
|
|
75
|
+
hooks = self.config.get_hooks_for_event(HookEventType.POST_TOOL_USE, tool_name)
|
|
76
|
+
if not hooks:
|
|
77
|
+
return []
|
|
78
|
+
|
|
79
|
+
event = self._create_event(
|
|
80
|
+
HookEventType.POST_TOOL_USE,
|
|
81
|
+
tool_name=tool_name,
|
|
82
|
+
tool_input=tool_input,
|
|
83
|
+
tool_response=tool_response,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# PostToolUse hooks don't block - they just run
|
|
87
|
+
return self.executor.execute_all(hooks, event, stop_on_block=False)
|
|
88
|
+
|
|
89
|
+
def run_user_prompt_submit(
|
|
90
|
+
self,
|
|
91
|
+
message: str,
|
|
92
|
+
) -> tuple[bool, str | None, list[HookResult]]:
|
|
93
|
+
"""Run UserPromptSubmit hooks."""
|
|
94
|
+
hooks = self.config.get_hooks_for_event(HookEventType.USER_PROMPT_SUBMIT)
|
|
95
|
+
if not hooks:
|
|
96
|
+
return True, None, []
|
|
97
|
+
|
|
98
|
+
event = self._create_event(
|
|
99
|
+
HookEventType.USER_PROMPT_SUBMIT,
|
|
100
|
+
message=message,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
results = self.executor.execute_all(hooks, event, stop_on_block=True)
|
|
104
|
+
|
|
105
|
+
# Check if any hook blocked
|
|
106
|
+
should_continue = all(r.should_continue for r in results)
|
|
107
|
+
|
|
108
|
+
# Collect additional context from hooks
|
|
109
|
+
additional_context_parts = [
|
|
110
|
+
r.additional_context for r in results if r.additional_context
|
|
111
|
+
]
|
|
112
|
+
additional_context = (
|
|
113
|
+
"\n".join(additional_context_parts) if additional_context_parts else None
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return should_continue, additional_context, results
|
|
117
|
+
|
|
118
|
+
def run_session_start(self) -> list[HookResult]:
|
|
119
|
+
"""Run SessionStart hooks when a conversation begins."""
|
|
120
|
+
hooks = self.config.get_hooks_for_event(HookEventType.SESSION_START)
|
|
121
|
+
if not hooks:
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
event = self._create_event(HookEventType.SESSION_START)
|
|
125
|
+
return self.executor.execute_all(hooks, event, stop_on_block=False)
|
|
126
|
+
|
|
127
|
+
def run_session_end(self) -> list[HookResult]:
|
|
128
|
+
"""Run SessionEnd hooks when a conversation ends."""
|
|
129
|
+
hooks = self.config.get_hooks_for_event(HookEventType.SESSION_END)
|
|
130
|
+
if not hooks:
|
|
131
|
+
return []
|
|
132
|
+
|
|
133
|
+
event = self._create_event(HookEventType.SESSION_END)
|
|
134
|
+
return self.executor.execute_all(hooks, event, stop_on_block=False)
|
|
135
|
+
|
|
136
|
+
def run_stop(
|
|
137
|
+
self,
|
|
138
|
+
reason: str | None = None,
|
|
139
|
+
) -> tuple[bool, list[HookResult]]:
|
|
140
|
+
"""Run Stop hooks. Returns (should_stop, results)."""
|
|
141
|
+
hooks = self.config.get_hooks_for_event(HookEventType.STOP)
|
|
142
|
+
if not hooks:
|
|
143
|
+
return True, []
|
|
144
|
+
|
|
145
|
+
event = self._create_event(
|
|
146
|
+
HookEventType.STOP,
|
|
147
|
+
metadata={"reason": reason} if reason else {},
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
results = self.executor.execute_all(hooks, event, stop_on_block=True)
|
|
151
|
+
|
|
152
|
+
# If a hook blocks, the agent should NOT stop (continue running)
|
|
153
|
+
should_stop = all(r.should_continue for r in results)
|
|
154
|
+
|
|
155
|
+
return should_stop, results
|
|
156
|
+
|
|
157
|
+
def has_hooks(self, event_type: HookEventType) -> bool:
|
|
158
|
+
"""Check if there are hooks configured for an event type."""
|
|
159
|
+
return self.config.has_hooks_for_event(event_type)
|
|
160
|
+
|
|
161
|
+
def get_blocking_reason(self, results: list[HookResult]) -> str | None:
|
|
162
|
+
"""Get the reason for blocking from hook results."""
|
|
163
|
+
for result in results:
|
|
164
|
+
if result.blocked:
|
|
165
|
+
if result.reason:
|
|
166
|
+
return result.reason
|
|
167
|
+
if result.stderr:
|
|
168
|
+
return result.stderr.strip()
|
|
169
|
+
return "Blocked by hook"
|
|
170
|
+
return None
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Hook event types and data structures."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HookEventType(str, Enum):
|
|
10
|
+
"""Types of hook events that can trigger hooks."""
|
|
11
|
+
|
|
12
|
+
PRE_TOOL_USE = "PreToolUse"
|
|
13
|
+
POST_TOOL_USE = "PostToolUse"
|
|
14
|
+
USER_PROMPT_SUBMIT = "UserPromptSubmit"
|
|
15
|
+
SESSION_START = "SessionStart"
|
|
16
|
+
SESSION_END = "SessionEnd"
|
|
17
|
+
STOP = "Stop"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HookEvent(BaseModel):
|
|
21
|
+
"""Data passed to hook scripts via stdin as JSON."""
|
|
22
|
+
|
|
23
|
+
event_type: HookEventType
|
|
24
|
+
tool_name: str | None = None
|
|
25
|
+
tool_input: dict[str, Any] | None = None
|
|
26
|
+
tool_response: dict[str, Any] | None = None
|
|
27
|
+
message: str | None = None
|
|
28
|
+
session_id: str | None = None
|
|
29
|
+
working_dir: str | None = None
|
|
30
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
model_config = {"use_enum_values": True}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class HookDecision(str, Enum):
|
|
36
|
+
"""Decisions a hook can make about an operation."""
|
|
37
|
+
|
|
38
|
+
ALLOW = "allow"
|
|
39
|
+
DENY = "deny"
|
|
40
|
+
# ASK = "ask" # Future: prompt user for confirmation before proceeding
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from cachetools import LRUCache
|
|
4
|
+
|
|
5
|
+
from openhands.sdk.logger import get_logger
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
logger = get_logger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MemoryLRUCache(LRUCache):
|
|
12
|
+
"""LRU cache with both entry count and memory size limits.
|
|
13
|
+
|
|
14
|
+
This cache enforces two limits:
|
|
15
|
+
1. Maximum number of entries (maxsize)
|
|
16
|
+
2. Maximum memory usage in bytes (max_memory)
|
|
17
|
+
|
|
18
|
+
When either limit is exceeded, the least recently used items are evicted.
|
|
19
|
+
|
|
20
|
+
Note: Memory tracking is based on string length for simplicity and accuracy.
|
|
21
|
+
For non-string values, sys.getsizeof is used as a rough approximation.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, max_memory: int, max_size: int, *args, **kwargs):
|
|
25
|
+
# Ensure minimum maxsize of 1 to avoid LRUCache issues
|
|
26
|
+
maxsize = max(1, max_size)
|
|
27
|
+
super().__init__(maxsize=maxsize, *args, **kwargs)
|
|
28
|
+
self.max_memory = max_memory
|
|
29
|
+
self.current_memory = 0
|
|
30
|
+
|
|
31
|
+
def _get_size(self, value: Any) -> int:
|
|
32
|
+
"""Calculate size of value for memory tracking.
|
|
33
|
+
|
|
34
|
+
For strings (the common case in FileStore), we use len() which gives
|
|
35
|
+
accurate character count. For other types, we use sys.getsizeof() as
|
|
36
|
+
a rough approximation.
|
|
37
|
+
"""
|
|
38
|
+
if isinstance(value, str):
|
|
39
|
+
# For strings, len() gives character count which is what we care about
|
|
40
|
+
# This is much more accurate than sys.getsizeof for our use case
|
|
41
|
+
return len(value)
|
|
42
|
+
elif isinstance(value, bytes):
|
|
43
|
+
return len(value)
|
|
44
|
+
else:
|
|
45
|
+
# For other types, fall back to sys.getsizeof
|
|
46
|
+
# This is mainly for edge cases and won't be accurate for nested
|
|
47
|
+
# structures, but it's better than nothing
|
|
48
|
+
try:
|
|
49
|
+
import sys
|
|
50
|
+
|
|
51
|
+
return sys.getsizeof(value)
|
|
52
|
+
except Exception:
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
def __setitem__(self, key: Any, value: Any) -> None:
|
|
56
|
+
new_size = self._get_size(value)
|
|
57
|
+
|
|
58
|
+
# Don't cache items that are larger than max_memory
|
|
59
|
+
# This prevents cache thrashing where one huge item evicts everything
|
|
60
|
+
if new_size > self.max_memory:
|
|
61
|
+
logger.debug(
|
|
62
|
+
f"Item too large for cache ({new_size} bytes > "
|
|
63
|
+
f"{self.max_memory} bytes), skipping cache"
|
|
64
|
+
)
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
# Update memory accounting if key exists
|
|
68
|
+
if key in self:
|
|
69
|
+
old_value = self[key]
|
|
70
|
+
self.current_memory -= self._get_size(old_value)
|
|
71
|
+
|
|
72
|
+
self.current_memory += new_size
|
|
73
|
+
|
|
74
|
+
# Evict items until we're under memory limit
|
|
75
|
+
while self.current_memory > self.max_memory and len(self) > 0:
|
|
76
|
+
self.popitem()
|
|
77
|
+
|
|
78
|
+
super().__setitem__(key, value)
|
|
79
|
+
|
|
80
|
+
def __delitem__(self, key: Any) -> None:
|
|
81
|
+
if key in self:
|
|
82
|
+
old_value = self[key]
|
|
83
|
+
self.current_memory -= self._get_size(old_value)
|
|
84
|
+
|
|
85
|
+
super().__delitem__(key)
|