Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/precommit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
161 changes: 0 additions & 161 deletions examples/01_standalone_sdk/33_hooks.py

This file was deleted.

147 changes: 147 additions & 0 deletions examples/01_standalone_sdk/33_hooks/33_hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""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} "
f"{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} "
f"{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}")
39 changes: 39 additions & 0 deletions examples/01_standalone_sdk/33_hooks/README.md
Original file line number Diff line number Diff line change
@@ -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 |
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/bin/bash
# PreToolUse hook: Block dangerous rm -rf commands
# Uses grep on raw JSON input (no jq needed)

input=$(cat)

# 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

exit 0 # Exit code 0 = allow the operation
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions examples/01_standalone_sdk/33_hooks/hook_scripts/log_tools.sh
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading