Skip to content

Commit 620adb6

Browse files
committed
feat(hooks): add on_user_input
Signed-off-by: Dorin Geman <dorin.geman@docker.com>
1 parent 76a05f0 commit 620adb6

File tree

11 files changed

+142
-15
lines changed

11 files changed

+142
-15
lines changed

agent-schema.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,13 @@
390390
"items": {
391391
"$ref": "#/definitions/HookDefinition"
392392
}
393+
},
394+
"on_user_input": {
395+
"type": "array",
396+
"description": "Hooks that run when the agent needs user input. Can send notifications or log events.",
397+
"items": {
398+
"$ref": "#/definitions/HookDefinition"
399+
}
393400
}
394401
},
395402
"additionalProperties": false

docs/TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
## New Pages
44

55
- [x] **Go SDK** — Not documented anywhere. The `examples/golibrary/` directory shows how to use cagent as a Go library. Needs a dedicated page. *(Completed: pages/guides/go-sdk.html)*
6-
- [x] **Hooks**`hooks` agent config (`pre_tool_use`, `post_tool_use`, `session_start`, `session_end`) is a significant feature with no documentation page. Covers running shell commands at various agent lifecycle points. *(Completed: pages/configuration/hooks.html)*
6+
- [x] **Hooks**`hooks` agent config (`pre_tool_use`, `post_tool_use`, `session_start`, `session_end`, `on_user_input`) is a significant feature with no documentation page. Covers running shell commands at various agent lifecycle points. *(Completed: pages/configuration/hooks.html)*
77
- [x] **Permissions** — Top-level `permissions` config with `allow`/`deny` glob patterns for tool call approval. Mentioned briefly in TUI page but has no dedicated reference. *(Completed: pages/configuration/permissions.html)*
88
- [x] **Sandbox Mode** — Shell tool `sandbox` config runs commands in Docker containers. Includes `image` and `paths` (bind mounts with `:ro` support). Not documented. *(Completed: pages/configuration/sandbox.html)*
99
- [x] **Structured Output** — Agent-level `structured_output` config (name, description, schema, strict). Forces model responses into a JSON schema. Not documented. *(Completed: pages/configuration/structured-output.html)*

docs/pages/configuration/agents.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ <h2>Full Schema</h2>
3333
post_tool_use: [list]
3434
session_start: [list]
3535
session_end: [list]
36+
on_user_input: [list]
3637
permissions: # Optional: tool execution control
3738
allow: [list]
3839
deny: [list]

docs/pages/configuration/hooks.html

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ <h2>Overview</h2>
1818

1919
<h2>Hook Types</h2>
2020

21-
<p>There are four hook event types:</p>
21+
<p>There are five hook event types:</p>
2222

2323
<table>
2424
<thead><tr><th>Event</th><th>When it fires</th><th>Can block?</th></tr></thead>
@@ -27,6 +27,7 @@ <h2>Hook Types</h2>
2727
<tr><td><code>post_tool_use</code></td><td>After a tool completes successfully</td><td>No</td></tr>
2828
<tr><td><code>session_start</code></td><td>When a session begins or resumes</td><td>No</td></tr>
2929
<tr><td><code>session_end</code></td><td>When a session terminates</td><td>No</td></tr>
30+
<tr><td><code>on_user_input</code></td><td>When the agent is waiting for user input</td><td>No</td></tr>
3031
</tbody>
3132
</table>
3233

@@ -61,7 +62,12 @@ <h2>Configuration</h2>
6162
# Run when session ends
6263
session_end:
6364
- type: command
64-
command: "./scripts/cleanup.sh"</code></pre>
65+
command: "./scripts/cleanup.sh"
66+
67+
# Run when agent is waiting for user input
68+
on_user_input:
69+
- type: command
70+
command: "./scripts/notify.sh"</code></pre>
6571

6672
<h2>Matcher Patterns</h2>
6773

@@ -96,17 +102,17 @@ <h2>Hook Input</h2>
96102
<h3>Input Fields by Event Type</h3>
97103

98104
<table>
99-
<thead><tr><th>Field</th><th>pre_tool_use</th><th>post_tool_use</th><th>session_start</th><th>session_end</th></tr></thead>
105+
<thead><tr><th>Field</th><th>pre_tool_use</th><th>post_tool_use</th><th>session_start</th><th>session_end</th><th>on_user_input</th></tr></thead>
100106
<tbody>
101-
<tr><td><code>session_id</code></td><td></td><td></td><td></td><td></td></tr>
102-
<tr><td><code>cwd</code></td><td></td><td></td><td></td><td></td></tr>
103-
<tr><td><code>hook_event_name</code></td><td></td><td></td><td></td><td></td></tr>
104-
<tr><td><code>tool_name</code></td><td></td><td></td><td></td><td></td></tr>
105-
<tr><td><code>tool_use_id</code></td><td></td><td></td><td></td><td></td></tr>
106-
<tr><td><code>tool_input</code></td><td></td><td></td><td></td><td></td></tr>
107-
<tr><td><code>tool_response</code></td><td></td><td></td><td></td><td></td></tr>
108-
<tr><td><code>source</code></td><td></td><td></td><td></td><td></td></tr>
109-
<tr><td><code>reason</code></td><td></td><td></td><td></td><td></td></tr>
107+
<tr><td><code>session_id</code></td><td></td><td></td><td></td><td></td><td></td></tr>
108+
<tr><td><code>cwd</code></td><td></td><td></td><td></td><td></td><td></td></tr>
109+
<tr><td><code>hook_event_name</code></td><td></td><td></td><td></td><td></td><td></td></tr>
110+
<tr><td><code>tool_name</code></td><td></td><td></td><td></td><td></td><td></td></tr>
111+
<tr><td><code>tool_use_id</code></td><td></td><td></td><td></td><td></td><td></td></tr>
112+
<tr><td><code>tool_input</code></td><td></td><td></td><td></td><td></td><td></td></tr>
113+
<tr><td><code>tool_response</code></td><td></td><td></td><td></td><td></td><td></td></tr>
114+
<tr><td><code>source</code></td><td></td><td></td><td></td><td></td><td></td></tr>
115+
<tr><td><code>reason</code></td><td></td><td></td><td></td><td></td><td></td></tr>
110116
</tbody>
111117
</table>
112118

examples/hooks.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,13 @@ agents:
9494
session_end:
9595
- type: command
9696
command: echo "👋 Session ended at $(date)"
97+
98+
# ============================================================
99+
# USER INPUT HOOKS - Run when agent needs user input
100+
# ============================================================
101+
on_user_input:
102+
# Example: Notify when user input is requested
103+
- type: command
104+
command: |
105+
# Send notification (macOS only - remove or adapt for other platforms)
106+
osascript -e 'display notification "ready!" with title "cagent"'

pkg/config/latest/types.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1211,6 +1211,9 @@ type HooksConfig struct {
12111211

12121212
// SessionEnd hooks run when a session ends
12131213
SessionEnd []HookDefinition `json:"session_end,omitempty" yaml:"session_end,omitempty"`
1214+
1215+
// OnUserInput hooks run when the agent needs user input
1216+
OnUserInput []HookDefinition `json:"on_user_input,omitempty" yaml:"on_user_input,omitempty"`
12141217
}
12151218

12161219
// IsEmpty returns true if no hooks are configured
@@ -1221,7 +1224,8 @@ func (h *HooksConfig) IsEmpty() bool {
12211224
return len(h.PreToolUse) == 0 &&
12221225
len(h.PostToolUse) == 0 &&
12231226
len(h.SessionStart) == 0 &&
1224-
len(h.SessionEnd) == 0
1227+
len(h.SessionEnd) == 0 &&
1228+
len(h.OnUserInput) == 0
12251229
}
12261230

12271231
// HookMatcherConfig represents a hook matcher with its hooks.
@@ -1277,6 +1281,13 @@ func (h *HooksConfig) validate() error {
12771281
}
12781282
}
12791283

1284+
// Validate OnUserInput hooks
1285+
for i, hook := range h.OnUserInput {
1286+
if err := hook.validate("on_user_input", i); err != nil {
1287+
return err
1288+
}
1289+
}
1290+
12801291
return nil
12811292
}
12821293

pkg/hooks/config.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,5 +62,14 @@ func FromConfig(cfg *latest.HooksConfig) *Config {
6262
})
6363
}
6464

65+
// Convert OnUserInput
66+
for _, h := range cfg.OnUserInput {
67+
result.OnUserInput = append(result.OnUserInput, Hook{
68+
Type: HookType(h.Type),
69+
Command: h.Command,
70+
Timeout: h.Timeout,
71+
})
72+
}
73+
6574
return result
6675
}

pkg/hooks/executor.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,17 @@ func (e *Executor) ExecuteSessionEnd(ctx context.Context, input *Input) (*Result
191191
return e.executeHooks(ctx, e.config.SessionEnd, input, EventSessionEnd)
192192
}
193193

194+
// ExecuteOnUserInput runs on-user-input hooks
195+
func (e *Executor) ExecuteOnUserInput(ctx context.Context, input *Input) (*Result, error) {
196+
if e.config == nil || len(e.config.OnUserInput) == 0 {
197+
return &Result{Allowed: true}, nil
198+
}
199+
200+
input.HookEventName = EventOnUserInput
201+
202+
return e.executeHooks(ctx, e.config.OnUserInput, input, EventOnUserInput)
203+
}
204+
194205
// executeHooks runs a list of hooks in parallel and aggregates results
195206
func (e *Executor) executeHooks(ctx context.Context, hooks []Hook, input *Input, eventType EventType) (*Result, error) {
196207
// Deduplicate hooks by command
@@ -415,3 +426,8 @@ func (e *Executor) HasSessionStartHooks() bool {
415426
func (e *Executor) HasSessionEndHooks() bool {
416427
return e.config != nil && len(e.config.SessionEnd) > 0
417428
}
429+
430+
// HasOnUserInputHooks returns true if there are any on-user-input hooks configured
431+
func (e *Executor) HasOnUserInputHooks() bool {
432+
return e.config != nil && len(e.config.OnUserInput) > 0
433+
}

pkg/hooks/hooks_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ func TestConfigIsEmpty(t *testing.T) {
8989
},
9090
expected: false,
9191
},
92+
{
93+
name: "with on_user_input",
94+
config: Config{
95+
OnUserInput: []Hook{{Type: HookTypeCommand}},
96+
},
97+
expected: false,
98+
},
9299
}
93100

94101
for _, tt := range tests {
@@ -480,6 +487,25 @@ func TestExecuteSessionEnd(t *testing.T) {
480487
assert.True(t, result.Allowed)
481488
}
482489

490+
func TestExecuteOnUserInput(t *testing.T) {
491+
t.Parallel()
492+
493+
config := &Config{
494+
OnUserInput: []Hook{
495+
{Type: HookTypeCommand, Command: "echo 'user input needed'", Timeout: 5},
496+
},
497+
}
498+
499+
exec := NewExecutor(config, t.TempDir(), nil)
500+
input := &Input{
501+
SessionID: "test-session",
502+
}
503+
504+
result, err := exec.ExecuteOnUserInput(t.Context(), input)
505+
require.NoError(t, err)
506+
assert.True(t, result.Allowed)
507+
}
508+
483509
func TestExecuteHooksWithContextCancellation(t *testing.T) {
484510
t.Parallel()
485511

pkg/hooks/types.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ const (
2828
// SessionEnd is triggered when a session terminates.
2929
// Can perform cleanup, logging, persist session state.
3030
EventSessionEnd EventType = "session_end"
31+
32+
// OnUserInput is triggered when the agent needs input from the user.
33+
// Can log, notify, or perform actions before user interaction.
34+
EventOnUserInput EventType = "on_user_input"
3135
)
3236

3337
// HookType represents the type of hook action
@@ -81,14 +85,18 @@ type Config struct {
8185

8286
// SessionEnd hooks run when a session ends
8387
SessionEnd []Hook `json:"session_end,omitempty" yaml:"session_end,omitempty"`
88+
89+
// OnUserInput hooks run when the agent needs user input
90+
OnUserInput []Hook `json:"on_user_input,omitempty" yaml:"on_user_input,omitempty"`
8491
}
8592

8693
// IsEmpty returns true if no hooks are configured
8794
func (c *Config) IsEmpty() bool {
8895
return len(c.PreToolUse) == 0 &&
8996
len(c.PostToolUse) == 0 &&
9097
len(c.SessionStart) == 0 &&
91-
len(c.SessionEnd) == 0
98+
len(c.SessionEnd) == 0 &&
99+
len(c.OnUserInput) == 0
92100
}
93101

94102
// Input represents the JSON input passed to hooks via stdin

0 commit comments

Comments
 (0)