Skip to content

Commit 514714b

Browse files
authored
Merge pull request #2519 from dgageot/board/improving-hooks-design-for-extensibility-76cf250c
feat(hooks): refactor for extensibility, add 5 events and 3 builtins
2 parents 04ee63f + 97bf594 commit 514714b

30 files changed

Lines changed: 2244 additions & 563 deletions

‎agent-schema.json‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,27 @@
505505
"$ref": "#/definitions/HookDefinition"
506506
}
507507
},
508+
"turn_start": {
509+
"type": "array",
510+
"description": "Hooks that run at the start of every agent turn (each model call). Their AdditionalContext is appended as transient system messages for that turn only — it is NOT persisted to the session, so per-turn signals (date, prompt files) are recomputed every turn instead of bloating message history on every resume.",
511+
"items": {
512+
"$ref": "#/definitions/HookDefinition"
513+
}
514+
},
515+
"before_llm_call": {
516+
"type": "array",
517+
"description": "Hooks that run just before each model call (after turn_start has assembled the messages). Use for observability, cost guardrails, or auditing without contributing system messages — turn_start is the right event for the latter.",
518+
"items": {
519+
"$ref": "#/definitions/HookDefinition"
520+
}
521+
},
522+
"after_llm_call": {
523+
"type": "array",
524+
"description": "Hooks that run just after each successful model call, before the response is recorded into the session and tool calls are dispatched. Receives the assistant text content in stop_response. Failed calls fire on_error instead.",
525+
"items": {
526+
"$ref": "#/definitions/HookDefinition"
527+
}
528+
},
508529
"session_end": {
509530
"type": "array",
510531
"description": "Hooks that run when a session ends. Can perform cleanup or logging.",
@@ -532,6 +553,20 @@
532553
"items": {
533554
"$ref": "#/definitions/HookDefinition"
534555
}
556+
},
557+
"on_error": {
558+
"type": "array",
559+
"description": "Hooks that run when the runtime hits an error during a turn (model failures, repetitive tool-call loops). Fires alongside notification (level=error); use this for structured error-only handlers without parsing notification_level.",
560+
"items": {
561+
"$ref": "#/definitions/HookDefinition"
562+
}
563+
},
564+
"on_max_iterations": {
565+
"type": "array",
566+
"description": "Hooks that run when the runtime reaches its configured max_iterations limit. Fires alongside notification (level=warning); use this for structured handlers (e.g. log to a metrics pipeline) without parsing notification_message.",
567+
"items": {
568+
"$ref": "#/definitions/HookDefinition"
569+
}
535570
}
536571
},
537572
"additionalProperties": false
@@ -569,14 +604,22 @@
569604
"properties": {
570605
"type": {
571606
"type": "string",
572-
"description": "Type of hook (currently only 'command' is supported)",
607+
"description": "Type of hook. 'command' executes a shell command; 'builtin' invokes a named in-process Go function registered by the runtime. The docker-agent runtime ships these builtins: 'add_date', 'add_environment_info', 'add_prompt_files'.",
573608
"enum": [
574-
"command"
609+
"command",
610+
"builtin"
575611
]
576612
},
577613
"command": {
578614
"type": "string",
579-
"description": "Shell command to execute. Receives JSON input via stdin with tool/session information."
615+
"description": "Shell command (type=command) or builtin name (type=builtin) to invoke. Command hooks receive JSON input via stdin with tool/session information."
616+
},
617+
"args": {
618+
"type": "array",
619+
"description": "Arbitrary string arguments passed to the hook handler. Builtin handlers receive them as a typed parameter; e.g. 'add_prompt_files' takes the list of prompt files to load.",
620+
"items": {
621+
"type": "string"
622+
}
580623
},
581624
"timeout": {
582625
"type": "integer",

‎examples/hooks.yaml‎

Lines changed: 195 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,106 +1,256 @@
11
#!/usr/bin/env docker agent run
22
#
3-
# Hooks Example - Demonstrating Allow, Deny, and Modify
3+
# Hooks - one-stop example
4+
# ==========================================================================
45
#
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.
99
#
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)
1547
#
1648

1749
agents:
1850
root:
1951
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.
2153
instruction: |
2254
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.
2657
toolsets:
2758
- type: shell
2859
- type: filesystem
2960

3061
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+
# ====================================================================
3467
pre_tool_use:
35-
# DENY dangerous shell commands
68+
# DENY dangerous shell commands.
3669
- matcher: "shell"
3770
hooks:
3871
- type: command
3972
timeout: 10
4073
command: |
41-
# Read the JSON input from stdin
4274
INPUT=$(cat)
4375
CMD=$(echo "$INPUT" | jq -r '.tool_input.cmd // ""')
44-
45-
# Check for dangerous patterns and DENY if found
4676
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
4877
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."}}
5079
EOF
5180
else
52-
# Allow the command to proceed
5381
echo '{"continue": true}'
5482
fi
5583
56-
# MODIFY: Add human-readable flag to ls commands
84+
# MODIFY: inject -h into bare `ls` calls for human-readable sizes.
5785
- matcher: "shell"
5886
hooks:
5987
- type: command
6088
timeout: 10
6189
command: |
6290
INPUT=$(cat)
6391
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
6692
if echo "$CMD" | grep -qE '^ls(\s|$)' && ! echo "$CMD" | grep -q '\-h'; then
67-
# Modify the command to add -h flag
6893
NEW_CMD=$(echo "$CMD" | sed 's/^ls/ls -h/')
6994
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"}
7196
EOF
7297
fi
73-
# If no modification needed, output nothing (allows by default)
7498
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+
# ====================================================================
78102
post_tool_use:
79103
- matcher: "shell"
80104
hooks:
81105
- type: command
82106
command: |
83107
INPUT=$(cat)
84108
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
85-
echo "✅ Post-hook: $TOOL completed successfully"
109+
echo "✅ Post-hook: $TOOL completed"
86110
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+
# ====================================================================
90116
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:
91158
- 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
93164
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+
# ====================================================================
94183
session_end:
95184
- 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
97191
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+
# ====================================================================
101196
on_user_input:
102-
# Example: Notify when user input is requested
103197
- type: command
104198
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

Comments
 (0)