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.
Files changed (56) hide show
  1. openhands/sdk/__init__.py +9 -1
  2. openhands/sdk/agent/agent.py +35 -12
  3. openhands/sdk/agent/base.py +53 -7
  4. openhands/sdk/agent/prompts/model_specific/anthropic_claude.j2 +3 -0
  5. openhands/sdk/agent/prompts/model_specific/google_gemini.j2 +1 -0
  6. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +2 -0
  7. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5.j2 +3 -0
  8. openhands/sdk/agent/prompts/self_documentation.j2 +15 -0
  9. openhands/sdk/agent/prompts/system_prompt.j2 +29 -1
  10. openhands/sdk/agent/utils.py +18 -4
  11. openhands/sdk/context/__init__.py +2 -0
  12. openhands/sdk/context/agent_context.py +42 -10
  13. openhands/sdk/context/condenser/base.py +11 -6
  14. openhands/sdk/context/condenser/llm_summarizing_condenser.py +169 -20
  15. openhands/sdk/context/condenser/no_op_condenser.py +2 -1
  16. openhands/sdk/context/condenser/pipeline_condenser.py +10 -9
  17. openhands/sdk/context/condenser/utils.py +149 -0
  18. openhands/sdk/context/prompts/prompt.py +40 -2
  19. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +3 -3
  20. openhands/sdk/context/skills/__init__.py +2 -0
  21. openhands/sdk/context/skills/skill.py +152 -1
  22. openhands/sdk/context/view.py +287 -27
  23. openhands/sdk/conversation/base.py +17 -0
  24. openhands/sdk/conversation/conversation.py +19 -0
  25. openhands/sdk/conversation/exceptions.py +29 -4
  26. openhands/sdk/conversation/impl/local_conversation.py +126 -9
  27. openhands/sdk/conversation/impl/remote_conversation.py +152 -3
  28. openhands/sdk/conversation/state.py +42 -1
  29. openhands/sdk/conversation/stuck_detector.py +81 -45
  30. openhands/sdk/conversation/types.py +30 -0
  31. openhands/sdk/event/llm_convertible/system.py +16 -20
  32. openhands/sdk/hooks/__init__.py +30 -0
  33. openhands/sdk/hooks/config.py +180 -0
  34. openhands/sdk/hooks/conversation_hooks.py +227 -0
  35. openhands/sdk/hooks/executor.py +155 -0
  36. openhands/sdk/hooks/manager.py +170 -0
  37. openhands/sdk/hooks/types.py +40 -0
  38. openhands/sdk/io/cache.py +85 -0
  39. openhands/sdk/io/local.py +39 -2
  40. openhands/sdk/llm/llm.py +3 -2
  41. openhands/sdk/llm/message.py +4 -3
  42. openhands/sdk/llm/mixins/fn_call_converter.py +61 -16
  43. openhands/sdk/llm/mixins/non_native_fc.py +5 -1
  44. openhands/sdk/llm/utils/model_features.py +64 -24
  45. openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
  46. openhands/sdk/llm/utils/verified_models.py +6 -4
  47. openhands/sdk/logger/logger.py +1 -1
  48. openhands/sdk/tool/schema.py +10 -0
  49. openhands/sdk/tool/tool.py +2 -2
  50. openhands/sdk/utils/async_executor.py +76 -67
  51. openhands/sdk/utils/models.py +1 -1
  52. openhands/sdk/utils/paging.py +63 -0
  53. {openhands_sdk-1.5.0.dist-info → openhands_sdk-1.7.2.dist-info}/METADATA +3 -3
  54. {openhands_sdk-1.5.0.dist-info → openhands_sdk-1.7.2.dist-info}/RECORD +56 -41
  55. {openhands_sdk-1.5.0.dist-info → openhands_sdk-1.7.2.dist-info}/WHEEL +0 -0
  56. {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__(self, state: ConversationState):
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
- # it takes 3 actions minimum to detect a loop, otherwise nothing to do here
53
- if len(events) < 3:
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
- # the first few scenarios detect 3 or 4 repeated steps
62
- # prepare the last 4 actions and observations, to check them out
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
- # retrieve the last four actions and observations starting from
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) < 4:
96
+ if isinstance(event, ActionEvent) and len(last_actions) < max_needed:
70
97
  last_actions.append(event)
71
- elif isinstance(event, ObservationBaseEvent) and len(last_observations) < 4:
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) >= 4 and len(last_observations) >= 4:
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 on the last six steps
90
- if len(events) >= 6:
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
- # it takes 4 actions and 4 observations to detect a loop
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 4 identical action-observation pairs
109
- if len(last_actions) == 4 and len(last_observations) == 4:
110
- logger.debug("Found 4 actions and 4 observations, checking for equality")
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) for action in last_actions
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
- # it takes 3 actions and 3 observations to detect a loop
139
- # check if the last three actions are the same and result in errors
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 three actions the "same"?
144
- if all(self._event_eq(last_actions[0], action) for action in last_actions[:3]):
145
- # and the last three observations are all errors?
146
- if all(isinstance(obs, AgentErrorEvent) for obs in last_observations[:3]):
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
- if len(events) < 3:
196
+ threshold = self.monologue_threshold
197
+ if len(events) < threshold:
159
198
  return False
160
199
 
161
- # Look for 3 consecutive agent messages without user interruption
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 >= 3
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
- # needs 6 actions and 6 observations to detect the ping-pong pattern
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 6 actions and 6 observations
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) < 6:
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) < 6
231
+ and len(last_observations) < threshold
193
232
  ):
194
233
  last_observations.append(event)
195
234
 
196
- if len(last_actions) == 6 and len(last_observations) == 6:
235
+ if len(last_actions) == threshold and len(last_observations) == threshold:
197
236
  break
198
237
 
199
- if len(last_actions) == 6 and len(last_observations) == 6:
200
- actions_equal = (
201
- self._event_eq(last_actions[0], last_actions[2])
202
- and self._event_eq(last_actions[0], last_actions[4])
203
- and self._event_eq(last_actions[1], last_actions[3])
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[0], last_observations[2])
208
- and self._event_eq(last_observations[0], last_observations[4])
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[ChatCompletionToolParam] = Field(
18
- ..., description="List of tools in OpenAI tool format"
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
- # Build display-only copy to avoid mutating event data
30
- tool_display = {
31
- k: (v[:27] + "..." if isinstance(v, str) and len(v) > 30 else v)
32
- for k, v in tool.items()
33
- }
34
- tool_fn = tool_display.get("function", None)
35
- if tool_fn and isinstance(tool_fn, dict):
36
- assert "name" in tool_fn
37
- assert "description" in tool_fn
38
- assert "parameters" in tool_fn
39
- params_str = json.dumps(tool_fn["parameters"])
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
- else:
48
- content.append(f"\n - Cannot access .function for {tool_display}")
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)