openhands-sdk 1.7.3__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 +111 -0
- openhands/sdk/agent/__init__.py +8 -0
- openhands/sdk/agent/agent.py +650 -0
- openhands/sdk/agent/base.py +457 -0
- openhands/sdk/agent/prompts/in_context_learning_example.j2 +169 -0
- openhands/sdk/agent/prompts/in_context_learning_example_suffix.j2 +3 -0
- 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/security_policy.j2 +22 -0
- openhands/sdk/agent/prompts/security_risk_assessment.j2 +21 -0
- openhands/sdk/agent/prompts/self_documentation.j2 +15 -0
- openhands/sdk/agent/prompts/system_prompt.j2 +132 -0
- openhands/sdk/agent/prompts/system_prompt_interactive.j2 +14 -0
- openhands/sdk/agent/prompts/system_prompt_long_horizon.j2 +40 -0
- openhands/sdk/agent/prompts/system_prompt_planning.j2 +40 -0
- openhands/sdk/agent/prompts/system_prompt_tech_philosophy.j2 +122 -0
- openhands/sdk/agent/utils.py +228 -0
- openhands/sdk/context/__init__.py +28 -0
- openhands/sdk/context/agent_context.py +264 -0
- openhands/sdk/context/condenser/__init__.py +18 -0
- openhands/sdk/context/condenser/base.py +100 -0
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +248 -0
- openhands/sdk/context/condenser/no_op_condenser.py +14 -0
- openhands/sdk/context/condenser/pipeline_condenser.py +56 -0
- openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
- openhands/sdk/context/condenser/utils.py +149 -0
- openhands/sdk/context/prompts/__init__.py +6 -0
- openhands/sdk/context/prompts/prompt.py +114 -0
- openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
- openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
- openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
- openhands/sdk/context/skills/__init__.py +28 -0
- openhands/sdk/context/skills/exceptions.py +11 -0
- openhands/sdk/context/skills/skill.py +720 -0
- openhands/sdk/context/skills/trigger.py +36 -0
- openhands/sdk/context/skills/types.py +48 -0
- openhands/sdk/context/view.py +503 -0
- openhands/sdk/conversation/__init__.py +40 -0
- openhands/sdk/conversation/base.py +281 -0
- openhands/sdk/conversation/conversation.py +152 -0
- openhands/sdk/conversation/conversation_stats.py +85 -0
- openhands/sdk/conversation/event_store.py +157 -0
- openhands/sdk/conversation/events_list_base.py +17 -0
- openhands/sdk/conversation/exceptions.py +50 -0
- openhands/sdk/conversation/fifo_lock.py +133 -0
- openhands/sdk/conversation/impl/__init__.py +5 -0
- openhands/sdk/conversation/impl/local_conversation.py +665 -0
- openhands/sdk/conversation/impl/remote_conversation.py +956 -0
- openhands/sdk/conversation/persistence_const.py +9 -0
- openhands/sdk/conversation/response_utils.py +41 -0
- openhands/sdk/conversation/secret_registry.py +126 -0
- openhands/sdk/conversation/serialization_diff.py +0 -0
- openhands/sdk/conversation/state.py +392 -0
- openhands/sdk/conversation/stuck_detector.py +311 -0
- openhands/sdk/conversation/title_utils.py +191 -0
- openhands/sdk/conversation/types.py +45 -0
- openhands/sdk/conversation/visualizer/__init__.py +12 -0
- openhands/sdk/conversation/visualizer/base.py +67 -0
- openhands/sdk/conversation/visualizer/default.py +373 -0
- openhands/sdk/critic/__init__.py +15 -0
- openhands/sdk/critic/base.py +38 -0
- openhands/sdk/critic/impl/__init__.py +12 -0
- openhands/sdk/critic/impl/agent_finished.py +83 -0
- openhands/sdk/critic/impl/empty_patch.py +49 -0
- openhands/sdk/critic/impl/pass_critic.py +42 -0
- openhands/sdk/event/__init__.py +42 -0
- openhands/sdk/event/base.py +149 -0
- openhands/sdk/event/condenser.py +82 -0
- openhands/sdk/event/conversation_error.py +25 -0
- openhands/sdk/event/conversation_state.py +104 -0
- openhands/sdk/event/llm_completion_log.py +39 -0
- openhands/sdk/event/llm_convertible/__init__.py +20 -0
- openhands/sdk/event/llm_convertible/action.py +139 -0
- openhands/sdk/event/llm_convertible/message.py +142 -0
- openhands/sdk/event/llm_convertible/observation.py +141 -0
- openhands/sdk/event/llm_convertible/system.py +61 -0
- openhands/sdk/event/token.py +16 -0
- openhands/sdk/event/types.py +11 -0
- openhands/sdk/event/user_action.py +21 -0
- openhands/sdk/git/exceptions.py +43 -0
- openhands/sdk/git/git_changes.py +249 -0
- openhands/sdk/git/git_diff.py +129 -0
- openhands/sdk/git/models.py +21 -0
- openhands/sdk/git/utils.py +189 -0
- 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/__init__.py +6 -0
- openhands/sdk/io/base.py +48 -0
- openhands/sdk/io/cache.py +85 -0
- openhands/sdk/io/local.py +119 -0
- openhands/sdk/io/memory.py +54 -0
- openhands/sdk/llm/__init__.py +45 -0
- openhands/sdk/llm/exceptions/__init__.py +45 -0
- openhands/sdk/llm/exceptions/classifier.py +50 -0
- openhands/sdk/llm/exceptions/mapping.py +54 -0
- openhands/sdk/llm/exceptions/types.py +101 -0
- openhands/sdk/llm/llm.py +1140 -0
- openhands/sdk/llm/llm_registry.py +122 -0
- openhands/sdk/llm/llm_response.py +59 -0
- openhands/sdk/llm/message.py +656 -0
- openhands/sdk/llm/mixins/fn_call_converter.py +1288 -0
- openhands/sdk/llm/mixins/non_native_fc.py +97 -0
- openhands/sdk/llm/options/__init__.py +1 -0
- openhands/sdk/llm/options/chat_options.py +93 -0
- openhands/sdk/llm/options/common.py +19 -0
- openhands/sdk/llm/options/responses_options.py +67 -0
- openhands/sdk/llm/router/__init__.py +10 -0
- openhands/sdk/llm/router/base.py +117 -0
- openhands/sdk/llm/router/impl/multimodal.py +76 -0
- openhands/sdk/llm/router/impl/random.py +22 -0
- openhands/sdk/llm/streaming.py +9 -0
- openhands/sdk/llm/utils/metrics.py +312 -0
- openhands/sdk/llm/utils/model_features.py +192 -0
- openhands/sdk/llm/utils/model_info.py +90 -0
- openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
- openhands/sdk/llm/utils/retry_mixin.py +128 -0
- openhands/sdk/llm/utils/telemetry.py +362 -0
- openhands/sdk/llm/utils/unverified_models.py +156 -0
- openhands/sdk/llm/utils/verified_models.py +65 -0
- openhands/sdk/logger/__init__.py +22 -0
- openhands/sdk/logger/logger.py +195 -0
- openhands/sdk/logger/rolling.py +113 -0
- openhands/sdk/mcp/__init__.py +24 -0
- openhands/sdk/mcp/client.py +76 -0
- openhands/sdk/mcp/definition.py +106 -0
- openhands/sdk/mcp/exceptions.py +19 -0
- openhands/sdk/mcp/tool.py +270 -0
- openhands/sdk/mcp/utils.py +83 -0
- openhands/sdk/observability/__init__.py +4 -0
- openhands/sdk/observability/laminar.py +166 -0
- openhands/sdk/observability/utils.py +20 -0
- openhands/sdk/py.typed +0 -0
- openhands/sdk/secret/__init__.py +19 -0
- openhands/sdk/secret/secrets.py +92 -0
- openhands/sdk/security/__init__.py +6 -0
- openhands/sdk/security/analyzer.py +111 -0
- openhands/sdk/security/confirmation_policy.py +61 -0
- openhands/sdk/security/llm_analyzer.py +29 -0
- openhands/sdk/security/risk.py +100 -0
- openhands/sdk/tool/__init__.py +34 -0
- openhands/sdk/tool/builtins/__init__.py +34 -0
- openhands/sdk/tool/builtins/finish.py +106 -0
- openhands/sdk/tool/builtins/think.py +117 -0
- openhands/sdk/tool/registry.py +184 -0
- openhands/sdk/tool/schema.py +286 -0
- openhands/sdk/tool/spec.py +39 -0
- openhands/sdk/tool/tool.py +481 -0
- openhands/sdk/utils/__init__.py +22 -0
- openhands/sdk/utils/async_executor.py +115 -0
- openhands/sdk/utils/async_utils.py +39 -0
- openhands/sdk/utils/cipher.py +68 -0
- openhands/sdk/utils/command.py +90 -0
- openhands/sdk/utils/deprecation.py +166 -0
- openhands/sdk/utils/github.py +44 -0
- openhands/sdk/utils/json.py +48 -0
- openhands/sdk/utils/models.py +570 -0
- openhands/sdk/utils/paging.py +63 -0
- openhands/sdk/utils/pydantic_diff.py +85 -0
- openhands/sdk/utils/pydantic_secrets.py +64 -0
- openhands/sdk/utils/truncate.py +117 -0
- openhands/sdk/utils/visualize.py +58 -0
- openhands/sdk/workspace/__init__.py +17 -0
- openhands/sdk/workspace/base.py +158 -0
- openhands/sdk/workspace/local.py +189 -0
- openhands/sdk/workspace/models.py +35 -0
- openhands/sdk/workspace/remote/__init__.py +8 -0
- openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
- openhands/sdk/workspace/remote/base.py +164 -0
- openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
- openhands/sdk/workspace/workspace.py +49 -0
- openhands_sdk-1.7.3.dist-info/METADATA +17 -0
- openhands_sdk-1.7.3.dist-info/RECORD +180 -0
- openhands_sdk-1.7.3.dist-info/WHEEL +5 -0
- openhands_sdk-1.7.3.dist-info/top_level.txt +1 -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
|
openhands/sdk/io/base.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FileStore(ABC):
|
|
5
|
+
"""Abstract base class for file storage operations.
|
|
6
|
+
|
|
7
|
+
This class defines the interface for file storage backends that can
|
|
8
|
+
handle basic file operations like reading, writing, listing, and deleting files.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def write(self, path: str, contents: str | bytes) -> None:
|
|
13
|
+
"""Write contents to a file at the specified path.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
path: The file path where contents should be written.
|
|
17
|
+
contents: The data to write, either as string or bytes.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def read(self, path: str) -> str:
|
|
22
|
+
"""Read and return the contents of a file as a string.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
path: The file path to read from.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
The file contents as a string.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def list(self, path: str) -> list[str]:
|
|
33
|
+
"""List all files and directories at the specified path.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
path: The directory path to list contents from.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
A list of file and directory names in the specified path.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def delete(self, path: str) -> None:
|
|
44
|
+
"""Delete the file or directory at the specified path.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
path: The file or directory path to delete.
|
|
48
|
+
"""
|