|
1 | 1 | #!/usr/bin/env docker agent run |
2 | 2 | # |
3 | | -# Hooks Example - Demonstrating Allow, Deny, and Modify |
| 3 | +# Hooks - one-stop example |
| 4 | +# ========================================================================== |
4 | 5 | # |
5 | | -# This example shows how hooks can control tool execution: |
6 | | -# - DENY: Block dangerous shell commands (rm -rf, sudo, etc.) |
7 | | -# - MODIFY: Automatically add safety flags to commands |
8 | | -# - ALLOW: Let safe operations proceed |
| 6 | +# Hooks let you observe and shape an agent's lifecycle. This file is the |
| 7 | +# single canonical example covering every supported event AND both handler |
| 8 | +# kinds, so you can see them side by side. |
9 | 9 | # |
10 | | -# Try these prompts to see hooks in action: |
11 | | -# - "Run: echo hello" → Allowed |
12 | | -# - "Run: rm -rf /tmp/test" → BLOCKED by hook |
13 | | -# - "Run: sudo apt update" → BLOCKED by hook |
14 | | -# - "List files in current dir" → Command gets modified with -h flag |
| 10 | +# Events: |
| 11 | +# |
| 12 | +# pre_tool_use - allow / deny / modify a tool call |
| 13 | +# post_tool_use - inspect a tool call's result (logging, audits) |
| 14 | +# session_start - one-time setup; AdditionalContext PERSISTS in the session |
| 15 | +# turn_start - per-turn context; AdditionalContext is TRANSIENT |
| 16 | +# before_llm_call - just before each model call (observability, guardrails) |
| 17 | +# after_llm_call - just after a successful model call |
| 18 | +# session_end - cleanup when the session terminates |
| 19 | +# on_user_input - the agent is waiting on the user |
| 20 | +# stop - the model finished its response |
| 21 | +# notification - errors / warnings emitted by the runtime (catch-all) |
| 22 | +# on_error - structured handler for runtime errors |
| 23 | +# on_max_iterations - structured handler for hitting max_iterations |
| 24 | +# |
| 25 | +# Handler kinds: |
| 26 | +# |
| 27 | +# command - shell command, JSON Input on stdin, decision returned via |
| 28 | +# stdout JSON or exit codes (2 = block) |
| 29 | +# builtin - in-process Go function registered by the runtime; reuses the |
| 30 | +# `command` field for the builtin's name and the `args` field |
| 31 | +# for parameters. Three are shipped: add_date, |
| 32 | +# add_environment_info, add_prompt_files. |
| 33 | +# |
| 34 | +# Try these prompts: |
| 35 | +# "Run: echo hello" → allowed |
| 36 | +# "Run: rm -rf /tmp/test" → denied by pre_tool_use |
| 37 | +# "Run: sudo apt update" → denied by pre_tool_use |
| 38 | +# "List files in current dir" → ls gets -h injected (modify) |
| 39 | +# |
| 40 | +# Logs (tail these in another terminal): |
| 41 | +# /tmp/agent-session.log (session_start, session_end) |
| 42 | +# /tmp/agent-llm-calls.log (before_llm_call, after_llm_call) |
| 43 | +# /tmp/agent-responses.log (stop) |
| 44 | +# /tmp/agent-notifications.log (notification) |
| 45 | +# /tmp/agent-errors.log (on_error) |
| 46 | +# /tmp/agent-max-iter.log (on_max_iterations) |
15 | 47 | # |
16 | 48 |
|
17 | 49 | agents: |
18 | 50 | root: |
19 | 51 | model: openai/gpt-4o |
20 | | - description: An agent with lifecycle hooks demonstrating allow/deny/modify |
| 52 | + description: One-stop example demonstrating every hook event and both handler kinds. |
21 | 53 | instruction: | |
22 | 54 | You are a helpful assistant with access to shell and filesystem tools. |
23 | | - Use them to help the user with their tasks. |
24 | | -
|
25 | | - When asked to run a command, use the shell tool directly. |
| 55 | + Use them to help the user with their tasks. When asked to run a |
| 56 | + command, use the shell tool directly. |
26 | 57 | toolsets: |
27 | 58 | - type: shell |
28 | 59 | - type: filesystem |
29 | 60 |
|
30 | 61 | hooks: |
31 | | - # ============================================================ |
32 | | - # PRE-TOOL-USE HOOKS - Control what happens BEFORE a tool runs |
33 | | - # ============================================================ |
| 62 | + # ==================================================================== |
| 63 | + # PRE-TOOL-USE - control what happens BEFORE a tool runs. |
| 64 | + # Tool-matched (regex on tool name); each entry can deny, allow with |
| 65 | + # a modified input, or stay silent (default allow). |
| 66 | + # ==================================================================== |
34 | 67 | pre_tool_use: |
35 | | - # DENY dangerous shell commands |
| 68 | + # DENY dangerous shell commands. |
36 | 69 | - matcher: "shell" |
37 | 70 | hooks: |
38 | 71 | - type: command |
39 | 72 | timeout: 10 |
40 | 73 | command: | |
41 | | - # Read the JSON input from stdin |
42 | 74 | INPUT=$(cat) |
43 | 75 | CMD=$(echo "$INPUT" | jq -r '.tool_input.cmd // ""') |
44 | | -
|
45 | | - # Check for dangerous patterns and DENY if found |
46 | 76 | if echo "$CMD" | grep -qiE 'rm\s+(-[^\s]*)?\s*-rf|rm\s+(-[^\s]*)?\s*-fr|sudo|mkfs|dd\s+if=|:(\(\)\{|\s*){.*}'; then |
47 | | - # Output JSON to deny with a reason |
48 | 77 | cat <<EOF |
49 | | - {"hook_specific_output":{"permission_decision":"deny","permission_decision_reason":"🚫 HOOK BLOCKED: Dangerous command pattern detected. Commands with rm -rf, sudo, mkfs, or dd are not allowed."}} |
| 78 | + {"hook_specific_output":{"permission_decision":"deny","permission_decision_reason":"🚫 HOOK BLOCKED: dangerous command pattern detected. rm -rf, sudo, mkfs, dd are not allowed."}} |
50 | 79 | EOF |
51 | 80 | else |
52 | | - # Allow the command to proceed |
53 | 81 | echo '{"continue": true}' |
54 | 82 | fi |
55 | 83 |
|
56 | | - # MODIFY: Add human-readable flag to ls commands |
| 84 | + # MODIFY: inject -h into bare `ls` calls for human-readable sizes. |
57 | 85 | - matcher: "shell" |
58 | 86 | hooks: |
59 | 87 | - type: command |
60 | 88 | timeout: 10 |
61 | 89 | command: | |
62 | 90 | INPUT=$(cat) |
63 | 91 | CMD=$(echo "$INPUT" | jq -r '.tool_input.cmd // ""') |
64 | | -
|
65 | | - # If it's a plain 'ls' command without -h, add -h for human-readable sizes |
66 | 92 | if echo "$CMD" | grep -qE '^ls(\s|$)' && ! echo "$CMD" | grep -q '\-h'; then |
67 | | - # Modify the command to add -h flag |
68 | 93 | NEW_CMD=$(echo "$CMD" | sed 's/^ls/ls -h/') |
69 | 94 | cat <<EOF |
70 | | - {"hook_specific_output":{"permission_decision":"allow","updated_input":{"cmd":"$NEW_CMD"}},"system_message":"📝 Hook modified command: added -h flag for human-readable output"} |
| 95 | + {"hook_specific_output":{"permission_decision":"allow","updated_input":{"cmd":"$NEW_CMD"}},"system_message":"📝 Hook modified command: added -h for human-readable output"} |
71 | 96 | EOF |
72 | 97 | fi |
73 | | - # If no modification needed, output nothing (allows by default) |
74 | 98 |
|
75 | | - # ============================================================ |
76 | | - # POST-TOOL-USE HOOKS - Run AFTER a tool completes |
77 | | - # ============================================================ |
| 99 | + # ==================================================================== |
| 100 | + # POST-TOOL-USE - run AFTER a tool completes (logging, audits). |
| 101 | + # ==================================================================== |
78 | 102 | post_tool_use: |
79 | 103 | - matcher: "shell" |
80 | 104 | hooks: |
81 | 105 | - type: command |
82 | 106 | command: | |
83 | 107 | INPUT=$(cat) |
84 | 108 | TOOL=$(echo "$INPUT" | jq -r '.tool_name') |
85 | | - echo "✅ Post-hook: $TOOL completed successfully" |
| 109 | + echo "✅ Post-hook: $TOOL completed" |
86 | 110 |
|
87 | | - # ============================================================ |
88 | | - # SESSION HOOKS - Run at session start/end |
89 | | - # ============================================================ |
| 111 | + # ==================================================================== |
| 112 | + # SESSION-START - runs ONCE when the session begins. |
| 113 | + # Result.AdditionalContext is appended to the session as a SystemMessage |
| 114 | + # and persists across turns and resumes. |
| 115 | + # ==================================================================== |
90 | 116 | session_start: |
| 117 | + # In-process Go function: reports working dir, OS, arch, git status. |
| 118 | + - type: builtin |
| 119 | + command: add_environment_info |
| 120 | + |
| 121 | + # Custom command hook: log session start and tell the model where |
| 122 | + # the log lives via additional_context. |
| 123 | + - type: command |
| 124 | + timeout: 10 |
| 125 | + command: | |
| 126 | + INPUT=$(cat) |
| 127 | + SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"') |
| 128 | + echo "🚀 Session $SESSION_ID started at $(date)" >> /tmp/agent-session.log |
| 129 | + echo '{"hook_specific_output":{"additional_context":"Session log: /tmp/agent-session.log"}}' |
| 130 | +
|
| 131 | + # ==================================================================== |
| 132 | + # TURN-START - runs at the start of every model call. |
| 133 | + # Result.AdditionalContext is spliced after the invariant cache |
| 134 | + # checkpoint and is NOT persisted, so per-turn signals refresh every |
| 135 | + # turn without bloating the message history. |
| 136 | + # ==================================================================== |
| 137 | + turn_start: |
| 138 | + # Built-in: prepends "Today's date: YYYY-MM-DD". |
| 139 | + - type: builtin |
| 140 | + command: add_date |
| 141 | + |
| 142 | + # Built-in: read each file under the working dir and join its |
| 143 | + # contents into a system message. The `args` field carries per-hook |
| 144 | + # parameters that builtin handlers receive directly. |
| 145 | + - type: builtin |
| 146 | + command: add_prompt_files |
| 147 | + args: |
| 148 | + - GUIDELINES.md |
| 149 | + - PROJECT.md |
| 150 | + |
| 151 | + # ==================================================================== |
| 152 | + # BEFORE-LLM-CALL - fires just before every model invocation, AFTER |
| 153 | + # turn_start has assembled the messages slice. Use for observability |
| 154 | + # / cost guardrails / auditing without contributing system messages |
| 155 | + # (turn_start is the right place for the latter). |
| 156 | + # ==================================================================== |
| 157 | + before_llm_call: |
91 | 158 | - type: command |
92 | | - command: echo "🚀 Session started at $(date). Hooks are active!" |
| 159 | + timeout: 5 |
| 160 | + command: | |
| 161 | + INPUT=$(cat) |
| 162 | + SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"') |
| 163 | + echo "[$(date)] [→] $SESSION_ID llm call starting" >> /tmp/agent-llm-calls.log |
93 | 164 |
|
| 165 | + # ==================================================================== |
| 166 | + # AFTER-LLM-CALL - fires just after a successful model call. The |
| 167 | + # assistant text content arrives via stop_response (matching the |
| 168 | + # stop event's payload). Failed calls fire on_error instead and |
| 169 | + # skip this event. |
| 170 | + # ==================================================================== |
| 171 | + after_llm_call: |
| 172 | + - type: command |
| 173 | + timeout: 5 |
| 174 | + command: | |
| 175 | + INPUT=$(cat) |
| 176 | + SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"') |
| 177 | + LEN=$(echo "$INPUT" | jq -r '.stop_response // ""' | wc -c | tr -d ' ') |
| 178 | + echo "[$(date)] [←] $SESSION_ID llm call complete, content=$LEN chars" >> /tmp/agent-llm-calls.log |
| 179 | +
|
| 180 | + # ==================================================================== |
| 181 | + # SESSION-END - cleanup when the session terminates. |
| 182 | + # ==================================================================== |
94 | 183 | session_end: |
95 | 184 | - type: command |
96 | | - command: echo "👋 Session ended at $(date)" |
| 185 | + timeout: 10 |
| 186 | + command: | |
| 187 | + INPUT=$(cat) |
| 188 | + SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"') |
| 189 | + REASON=$(echo "$INPUT" | jq -r '.reason // "unknown"') |
| 190 | + echo "👋 Session $SESSION_ID ended (reason: $REASON) at $(date)" >> /tmp/agent-session.log |
97 | 191 |
|
98 | | - # ============================================================ |
99 | | - # USER INPUT HOOKS - Run when agent needs user input |
100 | | - # ============================================================ |
| 192 | + # ==================================================================== |
| 193 | + # ON-USER-INPUT - the agent is waiting on the user. |
| 194 | + # Useful for desktop notifications. |
| 195 | + # ==================================================================== |
101 | 196 | on_user_input: |
102 | | - # Example: Notify when user input is requested |
103 | 197 | - type: command |
104 | 198 | command: | |
105 | | - # Send notification (macOS only - remove or adapt for other platforms) |
106 | | - osascript -e 'display notification "ready!" with title "docker-agent"' |
| 199 | + # macOS only — adapt for Linux notify-send / Windows toast. |
| 200 | + osascript -e 'display notification "ready!" with title "docker-agent"' 2>/dev/null || true |
| 201 | +
|
| 202 | + # ==================================================================== |
| 203 | + # STOP - runs when the model finishes a response. |
| 204 | + # Receives the response text via the stop_response field. |
| 205 | + # ==================================================================== |
| 206 | + stop: |
| 207 | + - type: command |
| 208 | + timeout: 10 |
| 209 | + command: | |
| 210 | + INPUT=$(cat) |
| 211 | + SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"') |
| 212 | + LEN=$(echo "$INPUT" | jq -r '.stop_response // ""' | wc -c | tr -d ' ') |
| 213 | + echo "[$(date)] Session $SESSION_ID - response length: $LEN chars" >> /tmp/agent-responses.log |
| 214 | +
|
| 215 | + # ==================================================================== |
| 216 | + # NOTIFICATION - runs when the runtime emits a warning or error |
| 217 | + # (max iterations, model errors, ...). Forward to Slack, Teams, or |
| 218 | + # a logfile. |
| 219 | + # ==================================================================== |
| 220 | + notification: |
| 221 | + - type: command |
| 222 | + timeout: 10 |
| 223 | + command: | |
| 224 | + INPUT=$(cat) |
| 225 | + LEVEL=$(echo "$INPUT" | jq -r '.notification_level // "unknown"') |
| 226 | + MESSAGE=$(echo "$INPUT" | jq -r '.notification_message // "no message"') |
| 227 | + echo "[$(date)] [$LEVEL] $MESSAGE" >> /tmp/agent-notifications.log |
| 228 | +
|
| 229 | + # ==================================================================== |
| 230 | + # ON-ERROR - fires alongside `notification` (level=error) but ONLY |
| 231 | + # for runtime errors. Use this when you want a dedicated entry point |
| 232 | + # for errors without filtering on .notification_level inside the |
| 233 | + # handler. Both events fire for the same condition; you can have |
| 234 | + # either or both. |
| 235 | + # ==================================================================== |
| 236 | + on_error: |
| 237 | + - type: command |
| 238 | + timeout: 10 |
| 239 | + command: | |
| 240 | + INPUT=$(cat) |
| 241 | + MESSAGE=$(echo "$INPUT" | jq -r '.notification_message // "no message"') |
| 242 | + echo "[$(date)] error: $MESSAGE" >> /tmp/agent-errors.log |
| 243 | +
|
| 244 | + # ==================================================================== |
| 245 | + # ON-MAX-ITERATIONS - fires alongside `notification` (level=warning) |
| 246 | + # but ONLY when the agent hits its iteration cap. Useful for |
| 247 | + # metrics pipelines that want to count runaway sessions without |
| 248 | + # parsing the notification text. |
| 249 | + # ==================================================================== |
| 250 | + on_max_iterations: |
| 251 | + - type: command |
| 252 | + timeout: 10 |
| 253 | + command: | |
| 254 | + INPUT=$(cat) |
| 255 | + MESSAGE=$(echo "$INPUT" | jq -r '.notification_message // "no message"') |
| 256 | + echo "[$(date)] max-iterations: $MESSAGE" >> /tmp/agent-max-iter.log |
0 commit comments