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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from openhands.sdk.conversation.state import ConversationState
|
|
2
|
+
from openhands.sdk.conversation.types import StuckDetectionThresholds
|
|
2
3
|
from openhands.sdk.event import (
|
|
3
4
|
ActionEvent,
|
|
4
5
|
AgentErrorEvent,
|
|
@@ -26,9 +27,31 @@ class StuckDetector:
|
|
|
26
27
|
"""
|
|
27
28
|
|
|
28
29
|
state: ConversationState
|
|
30
|
+
thresholds: StuckDetectionThresholds
|
|
29
31
|
|
|
30
|
-
def __init__(
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
state: ConversationState,
|
|
35
|
+
thresholds: StuckDetectionThresholds | None = None,
|
|
36
|
+
):
|
|
31
37
|
self.state = state
|
|
38
|
+
self.thresholds = thresholds or StuckDetectionThresholds()
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def action_observation_threshold(self) -> int:
|
|
42
|
+
return self.thresholds.action_observation
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def action_error_threshold(self) -> int:
|
|
46
|
+
return self.thresholds.action_error
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def monologue_threshold(self) -> int:
|
|
50
|
+
return self.thresholds.monologue
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def alternating_pattern_threshold(self) -> int:
|
|
54
|
+
return self.thresholds.alternating_pattern
|
|
32
55
|
|
|
33
56
|
def is_stuck(self) -> bool:
|
|
34
57
|
"""Check if the agent is currently stuck."""
|
|
@@ -49,8 +72,13 @@ class StuckDetector:
|
|
|
49
72
|
|
|
50
73
|
events = events[last_user_msg_index + 1 :]
|
|
51
74
|
|
|
52
|
-
#
|
|
53
|
-
|
|
75
|
+
# Determine minimum events needed
|
|
76
|
+
min_threshold = min(
|
|
77
|
+
self.action_observation_threshold,
|
|
78
|
+
self.action_error_threshold,
|
|
79
|
+
self.monologue_threshold,
|
|
80
|
+
)
|
|
81
|
+
if len(events) < min_threshold:
|
|
54
82
|
return False
|
|
55
83
|
|
|
56
84
|
logger.debug(f"Checking for stuck patterns in {len(events)} events")
|
|
@@ -58,19 +86,21 @@ class StuckDetector:
|
|
|
58
86
|
f"Events after last user message: {[type(e).__name__ for e in events]}"
|
|
59
87
|
)
|
|
60
88
|
|
|
61
|
-
#
|
|
62
|
-
|
|
89
|
+
# Collect enough actions and observations for detection
|
|
90
|
+
max_needed = max(self.action_observation_threshold, self.action_error_threshold)
|
|
63
91
|
last_actions: list[Event] = []
|
|
64
92
|
last_observations: list[Event] = []
|
|
65
93
|
|
|
66
|
-
#
|
|
67
|
-
# the end of history, wherever they are
|
|
94
|
+
# Retrieve the last N actions and observations from the end of history
|
|
68
95
|
for event in reversed(events):
|
|
69
|
-
if isinstance(event, ActionEvent) and len(last_actions) <
|
|
96
|
+
if isinstance(event, ActionEvent) and len(last_actions) < max_needed:
|
|
70
97
|
last_actions.append(event)
|
|
71
|
-
elif
|
|
98
|
+
elif (
|
|
99
|
+
isinstance(event, ObservationBaseEvent)
|
|
100
|
+
and len(last_observations) < max_needed
|
|
101
|
+
):
|
|
72
102
|
last_observations.append(event)
|
|
73
|
-
if len(last_actions) >=
|
|
103
|
+
if len(last_actions) >= max_needed and len(last_observations) >= max_needed:
|
|
74
104
|
break
|
|
75
105
|
|
|
76
106
|
# Check all stuck patterns
|
|
@@ -86,8 +116,8 @@ class StuckDetector:
|
|
|
86
116
|
if self._is_stuck_monologue(events):
|
|
87
117
|
return True
|
|
88
118
|
|
|
89
|
-
# scenario 4: action, observation alternating pattern
|
|
90
|
-
if len(events) >=
|
|
119
|
+
# scenario 4: action, observation alternating pattern
|
|
120
|
+
if len(events) >= self.alternating_pattern_threshold:
|
|
91
121
|
if self._is_stuck_alternating_action_observation(events):
|
|
92
122
|
return True
|
|
93
123
|
|
|
@@ -102,18 +132,21 @@ class StuckDetector:
|
|
|
102
132
|
self, last_actions: list[Event], last_observations: list[Event]
|
|
103
133
|
) -> bool:
|
|
104
134
|
# scenario 1: same action, same observation
|
|
105
|
-
|
|
106
|
-
# assert len(last_actions) == 4 and len(last_observations) == 4
|
|
135
|
+
threshold = self.action_observation_threshold
|
|
107
136
|
|
|
108
|
-
# Check for a loop of
|
|
109
|
-
if len(last_actions)
|
|
110
|
-
logger.debug(
|
|
137
|
+
# Check for a loop of identical action-observation pairs
|
|
138
|
+
if len(last_actions) >= threshold and len(last_observations) >= threshold:
|
|
139
|
+
logger.debug(
|
|
140
|
+
f"Found {len(last_actions)} actions and "
|
|
141
|
+
f"{len(last_observations)} observations, checking for equality"
|
|
142
|
+
)
|
|
111
143
|
actions_equal = all(
|
|
112
|
-
self._event_eq(last_actions[0], action)
|
|
144
|
+
self._event_eq(last_actions[0], action)
|
|
145
|
+
for action in last_actions[:threshold]
|
|
113
146
|
)
|
|
114
147
|
observations_equal = all(
|
|
115
148
|
self._event_eq(last_observations[0], observation)
|
|
116
|
-
for observation in last_observations
|
|
149
|
+
for observation in last_observations[:threshold]
|
|
117
150
|
)
|
|
118
151
|
logger.debug(
|
|
119
152
|
f"Actions equal: {actions_equal}, "
|
|
@@ -135,15 +168,20 @@ class StuckDetector:
|
|
|
135
168
|
self, last_actions: list[Event], last_observations: list[Event]
|
|
136
169
|
) -> bool:
|
|
137
170
|
# scenario 2: same action, errors
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if len(last_actions) < 3 or len(last_observations) < 3:
|
|
171
|
+
threshold = self.action_error_threshold
|
|
172
|
+
if len(last_actions) < threshold or len(last_observations) < threshold:
|
|
141
173
|
return False
|
|
142
174
|
|
|
143
|
-
# are the last
|
|
144
|
-
if all(
|
|
145
|
-
|
|
146
|
-
|
|
175
|
+
# are the last N actions the "same"?
|
|
176
|
+
if all(
|
|
177
|
+
self._event_eq(last_actions[0], action)
|
|
178
|
+
for action in last_actions[:threshold]
|
|
179
|
+
):
|
|
180
|
+
# and the last N observations are all errors?
|
|
181
|
+
if all(
|
|
182
|
+
isinstance(obs, AgentErrorEvent)
|
|
183
|
+
for obs in last_observations[:threshold]
|
|
184
|
+
):
|
|
147
185
|
logger.warning("Action, Error loop detected")
|
|
148
186
|
return True
|
|
149
187
|
|
|
@@ -155,10 +193,11 @@ class StuckDetector:
|
|
|
155
193
|
# check for repeated MessageActions with source=AGENT
|
|
156
194
|
# see if the agent is engaged in a good old monologue, telling
|
|
157
195
|
# itself the same thing over and over
|
|
158
|
-
|
|
196
|
+
threshold = self.monologue_threshold
|
|
197
|
+
if len(events) < threshold:
|
|
159
198
|
return False
|
|
160
199
|
|
|
161
|
-
# Look for
|
|
200
|
+
# Look for N consecutive agent messages without user interruption
|
|
162
201
|
agent_message_count = 0
|
|
163
202
|
|
|
164
203
|
for event in reversed(events):
|
|
@@ -174,40 +213,37 @@ class StuckDetector:
|
|
|
174
213
|
# Other events (actions/observations) don't count as monologue
|
|
175
214
|
break
|
|
176
215
|
|
|
177
|
-
return agent_message_count >=
|
|
216
|
+
return agent_message_count >= threshold
|
|
178
217
|
|
|
179
218
|
def _is_stuck_alternating_action_observation(self, events: list[Event]) -> bool:
|
|
180
219
|
# scenario 4: alternating action-observation loop
|
|
181
|
-
|
|
220
|
+
threshold = self.alternating_pattern_threshold
|
|
182
221
|
|
|
183
222
|
last_actions: list[Event] = []
|
|
184
223
|
last_observations: list[Event] = []
|
|
185
224
|
|
|
186
|
-
# collect most recent
|
|
225
|
+
# collect most recent N actions and N observations
|
|
187
226
|
for event in reversed(events):
|
|
188
|
-
if isinstance(event, ActionEvent) and len(last_actions) <
|
|
227
|
+
if isinstance(event, ActionEvent) and len(last_actions) < threshold:
|
|
189
228
|
last_actions.append(event)
|
|
190
229
|
elif (
|
|
191
230
|
isinstance(event, (ObservationEvent, AgentErrorEvent))
|
|
192
|
-
and len(last_observations) <
|
|
231
|
+
and len(last_observations) < threshold
|
|
193
232
|
):
|
|
194
233
|
last_observations.append(event)
|
|
195
234
|
|
|
196
|
-
if len(last_actions) ==
|
|
235
|
+
if len(last_actions) == threshold and len(last_observations) == threshold:
|
|
197
236
|
break
|
|
198
237
|
|
|
199
|
-
if len(last_actions) ==
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
and self._event_eq(last_actions[1], last_actions[5])
|
|
238
|
+
if len(last_actions) == threshold and len(last_observations) == threshold:
|
|
239
|
+
# Check alternating pattern: [A, B, A, B, A, B] where even/odd match
|
|
240
|
+
actions_equal = all(
|
|
241
|
+
self._event_eq(last_actions[i], last_actions[i + 2])
|
|
242
|
+
for i in range(threshold - 2)
|
|
205
243
|
)
|
|
206
|
-
observations_equal = (
|
|
207
|
-
self._event_eq(last_observations[
|
|
208
|
-
|
|
209
|
-
and self._event_eq(last_observations[1], last_observations[3])
|
|
210
|
-
and self._event_eq(last_observations[1], last_observations[5])
|
|
244
|
+
observations_equal = all(
|
|
245
|
+
self._event_eq(last_observations[i], last_observations[i + 2])
|
|
246
|
+
for i in range(threshold - 2)
|
|
211
247
|
)
|
|
212
248
|
|
|
213
249
|
if actions_equal and observations_equal:
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import uuid
|
|
2
2
|
from collections.abc import Callable
|
|
3
3
|
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
4
6
|
from openhands.sdk.event.base import Event
|
|
5
7
|
from openhands.sdk.llm.streaming import TokenCallbackType
|
|
6
8
|
|
|
@@ -13,3 +15,31 @@ ConversationTokenCallbackType = TokenCallbackType
|
|
|
13
15
|
|
|
14
16
|
ConversationID = uuid.UUID
|
|
15
17
|
"""Type alias for conversation IDs."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StuckDetectionThresholds(BaseModel):
|
|
21
|
+
"""Configuration for stuck detection thresholds.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
action_observation: Number of repetitions before triggering
|
|
25
|
+
action-observation loop detection
|
|
26
|
+
action_error: Number of repetitions before triggering
|
|
27
|
+
action-error loop detection
|
|
28
|
+
monologue: Number of consecutive agent messages before triggering
|
|
29
|
+
monologue detection
|
|
30
|
+
alternating_pattern: Number of repetitions before triggering
|
|
31
|
+
alternating pattern detection
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
action_observation: int = Field(
|
|
35
|
+
default=4, ge=1, description="Threshold for action-observation loop detection"
|
|
36
|
+
)
|
|
37
|
+
action_error: int = Field(
|
|
38
|
+
default=3, ge=1, description="Threshold for action-error loop detection"
|
|
39
|
+
)
|
|
40
|
+
monologue: int = Field(
|
|
41
|
+
default=3, ge=1, description="Threshold for agent monologue detection"
|
|
42
|
+
)
|
|
43
|
+
alternating_pattern: int = Field(
|
|
44
|
+
default=6, ge=1, description="Threshold for alternating pattern detection"
|
|
45
|
+
)
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import json
|
|
2
2
|
|
|
3
|
-
from litellm import ChatCompletionToolParam
|
|
4
3
|
from pydantic import Field
|
|
5
4
|
from rich.text import Text
|
|
6
5
|
|
|
7
6
|
from openhands.sdk.event.base import N_CHAR_PREVIEW, LLMConvertibleEvent
|
|
8
7
|
from openhands.sdk.event.types import SourceType
|
|
9
8
|
from openhands.sdk.llm import Message, TextContent
|
|
9
|
+
from openhands.sdk.tool import ToolDefinition
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class SystemPromptEvent(LLMConvertibleEvent):
|
|
@@ -14,8 +14,8 @@ class SystemPromptEvent(LLMConvertibleEvent):
|
|
|
14
14
|
|
|
15
15
|
source: SourceType = "agent"
|
|
16
16
|
system_prompt: TextContent = Field(..., description="The system prompt text")
|
|
17
|
-
tools: list[
|
|
18
|
-
..., description="List of tools
|
|
17
|
+
tools: list[ToolDefinition] = Field(
|
|
18
|
+
..., description="List of tools as ToolDefinition objects"
|
|
19
19
|
)
|
|
20
20
|
|
|
21
21
|
@property
|
|
@@ -26,26 +26,22 @@ class SystemPromptEvent(LLMConvertibleEvent):
|
|
|
26
26
|
content.append(self.system_prompt.text)
|
|
27
27
|
content.append(f"\n\nTools Available: {len(self.tools)}")
|
|
28
28
|
for tool in self.tools:
|
|
29
|
-
#
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
params_str = json.dumps(
|
|
29
|
+
# Use ToolDefinition properties directly
|
|
30
|
+
description = tool.description.split("\n")[0][:100]
|
|
31
|
+
if len(description) < len(tool.description):
|
|
32
|
+
description += "..."
|
|
33
|
+
|
|
34
|
+
content.append(f"\n - {tool.name}: {description}\n")
|
|
35
|
+
|
|
36
|
+
# Get parameters from the action type schema
|
|
37
|
+
try:
|
|
38
|
+
params_dict = tool.action_type.to_mcp_schema()
|
|
39
|
+
params_str = json.dumps(params_dict)
|
|
40
40
|
if len(params_str) > 200:
|
|
41
41
|
params_str = params_str[:197] + "..."
|
|
42
|
-
content.append(
|
|
43
|
-
f"\n - {tool_fn['name']}: "
|
|
44
|
-
f"{tool_fn['description'].split('\n')[0][:100]}...\n",
|
|
45
|
-
)
|
|
46
42
|
content.append(f" Parameters: {params_str}")
|
|
47
|
-
|
|
48
|
-
content.append(
|
|
43
|
+
except Exception:
|
|
44
|
+
content.append(" Parameters: <unavailable>")
|
|
49
45
|
return content
|
|
50
46
|
|
|
51
47
|
def to_llm_message(self) -> Message:
|
|
@@ -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)
|