From bda858e560fedaa20c619b00578f05cc06d33625 Mon Sep 17 00:00:00 2001 From: ak684 Date: Tue, 30 Dec 2025 00:35:01 +0000 Subject: [PATCH 1/5] feat(sdk): complete hooks implementation with additional context and stop hook - Implement additional_context injection in UserPromptSubmit hooks - Hook context is appended to MessageEvent.extended_content - Context flows through condensation and is included in LLM messages - Implement stop hook integration in conversation run loop - Stop hooks can deny premature agent completion - Feedback from hooks is injected as user message with [Stop hook feedback] prefix - Agent continues running after stop hook denial - Add comprehensive tests for both features - Tests for context appearing in extended_content and to_llm_message() - Tests for stop hook denial with feedback injection - Integration tests for full conversation loop with stop hooks - Add advanced hooks example (34_hooks_advanced.py) --- .github/workflows/precommit.yml | 2 +- .../01_standalone_sdk/34_hooks_advanced.py | 155 +++++++ .../conversation/impl/local_conversation.py | 29 +- .../openhands/sdk/hooks/conversation_hooks.py | 67 ++- tests/sdk/hooks/test_integration.py | 409 ++++++++++++++++++ 5 files changed, 650 insertions(+), 12 deletions(-) create mode 100644 examples/01_standalone_sdk/34_hooks_advanced.py diff --git a/.github/workflows/precommit.yml b/.github/workflows/precommit.yml index e2cfce456e..d236f84b90 100644 --- a/.github/workflows/precommit.yml +++ b/.github/workflows/precommit.yml @@ -28,4 +28,4 @@ jobs: run: uv sync --frozen --group dev - name: Run pre-commit (all files) - run: uv run pre-commit run --all-files + run: uv run pre-commit run --all-files --show-diff-on-failure diff --git a/examples/01_standalone_sdk/34_hooks_advanced.py b/examples/01_standalone_sdk/34_hooks_advanced.py new file mode 100644 index 0000000000..8132037933 --- /dev/null +++ b/examples/01_standalone_sdk/34_hooks_advanced.py @@ -0,0 +1,155 @@ +"""OpenHands Agent SDK — Advanced Hooks Example + +- UserPromptSubmit hook that injects git context when user mentions code changes +- Stop hook that verifies a task was completed before allowing finish + +These patterns are common in production: +- Injecting relevant context (git status, file contents) into user messages +- Enforcing task completion criteria before agent can finish +""" + +import os +import signal +import tempfile +from pathlib import Path + +from pydantic import SecretStr + +from openhands.sdk import LLM, Conversation +from openhands.sdk.hooks import HookConfig +from openhands.tools.preset.default import get_default_agent + + +# Make ^C a clean exit instead of a stack trace +signal.signal(signal.SIGINT, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt())) + + +def create_hooks(workspace: Path) -> tuple[HookConfig, Path]: + """Create hook scripts for the example. + + Creates: + 1. UserPromptSubmit hook - injects git status when user asks about changes + 2. Stop hook - requires a summary.txt file before allowing completion + """ + hook_dir = workspace / ".hooks" + hook_dir.mkdir(exist_ok=True) + + # UserPromptSubmit: Inject git status when user mentions changes/diff/git + context_script = hook_dir / "inject_git_context.sh" + context_script.write_text( + """#!/bin/bash +# Inject git context when user asks about code changes +input=$(cat) + +# Check if user is asking about changes, diff, or git +if echo "$input" | grep -qiE "(changes|diff|git|commit|modified)"; then + # Get git status if in a git repo + if git rev-parse --git-dir > /dev/null 2>&1; then + status=$(git status --short 2>/dev/null | head -10) + if [ -n "$status" ]; then + # Escape for JSON + escaped=$(echo "$status" | sed 's/"/\\\\"/g' | tr '\\n' ' ') + echo "{\\"additionalContext\\": \\"Current git status: $escaped\\"}" + fi + fi +fi +exit 0 +""" + ) + context_script.chmod(0o755) + + # Stop hook: Require summary.txt to exist before allowing completion + summary_file = workspace / "summary.txt" + stop_script = hook_dir / "require_summary.sh" + stop_script.write_text( + f'''#!/bin/bash +# Require a summary.txt file before allowing agent to finish +if [ ! -f "{summary_file}" ]; then + echo '{{"decision": "deny", "additionalContext": "Create summary.txt first."}}' + exit 2 +fi +exit 0 +''' + ) + stop_script.chmod(0o755) + + config = HookConfig.from_dict( + { + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [{"type": "command", "command": str(context_script)}], + } + ], + "Stop": [ + { + "hooks": [{"type": "command", "command": str(stop_script)}], + } + ], + } + } + ) + + return config, summary_file + + +def main(): + # Configure LLM + api_key = os.getenv("LLM_API_KEY") + assert api_key is not None, "LLM_API_KEY environment variable is not set." + model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") + base_url = os.getenv("LLM_BASE_URL") + + llm = LLM( + usage_id="agent", + model=model, + base_url=base_url, + api_key=SecretStr(api_key), + ) + + with tempfile.TemporaryDirectory() as tmpdir: + workspace = Path(tmpdir) + + # Initialize as git repo for the context injection demo + os.system(f"cd {workspace} && git init -q && echo 'test' > file.txt") + + hook_config, summary_file = create_hooks(workspace) + print(f"Workspace: {workspace}") + print(f"Hook scripts created in {workspace}/.hooks/") + + agent = get_default_agent(llm=llm) + conversation = Conversation( + agent=agent, + workspace=str(workspace), + hook_config=hook_config, + ) + + print("\n" + "=" * 60) + print("Demo: Context Injection + Task Completion Enforcement") + print("=" * 60) + print("\nThe UserPromptSubmit hook will inject git status context.") + print("The Stop hook requires summary.txt before agent can finish.\n") + + # This message triggers git context injection and task completion + conversation.send_message( + "Check what files have changes, then create summary.txt " + "describing the repo state." + ) + conversation.run() + + # Verify summary was created + if summary_file.exists(): + print(f"\n[summary.txt created: {summary_file.read_text()[:100]}...]") + else: + print("\n[Warning: summary.txt was not created]") + + print("\n" + "=" * 60) + print("Example Complete!") + print("=" * 60) + + cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost + print(f"\nEXAMPLE_COST: {cost}") + + +if __name__ == "__main__": + main() diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 984f01f846..cb141f9567 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -335,12 +335,39 @@ def run(self) -> None: # Before value can be modified step can be taken # Ensure step conditions are checked when lock is already acquired if self._state.execution_status in [ - ConversationExecutionStatus.FINISHED, ConversationExecutionStatus.PAUSED, ConversationExecutionStatus.STUCK, ]: break + # Handle stop hooks on FINISHED + if ( + self._state.execution_status + == ConversationExecutionStatus.FINISHED + ): + if self._hook_processor is not None: + should_stop, feedback = self._hook_processor.run_stop( + reason="agent_finished" + ) + if not should_stop: + logger.info("Stop hook denied agent stopping") + if feedback: + prefixed = f"[Stop hook feedback] {feedback}" + feedback_msg = MessageEvent( + source="user", + llm_message=Message( + role="user", + content=[TextContent(text=prefixed)], + ), + ) + self._on_event(feedback_msg) + self._state.execution_status = ( + ConversationExecutionStatus.RUNNING + ) + continue + # No hooks or hooks allowed stopping + break + # Check for stuck patterns if enabled if self._stuck_detector: is_stuck = self._stuck_detector.is_stuck() diff --git a/openhands-sdk/openhands/sdk/hooks/conversation_hooks.py b/openhands-sdk/openhands/sdk/hooks/conversation_hooks.py index 6d0e907881..0fc77425c3 100644 --- a/openhands-sdk/openhands/sdk/hooks/conversation_hooks.py +++ b/openhands-sdk/openhands/sdk/hooks/conversation_hooks.py @@ -6,6 +6,7 @@ from openhands.sdk.hooks.config import HookConfig from openhands.sdk.hooks.manager import HookManager from openhands.sdk.hooks.types import HookEventType +from openhands.sdk.llm import TextContent from openhands.sdk.logger import get_logger @@ -41,6 +42,9 @@ def set_conversation_state(self, state: "ConversationState") -> None: def on_event(self, event: Event) -> None: """Process an event and run appropriate hooks.""" + # Track the event to pass to callbacks (may be modified by hooks) + callback_event = event + # Run PreToolUse hooks for action events if isinstance(event, ActionEvent) and event.action is not None: self._handle_pre_tool_use(event) @@ -51,11 +55,11 @@ def on_event(self, event: Event) -> None: # Run UserPromptSubmit hooks for user messages if isinstance(event, MessageEvent) and event.source == "user": - self._handle_user_prompt_submit(event) + callback_event = self._handle_user_prompt_submit(event) - # Call original callback + # Call original callback with (possibly modified) event if self.original_callback: - self.original_callback(event) + self.original_callback(callback_event) def _handle_pre_tool_use(self, event: ActionEvent) -> None: """Handle PreToolUse hooks. Blocked actions are marked in conversation state.""" @@ -141,16 +145,18 @@ def _handle_post_tool_use(self, event: ObservationEvent) -> None: if result.error: logger.warning(f"PostToolUse hook error: {result.error}") - def _handle_user_prompt_submit(self, event: MessageEvent) -> None: - """Handle UserPromptSubmit hooks before processing a user message.""" + def _handle_user_prompt_submit(self, event: MessageEvent) -> MessageEvent: + """Handle UserPromptSubmit hooks before processing a user message. + + Returns the (possibly modified) event. If hooks inject additional_context, + a new MessageEvent is created with the context appended to extended_content. + """ if not self.hook_manager.has_hooks(HookEventType.USER_PROMPT_SUBMIT): - return + return event # Extract message text message = "" if event.llm_message and event.llm_message.content: - from openhands.sdk.llm import TextContent - for content in event.llm_message.content: if isinstance(content, TextContent): message += content.text @@ -175,9 +181,23 @@ def _handle_user_prompt_submit(self, event: MessageEvent) -> None: "after creating the Conversation." ) - # TODO: Inject additional_context into the message + # Inject additional_context into extended_content if additional_context: - logger.info(f"Hook injected context: {additional_context[:100]}...") + logger.debug(f"Hook injecting context: {additional_context[:100]}...") + new_extended_content = list(event.extended_content) + [ + TextContent(text=additional_context) + ] + # MessageEvent is frozen, so create a new one + event = MessageEvent( + source=event.source, + llm_message=event.llm_message, + llm_response_id=event.llm_response_id, + activated_skills=event.activated_skills, + extended_content=new_extended_content, + sender=event.sender, + ) + + return event def is_action_blocked(self, action_id: str) -> bool: """Check if an action was blocked by a hook.""" @@ -205,6 +225,33 @@ def run_session_end(self) -> None: if r.error: logger.warning(f"SessionEnd hook error: {r.error}") + def run_stop(self, reason: str | None = None) -> tuple[bool, str | None]: + """Run Stop hooks. Returns (should_stop, feedback).""" + if not self.hook_manager.has_hooks(HookEventType.STOP): + return True, None + + should_stop, results = self.hook_manager.run_stop(reason=reason) + + # Log any errors + for r in results: + if r.error: + logger.warning(f"Stop hook error: {r.error}") + + # Collect feedback if denied + feedback = None + if not should_stop: + reason_text = self.hook_manager.get_blocking_reason(results) + logger.info(f"Stop hook denied stopping: {reason_text}") + feedback_parts = [ + r.additional_context for r in results if r.additional_context + ] + if feedback_parts: + feedback = "\n".join(feedback_parts) + elif reason_text: + feedback = reason_text + + return should_stop, feedback + def create_hook_callback( hook_config: HookConfig | None = None, diff --git a/tests/sdk/hooks/test_integration.py b/tests/sdk/hooks/test_integration.py index 633251cdbc..b41ee2d053 100644 --- a/tests/sdk/hooks/test_integration.py +++ b/tests/sdk/hooks/test_integration.py @@ -476,3 +476,412 @@ def test_create_hook_callback_returns_processor_and_callback(self, tmp_path): assert isinstance(processor, HookEventProcessor) assert callable(callback) assert callback == processor.on_event + + +class TestAdditionalContextInjection: + """Tests for additional_context injection into LLM messages.""" + + @pytest.fixture + def mock_conversation_state(self, tmp_path): + """Create a mock conversation state using the factory method.""" + import uuid + + from pydantic import SecretStr + + from openhands.sdk.agent import Agent + from openhands.sdk.llm import LLM + from openhands.sdk.workspace import LocalWorkspace + + llm = LLM(model="test-model", api_key=SecretStr("test-key")) + agent = Agent(llm=llm, tools=[]) + workspace = LocalWorkspace(working_dir=str(tmp_path)) + + return ConversationState.create( + id=uuid.uuid4(), + agent=agent, + workspace=workspace, + persistence_dir=None, + ) + + def test_additional_context_appears_in_extended_content( + self, tmp_path, mock_conversation_state + ): + """Test hook additional_context is injected into extended_content.""" + # Create a hook that returns additional context + script = tmp_path / "add_context.sh" + script.write_text( + "#!/bin/bash\n" + 'echo \'{"additionalContext": "Important context from hook"}\'\n' + "exit 0" + ) + script.chmod(0o755) + + config = HookConfig.from_dict( + { + "hooks": { + "UserPromptSubmit": [ + {"hooks": [{"type": "command", "command": str(script)}]} + ] + } + } + ) + + manager = HookManager(config=config, working_dir=str(tmp_path)) + processed_events = [] + + def capture_callback(event): + processed_events.append(event) + + processor = HookEventProcessor( + hook_manager=manager, original_callback=capture_callback + ) + processor.set_conversation_state(mock_conversation_state) + + original_event = MessageEvent( + source="user", + llm_message=Message( + role="user", + content=[TextContent(text="Hello")], + ), + ) + + processor.on_event(original_event) + + # Check that the callback received a modified event + assert len(processed_events) == 1 + processed_event = processed_events[0] + assert isinstance(processed_event, MessageEvent) + + # The extended_content should contain the hook's additional context + assert len(processed_event.extended_content) == 1 + assert processed_event.extended_content[0].text == "Important context from hook" + + def test_additional_context_appears_in_llm_message( + self, tmp_path, mock_conversation_state + ): + """Test that hook additional_context appears when converting to LLM message.""" + script = tmp_path / "add_context.sh" + script.write_text( + '#!/bin/bash\necho \'{"additionalContext": "Injected by hook"}\'\nexit 0' + ) + script.chmod(0o755) + + config = HookConfig.from_dict( + { + "hooks": { + "UserPromptSubmit": [ + {"hooks": [{"type": "command", "command": str(script)}]} + ] + } + } + ) + + manager = HookManager(config=config, working_dir=str(tmp_path)) + processed_events = [] + + def capture_callback(event): + processed_events.append(event) + + processor = HookEventProcessor( + hook_manager=manager, original_callback=capture_callback + ) + processor.set_conversation_state(mock_conversation_state) + + original_event = MessageEvent( + source="user", + llm_message=Message( + role="user", + content=[TextContent(text="User message")], + ), + ) + + processor.on_event(original_event) + + # Get the LLM message from the processed event + processed_event = processed_events[0] + llm_message = processed_event.to_llm_message() + + # The content should include both original message and hook context + content_texts = [ + c.text for c in llm_message.content if isinstance(c, TextContent) + ] + assert "User message" in content_texts + assert "Injected by hook" in content_texts + + def test_additional_context_preserves_existing_extended_content( + self, tmp_path, mock_conversation_state + ): + """Test that hook context is appended to existing extended_content.""" + script = tmp_path / "add_context.sh" + script.write_text( + '#!/bin/bash\necho \'{"additionalContext": "Hook context"}\'\nexit 0' + ) + script.chmod(0o755) + + config = HookConfig.from_dict( + { + "hooks": { + "UserPromptSubmit": [ + {"hooks": [{"type": "command", "command": str(script)}]} + ] + } + } + ) + + manager = HookManager(config=config, working_dir=str(tmp_path)) + processed_events = [] + + def capture_callback(event): + processed_events.append(event) + + processor = HookEventProcessor( + hook_manager=manager, original_callback=capture_callback + ) + processor.set_conversation_state(mock_conversation_state) + + # Create event with existing extended_content + original_event = MessageEvent( + source="user", + llm_message=Message( + role="user", + content=[TextContent(text="Hello")], + ), + extended_content=[TextContent(text="Existing context")], + ) + + processor.on_event(original_event) + + processed_event = processed_events[0] + + # Both existing and hook context should be present + assert len(processed_event.extended_content) == 2 + content_texts = [c.text for c in processed_event.extended_content] + assert "Existing context" in content_texts + assert "Hook context" in content_texts + + +class TestStopHookIntegration: + """Tests for Stop hook integration in conversations.""" + + @pytest.fixture + def mock_conversation_state(self, tmp_path): + """Create a mock conversation state using the factory method.""" + import uuid + + from pydantic import SecretStr + + from openhands.sdk.agent import Agent + from openhands.sdk.llm import LLM + from openhands.sdk.workspace import LocalWorkspace + + llm = LLM(model="test-model", api_key=SecretStr("test-key")) + agent = Agent(llm=llm, tools=[]) + workspace = LocalWorkspace(working_dir=str(tmp_path)) + + return ConversationState.create( + id=uuid.uuid4(), + agent=agent, + workspace=workspace, + persistence_dir=None, + ) + + def test_run_stop_with_allowing_hook(self, tmp_path, mock_conversation_state): + """Test that run_stop returns True when hook allows stopping.""" + script = tmp_path / "allow_stop.sh" + script.write_text('#!/bin/bash\necho \'{"decision": "allow"}\'\nexit 0') + script.chmod(0o755) + + config = HookConfig.from_dict( + { + "hooks": { + "Stop": [{"hooks": [{"type": "command", "command": str(script)}]}] + } + } + ) + + manager = HookManager(config=config, working_dir=str(tmp_path)) + processor = HookEventProcessor(hook_manager=manager) + processor.set_conversation_state(mock_conversation_state) + + should_stop, feedback = processor.run_stop(reason="finish_tool") + + assert should_stop is True + assert feedback is None + + def test_run_stop_with_denying_hook(self, tmp_path, mock_conversation_state): + """Test that run_stop returns False when hook denies stopping.""" + script = tmp_path / "deny_stop.sh" + script.write_text( + "#!/bin/bash\n" + 'echo \'{"decision": "deny", "reason": "Not done yet"}\'\n' + "exit 2" + ) + script.chmod(0o755) + + config = HookConfig.from_dict( + { + "hooks": { + "Stop": [{"hooks": [{"type": "command", "command": str(script)}]}] + } + } + ) + + manager = HookManager(config=config, working_dir=str(tmp_path)) + processor = HookEventProcessor(hook_manager=manager) + processor.set_conversation_state(mock_conversation_state) + + should_stop, feedback = processor.run_stop(reason="finish_tool") + + assert should_stop is False + assert feedback == "Not done yet" + + def test_run_stop_with_additional_context_as_feedback( + self, tmp_path, mock_conversation_state + ): + """Test additional_context is returned as feedback when stop is denied.""" + script = tmp_path / "deny_with_feedback.sh" + context_json = '{"decision": "deny", "additionalContext": "Please complete X"}' + script.write_text(f"#!/bin/bash\necho '{context_json}'\nexit 2") + script.chmod(0o755) + + config = HookConfig.from_dict( + { + "hooks": { + "Stop": [{"hooks": [{"type": "command", "command": str(script)}]}] + } + } + ) + + manager = HookManager(config=config, working_dir=str(tmp_path)) + processor = HookEventProcessor(hook_manager=manager) + processor.set_conversation_state(mock_conversation_state) + + should_stop, feedback = processor.run_stop(reason="finish_tool") + + assert should_stop is False + assert feedback == "Please complete X" + + def test_stop_hook_error_is_logged_and_allows_stop( + self, tmp_path, mock_conversation_state + ): + """Test that hook errors are handled gracefully and stopping is allowed.""" + script = tmp_path / "error_hook.sh" + script.write_text("#!/bin/bash\nexit 1") # Non-blocking error exit + script.chmod(0o755) + + config = HookConfig.from_dict( + { + "hooks": { + "Stop": [{"hooks": [{"type": "command", "command": str(script)}]}] + } + } + ) + + manager = HookManager(config=config, working_dir=str(tmp_path)) + processor = HookEventProcessor(hook_manager=manager) + processor.set_conversation_state(mock_conversation_state) + + should_stop, feedback = processor.run_stop(reason="finish_tool") + + # Error exit (1) doesn't block, so stopping should proceed + assert should_stop is True + assert feedback is None + + +class TestStopHookConversationIntegration: + """Integration tests for Stop hook in LocalConversation run loop.""" + + def test_stop_hook_denial_injects_feedback_and_continues(self, tmp_path): + """Test stop hook denial injects feedback and continues loop.""" + from unittest.mock import patch + + from pydantic import SecretStr + + from openhands.sdk.agent import Agent + from openhands.sdk.conversation import LocalConversation + from openhands.sdk.conversation.state import ConversationExecutionStatus + from openhands.sdk.llm import LLM + + # Create a stop hook that denies stopping the first time, then allows + stop_count_file = tmp_path / "stop_count" + stop_count_file.write_text("0") + + script = tmp_path / "conditional_stop.sh" + script.write_text(f"""#!/bin/bash +count=$(cat {stop_count_file}) +new_count=$((count + 1)) +echo $new_count > {stop_count_file} + +if [ "$count" -eq "0" ]; then + echo '{{"decision": "deny", "additionalContext": "Complete the task first"}}' + exit 2 +else + echo '{{"decision": "allow"}}' + exit 0 +fi +""") + script.chmod(0o755) + + hook_config = HookConfig.from_dict( + { + "hooks": { + "Stop": [{"hooks": [{"type": "command", "command": str(script)}]}] + } + } + ) + + llm = LLM(model="test-model", api_key=SecretStr("test-key")) + agent = Agent(llm=llm, tools=[]) + + # Track events + events_captured = [] + + def capture_event(event): + events_captured.append(event) + + # Create a mock agent that sets FINISHED immediately + step_count = 0 + + def mock_step(self, conversation, on_event, on_token=None): + nonlocal step_count + step_count += 1 + # Always set to FINISHED - the stop hook integration should handle this + conversation.state.execution_status = ConversationExecutionStatus.FINISHED + + with patch.object(Agent, "step", mock_step): + conversation = LocalConversation( + agent=agent, + workspace=tmp_path, + hook_config=hook_config, + callbacks=[capture_event], + visualizer=None, + max_iteration_per_run=10, + ) + + # Send a message to start + conversation.send_message("Hello") + + # Run the conversation + conversation.run() + + # Close to trigger session end + conversation.close() + + # The agent should have been called twice: + # 1. First step sets FINISHED, stop hook denies, feedback injected + # 2. Second step sets FINISHED, stop hook allows, conversation ends + assert step_count == 2 + + # Check that feedback was injected as a user message with prefix + feedback_messages = [ + e + for e in events_captured + if isinstance(e, MessageEvent) + and e.source == "user" + and any( + "[Stop hook feedback] Complete the task first" in c.text + for c in e.llm_message.content + if isinstance(c, TextContent) + ) + ] + assert len(feedback_messages) == 1, "Feedback message should be injected once" From 55c4838b191537cc7c88a563fd67a6e3890b467e Mon Sep 17 00:00:00 2001 From: ak684 Date: Thu, 1 Jan 2026 22:27:31 -0500 Subject: [PATCH 2/5] refactor: reorganize hooks examples into folder structure - Move 33_hooks.py to 33_hooks/ folder with separate scripts - Split into basic_hooks.py and advanced_hooks.py - Remove main() wrapper to match codebase convention (31/33 examples) - Extract shell scripts to scripts/ directory for reusability - Add README.md documenting hook types and usage Addresses review feedback: - Consolidate hook examples into folder structure - Simplify examples by removing main() wrapper --- examples/01_standalone_sdk/33_hooks.py | 161 ------------------ .../01_standalone_sdk/33_hooks/33_hooks.py | 145 ++++++++++++++++ examples/01_standalone_sdk/33_hooks/README.md | 39 +++++ .../33_hooks/hook_scripts/block_dangerous.sh | 14 ++ .../hook_scripts/inject_git_context.sh | 18 ++ .../33_hooks/hook_scripts/log_tools.sh | 9 + .../33_hooks/hook_scripts/require_summary.sh | 11 ++ .../01_standalone_sdk/34_hooks_advanced.py | 155 ----------------- 8 files changed, 236 insertions(+), 316 deletions(-) delete mode 100644 examples/01_standalone_sdk/33_hooks.py create mode 100644 examples/01_standalone_sdk/33_hooks/33_hooks.py create mode 100644 examples/01_standalone_sdk/33_hooks/README.md create mode 100755 examples/01_standalone_sdk/33_hooks/hook_scripts/block_dangerous.sh create mode 100755 examples/01_standalone_sdk/33_hooks/hook_scripts/inject_git_context.sh create mode 100755 examples/01_standalone_sdk/33_hooks/hook_scripts/log_tools.sh create mode 100755 examples/01_standalone_sdk/33_hooks/hook_scripts/require_summary.sh delete mode 100644 examples/01_standalone_sdk/34_hooks_advanced.py diff --git a/examples/01_standalone_sdk/33_hooks.py b/examples/01_standalone_sdk/33_hooks.py deleted file mode 100644 index f640a64a27..0000000000 --- a/examples/01_standalone_sdk/33_hooks.py +++ /dev/null @@ -1,161 +0,0 @@ -"""OpenHands Agent SDK — Hooks Example - -This example demonstrates how to use hooks to intercept and control agent behavior. -Hooks are shell scripts that run at key lifecycle events, enabling: -- Blocking dangerous commands before execution (PreToolUse) -- Logging tool usage after execution (PostToolUse) -- Processing user messages before they reach the agent (UserPromptSubmit) - -Hooks are configured in .openhands/hooks.json or passed programmatically. -""" - -import os -import signal -import tempfile -from pathlib import Path - -from pydantic import SecretStr - -from openhands.sdk import LLM, Conversation -from openhands.sdk.hooks import HookConfig -from openhands.tools.preset.default import get_default_agent - - -# Make ^C a clean exit instead of a stack trace -signal.signal(signal.SIGINT, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt())) - - -def create_example_hooks(tmpdir: Path) -> tuple[HookConfig, Path]: - """Create example hook scripts and configuration. - - This creates two hooks: - 1. A PreToolUse hook that blocks 'rm -rf' commands - 2. A PostToolUse hook that logs all tool usage - """ - # Create a blocking hook that prevents dangerous commands - # Uses jq for JSON parsing (needed for nested fields like tool_input.command) - block_script = tmpdir / "block_dangerous.sh" - block_script.write_text("""#!/bin/bash -# Read JSON input from stdin -input=$(cat) -command=$(echo "$input" | jq -r '.tool_input.command // ""') - -# Block rm -rf commands -if [[ "$command" =~ "rm -rf" ]]; then - echo '{"decision": "deny", "reason": "rm -rf commands are blocked for safety"}' - exit 2 # Exit code 2 = block the operation -fi - -exit 0 # Exit code 0 = allow the operation -""") - block_script.chmod(0o755) - - # Create a logging hook that records tool usage - # Uses OPENHANDS_TOOL_NAME env var (no jq/python needed!) - log_file = tmpdir / "tool_usage.log" - log_script = tmpdir / "log_tools.sh" - log_script.write_text(f"""#!/bin/bash -# OPENHANDS_TOOL_NAME is set by the hooks system -echo "[$(date)] Tool used: $OPENHANDS_TOOL_NAME" >> {log_file} -exit 0 -""") - log_script.chmod(0o755) - - # Create hook configuration - return HookConfig.from_dict( - { - "hooks": { - "PreToolUse": [ - { - "matcher": "terminal", # Only match the terminal tool - "hooks": [ - { - "type": "command", - "command": str(block_script), - "timeout": 10, - } - ], - } - ], - "PostToolUse": [ - { - "matcher": "*", # Match all tools - "hooks": [ - { - "type": "command", - "command": str(log_script), - "timeout": 5, - } - ], - } - ], - } - } - ), log_file - - -def main(): - # Configure LLM - api_key = os.getenv("LLM_API_KEY") - assert api_key is not None, "LLM_API_KEY environment variable is not set." - model = os.getenv("LLM_MODEL", "openhands/claude-sonnet-4-20250514") - base_url = os.getenv("LLM_BASE_URL") - llm = LLM( - usage_id="agent", - model=model, - base_url=base_url, - api_key=SecretStr(api_key), - ) - - # Create a temporary directory for hook scripts - with tempfile.TemporaryDirectory() as tmpdir: - tmpdir = Path(tmpdir) - - # Create example hooks - hook_config, log_file = create_example_hooks(tmpdir) - print(f"Created hook scripts in {tmpdir}") - - # Create agent and conversation with hooks - # Just pass hook_config - it auto-wires everything! - agent = get_default_agent(llm=llm) - conversation = Conversation( - agent=agent, - workspace=os.getcwd(), - hook_config=hook_config, - ) - - print("\n" + "=" * 60) - print("Example 1: Safe command (should work)") - print("=" * 60) - conversation.send_message("Please run: echo 'Hello from hooks example!'") - conversation.run() - - # Show the log file - if log_file.exists(): - print("\n[Log file contents]") - print(log_file.read_text()) - - print("\n" + "=" * 60) - print("Example 2: Dangerous command (should be BLOCKED)") - print("=" * 60) - conversation.send_message("Please run: rm -rf /tmp/test_delete") - conversation.run() - - print("\n" + "=" * 60) - print("Example Complete!") - print("=" * 60) - print("\nKey points:") - print("- PreToolUse hooks run BEFORE tool execution and can block operations") - print("- PostToolUse hooks run AFTER tool execution for logging/auditing") - print("- Exit code 2 from a hook blocks the operation") - print("- Hooks receive JSON on stdin with event details") - print("- Environment variables like $OPENHANDS_TOOL_NAME simplify simple hooks") - print("- Hook config can be in .openhands/hooks.json or passed via hook_config") - - # Report cost - cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost - print(f"EXAMPLE_COST: {cost}") - - -if __name__ == "__main__": - main() diff --git a/examples/01_standalone_sdk/33_hooks/33_hooks.py b/examples/01_standalone_sdk/33_hooks/33_hooks.py new file mode 100644 index 0000000000..50e94791a5 --- /dev/null +++ b/examples/01_standalone_sdk/33_hooks/33_hooks.py @@ -0,0 +1,145 @@ +"""OpenHands Agent SDK — Hooks Example + +Demonstrates the OpenHands hooks system. +Hooks are shell scripts that run at key lifecycle events: + +- PreToolUse: Block dangerous commands before execution +- PostToolUse: Log tool usage after execution +- UserPromptSubmit: Inject context into user messages +- Stop: Enforce task completion criteria + +The hook scripts are in the scripts/ directory alongside this file. +""" + +import os +import signal +import tempfile +from pathlib import Path + +from pydantic import SecretStr + +from openhands.sdk import LLM, Conversation +from openhands.sdk.hooks import HookConfig +from openhands.tools.preset.default import get_default_agent + + +signal.signal(signal.SIGINT, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt())) + +SCRIPT_DIR = Path(__file__).parent / "hook_scripts" + +# Configure LLM +api_key = os.getenv("LLM_API_KEY") +assert api_key is not None, "LLM_API_KEY environment variable is not set." +model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") +base_url = os.getenv("LLM_BASE_URL") + +llm = LLM( + usage_id="agent", + model=model, + base_url=base_url, + api_key=SecretStr(api_key), +) + +# Create temporary workspace with git repo +with tempfile.TemporaryDirectory() as tmpdir: + workspace = Path(tmpdir) + os.system(f"cd {workspace} && git init -q && echo 'test' > file.txt") + + log_file = workspace / "tool_usage.log" + summary_file = workspace / "summary.txt" + + # Configure ALL hook types in one config + hook_config = HookConfig.from_dict( + { + "hooks": { + "PreToolUse": [ + { + "matcher": "terminal", + "hooks": [ + { + "type": "command", + "command": str(SCRIPT_DIR / "block_dangerous.sh"), + "timeout": 10, + } + ], + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": f"LOG_FILE={log_file} {SCRIPT_DIR / 'log_tools.sh'}", + "timeout": 5, + } + ], + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": str(SCRIPT_DIR / "inject_git_context.sh"), + } + ], + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": f"SUMMARY_FILE={summary_file} {SCRIPT_DIR / 'require_summary.sh'}", + } + ], + } + ], + } + } + ) + + agent = get_default_agent(llm=llm) + conversation = Conversation( + agent=agent, + workspace=str(workspace), + hook_config=hook_config, + ) + + # Demo 1: Safe command (PostToolUse logs it) + print("=" * 60) + print("Demo 1: Safe command - logged by PostToolUse") + print("=" * 60) + conversation.send_message("Run: echo 'Hello from hooks!'") + conversation.run() + + if log_file.exists(): + print(f"\n[Log: {log_file.read_text().strip()}]") + + # Demo 2: Dangerous command (PreToolUse blocks it) + print("\n" + "=" * 60) + print("Demo 2: Dangerous command - blocked by PreToolUse") + print("=" * 60) + conversation.send_message("Run: rm -rf /tmp/test") + conversation.run() + + # Demo 3: Context injection + Stop hook enforcement + print("\n" + "=" * 60) + print("Demo 3: Context injection + Stop hook") + print("=" * 60) + print("UserPromptSubmit injects git status; Stop requires summary.txt\n") + conversation.send_message( + "Check what files have changes, then create summary.txt describing the repo." + ) + conversation.run() + + if summary_file.exists(): + print(f"\n[summary.txt: {summary_file.read_text()[:80]}...]") + + print("\n" + "=" * 60) + print("Example Complete!") + print("=" * 60) + + cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost + print(f"\nEXAMPLE_COST: {cost}") diff --git a/examples/01_standalone_sdk/33_hooks/README.md b/examples/01_standalone_sdk/33_hooks/README.md new file mode 100644 index 0000000000..d6809d017a --- /dev/null +++ b/examples/01_standalone_sdk/33_hooks/README.md @@ -0,0 +1,39 @@ +# Hooks Examples + +This folder demonstrates the OpenHands hooks system. + +## Example + +- **33_hooks.py** - Complete hooks demo showing all four hook types + +## Scripts + +The `hook_scripts/` directory contains reusable hook script examples: + +- `block_dangerous.sh` - Blocks rm -rf commands (PreToolUse) +- `log_tools.sh` - Logs tool usage to a file (PostToolUse) +- `inject_git_context.sh` - Injects git status into prompts (UserPromptSubmit) +- `require_summary.sh` - Requires summary.txt before stopping (Stop) + +## Running + +```bash +# Set your LLM credentials +export LLM_API_KEY="your-key" +export LLM_MODEL="anthropic/claude-sonnet-4-5-20250929" # optional +export LLM_BASE_URL="https://your-endpoint" # optional + +# Run example +python 33_hooks.py +``` + +## Hook Types + +| Hook | When it runs | Can block? | +|------|--------------|------------| +| PreToolUse | Before tool execution | Yes (exit 2) | +| PostToolUse | After tool execution | No | +| UserPromptSubmit | Before processing user message | Yes (exit 2) | +| Stop | When agent tries to finish | Yes (exit 2) | +| SessionStart | When conversation starts | No | +| SessionEnd | When conversation ends | No | diff --git a/examples/01_standalone_sdk/33_hooks/hook_scripts/block_dangerous.sh b/examples/01_standalone_sdk/33_hooks/hook_scripts/block_dangerous.sh new file mode 100755 index 0000000000..79b313fafc --- /dev/null +++ b/examples/01_standalone_sdk/33_hooks/hook_scripts/block_dangerous.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# PreToolUse hook: Block dangerous rm -rf commands +# Uses jq for JSON parsing (needed for nested fields like tool_input.command) + +input=$(cat) +command=$(echo "$input" | jq -r '.tool_input.command // ""') + +# Block rm -rf commands +if [[ "$command" =~ "rm -rf" ]]; then + echo '{"decision": "deny", "reason": "rm -rf commands are blocked for safety"}' + exit 2 # Exit code 2 = block the operation +fi + +exit 0 # Exit code 0 = allow the operation diff --git a/examples/01_standalone_sdk/33_hooks/hook_scripts/inject_git_context.sh b/examples/01_standalone_sdk/33_hooks/hook_scripts/inject_git_context.sh new file mode 100755 index 0000000000..51903bf6bb --- /dev/null +++ b/examples/01_standalone_sdk/33_hooks/hook_scripts/inject_git_context.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# UserPromptSubmit hook: Inject git status when user asks about code changes + +input=$(cat) + +# Check if user is asking about changes, diff, or git +if echo "$input" | grep -qiE "(changes|diff|git|commit|modified)"; then + # Get git status if in a git repo + if git rev-parse --git-dir > /dev/null 2>&1; then + status=$(git status --short 2>/dev/null | head -10) + if [ -n "$status" ]; then + # Escape for JSON + escaped=$(echo "$status" | sed 's/"/\\"/g' | tr '\n' ' ') + echo "{\"additionalContext\": \"Current git status: $escaped\"}" + fi + fi +fi +exit 0 diff --git a/examples/01_standalone_sdk/33_hooks/hook_scripts/log_tools.sh b/examples/01_standalone_sdk/33_hooks/hook_scripts/log_tools.sh new file mode 100755 index 0000000000..a186d73102 --- /dev/null +++ b/examples/01_standalone_sdk/33_hooks/hook_scripts/log_tools.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# PostToolUse hook: Log all tool usage +# Uses OPENHANDS_TOOL_NAME env var (no jq/python needed!) + +# LOG_FILE should be set by the calling script +LOG_FILE="${LOG_FILE:-/tmp/tool_usage.log}" + +echo "[$(date)] Tool used: $OPENHANDS_TOOL_NAME" >> "$LOG_FILE" +exit 0 diff --git a/examples/01_standalone_sdk/33_hooks/hook_scripts/require_summary.sh b/examples/01_standalone_sdk/33_hooks/hook_scripts/require_summary.sh new file mode 100755 index 0000000000..26aa0701db --- /dev/null +++ b/examples/01_standalone_sdk/33_hooks/hook_scripts/require_summary.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Stop hook: Require a summary.txt file before allowing agent to finish +# SUMMARY_FILE should be set by the calling script + +SUMMARY_FILE="${SUMMARY_FILE:-./summary.txt}" + +if [ ! -f "$SUMMARY_FILE" ]; then + echo '{"decision": "deny", "additionalContext": "Create summary.txt first."}' + exit 2 +fi +exit 0 diff --git a/examples/01_standalone_sdk/34_hooks_advanced.py b/examples/01_standalone_sdk/34_hooks_advanced.py deleted file mode 100644 index 8132037933..0000000000 --- a/examples/01_standalone_sdk/34_hooks_advanced.py +++ /dev/null @@ -1,155 +0,0 @@ -"""OpenHands Agent SDK — Advanced Hooks Example - -- UserPromptSubmit hook that injects git context when user mentions code changes -- Stop hook that verifies a task was completed before allowing finish - -These patterns are common in production: -- Injecting relevant context (git status, file contents) into user messages -- Enforcing task completion criteria before agent can finish -""" - -import os -import signal -import tempfile -from pathlib import Path - -from pydantic import SecretStr - -from openhands.sdk import LLM, Conversation -from openhands.sdk.hooks import HookConfig -from openhands.tools.preset.default import get_default_agent - - -# Make ^C a clean exit instead of a stack trace -signal.signal(signal.SIGINT, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt())) - - -def create_hooks(workspace: Path) -> tuple[HookConfig, Path]: - """Create hook scripts for the example. - - Creates: - 1. UserPromptSubmit hook - injects git status when user asks about changes - 2. Stop hook - requires a summary.txt file before allowing completion - """ - hook_dir = workspace / ".hooks" - hook_dir.mkdir(exist_ok=True) - - # UserPromptSubmit: Inject git status when user mentions changes/diff/git - context_script = hook_dir / "inject_git_context.sh" - context_script.write_text( - """#!/bin/bash -# Inject git context when user asks about code changes -input=$(cat) - -# Check if user is asking about changes, diff, or git -if echo "$input" | grep -qiE "(changes|diff|git|commit|modified)"; then - # Get git status if in a git repo - if git rev-parse --git-dir > /dev/null 2>&1; then - status=$(git status --short 2>/dev/null | head -10) - if [ -n "$status" ]; then - # Escape for JSON - escaped=$(echo "$status" | sed 's/"/\\\\"/g' | tr '\\n' ' ') - echo "{\\"additionalContext\\": \\"Current git status: $escaped\\"}" - fi - fi -fi -exit 0 -""" - ) - context_script.chmod(0o755) - - # Stop hook: Require summary.txt to exist before allowing completion - summary_file = workspace / "summary.txt" - stop_script = hook_dir / "require_summary.sh" - stop_script.write_text( - f'''#!/bin/bash -# Require a summary.txt file before allowing agent to finish -if [ ! -f "{summary_file}" ]; then - echo '{{"decision": "deny", "additionalContext": "Create summary.txt first."}}' - exit 2 -fi -exit 0 -''' - ) - stop_script.chmod(0o755) - - config = HookConfig.from_dict( - { - "hooks": { - "UserPromptSubmit": [ - { - "hooks": [{"type": "command", "command": str(context_script)}], - } - ], - "Stop": [ - { - "hooks": [{"type": "command", "command": str(stop_script)}], - } - ], - } - } - ) - - return config, summary_file - - -def main(): - # Configure LLM - api_key = os.getenv("LLM_API_KEY") - assert api_key is not None, "LLM_API_KEY environment variable is not set." - model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") - base_url = os.getenv("LLM_BASE_URL") - - llm = LLM( - usage_id="agent", - model=model, - base_url=base_url, - api_key=SecretStr(api_key), - ) - - with tempfile.TemporaryDirectory() as tmpdir: - workspace = Path(tmpdir) - - # Initialize as git repo for the context injection demo - os.system(f"cd {workspace} && git init -q && echo 'test' > file.txt") - - hook_config, summary_file = create_hooks(workspace) - print(f"Workspace: {workspace}") - print(f"Hook scripts created in {workspace}/.hooks/") - - agent = get_default_agent(llm=llm) - conversation = Conversation( - agent=agent, - workspace=str(workspace), - hook_config=hook_config, - ) - - print("\n" + "=" * 60) - print("Demo: Context Injection + Task Completion Enforcement") - print("=" * 60) - print("\nThe UserPromptSubmit hook will inject git status context.") - print("The Stop hook requires summary.txt before agent can finish.\n") - - # This message triggers git context injection and task completion - conversation.send_message( - "Check what files have changes, then create summary.txt " - "describing the repo state." - ) - conversation.run() - - # Verify summary was created - if summary_file.exists(): - print(f"\n[summary.txt created: {summary_file.read_text()[:100]}...]") - else: - print("\n[Warning: summary.txt was not created]") - - print("\n" + "=" * 60) - print("Example Complete!") - print("=" * 60) - - cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost - print(f"\nEXAMPLE_COST: {cost}") - - -if __name__ == "__main__": - main() From 175b0bd38809960b231820f019632640c411dedf Mon Sep 17 00:00:00 2001 From: ak684 Date: Thu, 1 Jan 2026 23:20:34 -0500 Subject: [PATCH 3/5] fix: break long lines in hooks example to pass linting --- examples/01_standalone_sdk/33_hooks/33_hooks.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/01_standalone_sdk/33_hooks/33_hooks.py b/examples/01_standalone_sdk/33_hooks/33_hooks.py index 50e94791a5..a47f9b0f1f 100644 --- a/examples/01_standalone_sdk/33_hooks/33_hooks.py +++ b/examples/01_standalone_sdk/33_hooks/33_hooks.py @@ -70,7 +70,8 @@ "hooks": [ { "type": "command", - "command": f"LOG_FILE={log_file} {SCRIPT_DIR / 'log_tools.sh'}", + "command": f"LOG_FILE={log_file} " + f"{SCRIPT_DIR / 'log_tools.sh'}", "timeout": 5, } ], @@ -91,7 +92,8 @@ "hooks": [ { "type": "command", - "command": f"SUMMARY_FILE={summary_file} {SCRIPT_DIR / 'require_summary.sh'}", + "command": f"SUMMARY_FILE={summary_file} " + f"{SCRIPT_DIR / 'require_summary.sh'}", } ], } From 62831a058865c418c53d7f017271945418a1b76f Mon Sep 17 00:00:00 2001 From: ak684 Date: Sat, 3 Jan 2026 03:12:51 +0000 Subject: [PATCH 4/5] fix: wire up original_callback in hook processor for event forwarding Added tests verifying LocalConversation correctly wires hook callbacks to event persistence via original_callback parameter. --- .../conversation/impl/local_conversation.py | 36 ++++++------- tests/sdk/hooks/test_integration.py | 54 +++++++++++++++++++ 2 files changed, 70 insertions(+), 20 deletions(-) diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index cb141f9567..687271cc38 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -140,42 +140,38 @@ def __init__( def _default_callback(e): self._state.events.append(e) - self._hook_processor = None - hook_callback = None - if hook_config is not None: - self._hook_processor, hook_callback = create_hook_callback( - hook_config=hook_config, - working_dir=str(self.workspace.working_dir), - session_id=str(desired_id), - ) - + # Build the callback chain WITHOUT hooks first callback_list = list(callbacks) if callbacks else [] - if hook_callback is not None: - callback_list.insert(0, hook_callback) - composed_list = callback_list + [_default_callback] + # Handle visualization configuration if isinstance(visualizer, ConversationVisualizerBase): - # Use custom visualizer instance self._visualizer = visualizer - # Initialize the visualizer with conversation state self._visualizer.initialize(self._state) composed_list = [self._visualizer.on_event] + composed_list - # visualizer should happen first for visibility elif isinstance(visualizer, type) and issubclass( visualizer, ConversationVisualizerBase ): - # Instantiate the visualizer class with appropriate parameters self._visualizer = visualizer() - # Initialize with state self._visualizer.initialize(self._state) composed_list = [self._visualizer.on_event] + composed_list - # visualizer should happen first for visibility else: - # No visualization (visualizer is None) self._visualizer = None - self._on_event = BaseConversation.compose_callbacks(composed_list) + # Compose the base callback chain (visualizer -> user callbacks -> default) + base_callback = BaseConversation.compose_callbacks(composed_list) + + # If hooks configured, wrap with hook processor that forwards to base chain + self._hook_processor = None + if hook_config is not None: + self._hook_processor, self._on_event = create_hook_callback( + hook_config=hook_config, + working_dir=str(self.workspace.working_dir), + session_id=str(desired_id), + original_callback=base_callback, + ) + else: + self._on_event = base_callback self._on_token = ( BaseConversation.compose_callbacks(token_callbacks) if token_callbacks diff --git a/tests/sdk/hooks/test_integration.py b/tests/sdk/hooks/test_integration.py index b41ee2d053..78c24c9a59 100644 --- a/tests/sdk/hooks/test_integration.py +++ b/tests/sdk/hooks/test_integration.py @@ -478,6 +478,60 @@ def test_create_hook_callback_returns_processor_and_callback(self, tmp_path): assert callback == processor.on_event +class TestLocalConversationHookCallbackWiring: + """Tests that LocalConversation correctly wires hook callbacks to event persistence.""" + + def test_modified_events_with_additional_context_persisted(self, tmp_path): + """Test that hook-modified events (with additional_context) get persisted.""" + from pydantic import SecretStr + + from openhands.sdk.agent import Agent + from openhands.sdk.conversation import LocalConversation + from openhands.sdk.llm import LLM + + # Create a hook that adds additional_context + script = tmp_path / "add_context.sh" + script.write_text( + '#!/bin/bash\n' + 'echo \'{"additionalContext": "HOOK_INJECTED_CONTEXT"}\'\n' + 'exit 0' + ) + script.chmod(0o755) + + hook_config = HookConfig.from_dict({ + "hooks": { + "UserPromptSubmit": [ + {"hooks": [{"type": "command", "command": str(script)}]} + ] + } + }) + + llm = LLM(model="test-model", api_key=SecretStr("test-key")) + agent = Agent(llm=llm, tools=[]) + + conversation = LocalConversation( + agent=agent, + workspace=str(tmp_path), + hook_config=hook_config, + visualizer=None, + ) + + conversation.send_message("Hello") + + # Verify the MODIFIED event (with extended_content) was persisted + events = list(conversation.state.events) + message_events = [e for e in events if isinstance(e, MessageEvent)] + + assert len(message_events) == 1 + assert len(message_events[0].extended_content) > 0 + assert any( + "HOOK_INJECTED_CONTEXT" in c.text + for c in message_events[0].extended_content + ) + + conversation.close() + + class TestAdditionalContextInjection: """Tests for additional_context injection into LLM messages.""" From 046c60aaf59d1c85ee69d7f5a51929be41add498 Mon Sep 17 00:00:00 2001 From: ak684 Date: Sat, 3 Jan 2026 03:36:52 +0000 Subject: [PATCH 5/5] refactor: remove jq dependency from hook example Use grep on raw JSON input instead of jq for detecting dangerous commands. This makes the example work out of the box without requiring jq installation. --- .../33_hooks/hook_scripts/block_dangerous.sh | 7 +++---- .../conversation/impl/local_conversation.py | 9 +++++++-- tests/sdk/hooks/test_integration.py | 20 ++++++++++--------- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/examples/01_standalone_sdk/33_hooks/hook_scripts/block_dangerous.sh b/examples/01_standalone_sdk/33_hooks/hook_scripts/block_dangerous.sh index 79b313fafc..de0662b077 100755 --- a/examples/01_standalone_sdk/33_hooks/hook_scripts/block_dangerous.sh +++ b/examples/01_standalone_sdk/33_hooks/hook_scripts/block_dangerous.sh @@ -1,12 +1,11 @@ #!/bin/bash # PreToolUse hook: Block dangerous rm -rf commands -# Uses jq for JSON parsing (needed for nested fields like tool_input.command) +# Uses grep on raw JSON input (no jq needed) input=$(cat) -command=$(echo "$input" | jq -r '.tool_input.command // ""') -# Block rm -rf commands -if [[ "$command" =~ "rm -rf" ]]; then +# Block rm -rf commands by checking if the input contains the pattern +if echo "$input" | grep -q "rm -rf"; then echo '{"decision": "deny", "reason": "rm -rf commands are blocked for safety"}' exit 2 # Exit code 2 = block the operation fi diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 687271cc38..9da8fa681a 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -140,22 +140,27 @@ def __init__( def _default_callback(e): self._state.events.append(e) - # Build the callback chain WITHOUT hooks first callback_list = list(callbacks) if callbacks else [] composed_list = callback_list + [_default_callback] - # Handle visualization configuration if isinstance(visualizer, ConversationVisualizerBase): + # Use custom visualizer instance self._visualizer = visualizer + # Initialize the visualizer with conversation state self._visualizer.initialize(self._state) composed_list = [self._visualizer.on_event] + composed_list + # visualizer should happen first for visibility elif isinstance(visualizer, type) and issubclass( visualizer, ConversationVisualizerBase ): + # Instantiate the visualizer class with appropriate parameters self._visualizer = visualizer() + # Initialize with state self._visualizer.initialize(self._state) composed_list = [self._visualizer.on_event] + composed_list + # visualizer should happen first for visibility else: + # No visualization (visualizer is None) self._visualizer = None # Compose the base callback chain (visualizer -> user callbacks -> default) diff --git a/tests/sdk/hooks/test_integration.py b/tests/sdk/hooks/test_integration.py index 78c24c9a59..584540f0ba 100644 --- a/tests/sdk/hooks/test_integration.py +++ b/tests/sdk/hooks/test_integration.py @@ -479,7 +479,7 @@ def test_create_hook_callback_returns_processor_and_callback(self, tmp_path): class TestLocalConversationHookCallbackWiring: - """Tests that LocalConversation correctly wires hook callbacks to event persistence.""" + """Tests that LocalConversation wires hook callbacks to event persistence.""" def test_modified_events_with_additional_context_persisted(self, tmp_path): """Test that hook-modified events (with additional_context) get persisted.""" @@ -492,19 +492,21 @@ def test_modified_events_with_additional_context_persisted(self, tmp_path): # Create a hook that adds additional_context script = tmp_path / "add_context.sh" script.write_text( - '#!/bin/bash\n' + "#!/bin/bash\n" 'echo \'{"additionalContext": "HOOK_INJECTED_CONTEXT"}\'\n' - 'exit 0' + "exit 0" ) script.chmod(0o755) - hook_config = HookConfig.from_dict({ - "hooks": { - "UserPromptSubmit": [ - {"hooks": [{"type": "command", "command": str(script)}]} - ] + hook_config = HookConfig.from_dict( + { + "hooks": { + "UserPromptSubmit": [ + {"hooks": [{"type": "command", "command": str(script)}]} + ] + } } - }) + ) llm = LLM(model="test-model", api_key=SecretStr("test-key")) agent = Agent(llm=llm, tools=[])