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
@@ -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)