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
7 changes: 7 additions & 0 deletions agent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,13 @@
"items": {
"$ref": "#/definitions/HookDefinition"
}
},
"on_user_input": {
"type": "array",
"description": "Hooks that run when the agent needs user input. Can send notifications or log events.",
"items": {
"$ref": "#/definitions/HookDefinition"
}
}
},
"additionalProperties": false
Expand Down
2 changes: 1 addition & 1 deletion docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## New Pages

- [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)*
- [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)*
- [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)*
- [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)*
- [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)*
- [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)*
Expand Down
1 change: 1 addition & 0 deletions docs/pages/configuration/agents.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ <h2>Full Schema</h2>
post_tool_use: [list]
session_start: [list]
session_end: [list]
on_user_input: [list]
permissions: # Optional: tool execution control
allow: [list]
deny: [list]
Expand Down
30 changes: 18 additions & 12 deletions docs/pages/configuration/hooks.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ <h2>Overview</h2>

<h2>Hook Types</h2>

<p>There are four hook event types:</p>
<p>There are five hook event types:</p>

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

Expand Down Expand Up @@ -61,7 +62,12 @@ <h2>Configuration</h2>
# Run when session ends
session_end:
- type: command
command: "./scripts/cleanup.sh"</code></pre>
command: "./scripts/cleanup.sh"

# Run when agent is waiting for user input
on_user_input:
- type: command
command: "./scripts/notify.sh"</code></pre>

<h2>Matcher Patterns</h2>

Expand Down Expand Up @@ -96,17 +102,17 @@ <h2>Hook Input</h2>
<h3>Input Fields by Event Type</h3>

<table>
<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>
<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>
<tbody>
<tr><td><code>session_id</code></td><td>✓</td><td>✓</td><td>✓</td><td>✓</td></tr>
<tr><td><code>cwd</code></td><td>✓</td><td>✓</td><td>✓</td><td>✓</td></tr>
<tr><td><code>hook_event_name</code></td><td>✓</td><td>✓</td><td>✓</td><td>✓</td></tr>
<tr><td><code>tool_name</code></td><td>✓</td><td>✓</td><td></td><td></td></tr>
<tr><td><code>tool_use_id</code></td><td>✓</td><td>✓</td><td></td><td></td></tr>
<tr><td><code>tool_input</code></td><td>✓</td><td>✓</td><td></td><td></td></tr>
<tr><td><code>tool_response</code></td><td></td><td>✓</td><td></td><td></td></tr>
<tr><td><code>source</code></td><td></td><td></td><td>✓</td><td></td></tr>
<tr><td><code>reason</code></td><td></td><td></td><td></td><td>✓</td></tr>
<tr><td><code>session_id</code></td><td>✓</td><td>✓</td><td>✓</td><td>✓</td><td>✓</td></tr>
<tr><td><code>cwd</code></td><td>✓</td><td>✓</td><td>✓</td><td>✓</td><td>✓</td></tr>
<tr><td><code>hook_event_name</code></td><td>✓</td><td>✓</td><td>✓</td><td>✓</td><td>✓</td></tr>
<tr><td><code>tool_name</code></td><td>✓</td><td>✓</td><td></td><td></td><td></td></tr>
<tr><td><code>tool_use_id</code></td><td>✓</td><td>✓</td><td></td><td></td><td></td></tr>
<tr><td><code>tool_input</code></td><td>✓</td><td>✓</td><td></td><td></td><td></td></tr>
<tr><td><code>tool_response</code></td><td></td><td>✓</td><td></td><td></td><td></td></tr>
<tr><td><code>source</code></td><td></td><td></td><td>✓</td><td></td><td></td></tr>
<tr><td><code>reason</code></td><td></td><td></td><td></td><td>✓</td><td></td></tr>
</tbody>
</table>

Expand Down
10 changes: 10 additions & 0 deletions examples/hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,13 @@ agents:
session_end:
- type: command
command: echo "👋 Session ended at $(date)"

# ============================================================
# USER INPUT HOOKS - Run when agent needs user input
# ============================================================
on_user_input:
# Example: Notify when user input is requested
- type: command
command: |
# Send notification (macOS only - remove or adapt for other platforms)
osascript -e 'display notification "ready!" with title "cagent"'
13 changes: 12 additions & 1 deletion pkg/config/latest/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -1211,6 +1211,9 @@ type HooksConfig struct {

// SessionEnd hooks run when a session ends
SessionEnd []HookDefinition `json:"session_end,omitempty" yaml:"session_end,omitempty"`

// OnUserInput hooks run when the agent needs user input
OnUserInput []HookDefinition `json:"on_user_input,omitempty" yaml:"on_user_input,omitempty"`
}

// IsEmpty returns true if no hooks are configured
Expand All @@ -1221,7 +1224,8 @@ func (h *HooksConfig) IsEmpty() bool {
return len(h.PreToolUse) == 0 &&
len(h.PostToolUse) == 0 &&
len(h.SessionStart) == 0 &&
len(h.SessionEnd) == 0
len(h.SessionEnd) == 0 &&
len(h.OnUserInput) == 0
}

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

// Validate OnUserInput hooks
for i, hook := range h.OnUserInput {
if err := hook.validate("on_user_input", i); err != nil {
return err
}
}

return nil
}

Expand Down
9 changes: 9 additions & 0 deletions pkg/hooks/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,14 @@ func FromConfig(cfg *latest.HooksConfig) *Config {
})
}

// Convert OnUserInput
for _, h := range cfg.OnUserInput {
result.OnUserInput = append(result.OnUserInput, Hook{
Type: HookType(h.Type),
Command: h.Command,
Timeout: h.Timeout,
})
}

return result
}
16 changes: 16 additions & 0 deletions pkg/hooks/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,17 @@ func (e *Executor) ExecuteSessionEnd(ctx context.Context, input *Input) (*Result
return e.executeHooks(ctx, e.config.SessionEnd, input, EventSessionEnd)
}

// ExecuteOnUserInput runs on-user-input hooks
func (e *Executor) ExecuteOnUserInput(ctx context.Context, input *Input) (*Result, error) {
if e.config == nil || len(e.config.OnUserInput) == 0 {
return &Result{Allowed: true}, nil
}

input.HookEventName = EventOnUserInput

return e.executeHooks(ctx, e.config.OnUserInput, input, EventOnUserInput)
}

// executeHooks runs a list of hooks in parallel and aggregates results
func (e *Executor) executeHooks(ctx context.Context, hooks []Hook, input *Input, eventType EventType) (*Result, error) {
// Deduplicate hooks by command
Expand Down Expand Up @@ -415,3 +426,8 @@ func (e *Executor) HasSessionStartHooks() bool {
func (e *Executor) HasSessionEndHooks() bool {
return e.config != nil && len(e.config.SessionEnd) > 0
}

// HasOnUserInputHooks returns true if there are any on-user-input hooks configured
func (e *Executor) HasOnUserInputHooks() bool {
return e.config != nil && len(e.config.OnUserInput) > 0
}
26 changes: 26 additions & 0 deletions pkg/hooks/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ func TestConfigIsEmpty(t *testing.T) {
},
expected: false,
},
{
name: "with on_user_input",
config: Config{
OnUserInput: []Hook{{Type: HookTypeCommand}},
},
expected: false,
},
}

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

func TestExecuteOnUserInput(t *testing.T) {
t.Parallel()

config := &Config{
OnUserInput: []Hook{
{Type: HookTypeCommand, Command: "echo 'user input needed'", Timeout: 5},
},
}

exec := NewExecutor(config, t.TempDir(), nil)
input := &Input{
SessionID: "test-session",
}

result, err := exec.ExecuteOnUserInput(t.Context(), input)
require.NoError(t, err)
assert.True(t, result.Allowed)
}

func TestExecuteHooksWithContextCancellation(t *testing.T) {
t.Parallel()

Expand Down
10 changes: 9 additions & 1 deletion pkg/hooks/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ const (
// SessionEnd is triggered when a session terminates.
// Can perform cleanup, logging, persist session state.
EventSessionEnd EventType = "session_end"

// OnUserInput is triggered when the agent needs input from the user.
// Can log, notify, or perform actions before user interaction.
EventOnUserInput EventType = "on_user_input"
)

// HookType represents the type of hook action
Expand Down Expand Up @@ -81,14 +85,18 @@ type Config struct {

// SessionEnd hooks run when a session ends
SessionEnd []Hook `json:"session_end,omitempty" yaml:"session_end,omitempty"`

// OnUserInput hooks run when the agent needs user input
OnUserInput []Hook `json:"on_user_input,omitempty" yaml:"on_user_input,omitempty"`
}

// IsEmpty returns true if no hooks are configured
func (c *Config) IsEmpty() bool {
return len(c.PreToolUse) == 0 &&
len(c.PostToolUse) == 0 &&
len(c.SessionStart) == 0 &&
len(c.SessionEnd) == 0
len(c.SessionEnd) == 0 &&
len(c.OnUserInput) == 0
}

// Input represents the JSON input passed to hooks via stdin
Expand Down
33 changes: 33 additions & 0 deletions pkg/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,33 @@ func (r *LocalRuntime) getHooksExecutor(a *agent.Agent) *hooks.Executor {
return hooks.NewExecutor(hooksCfg, r.workingDir, r.env)
}

// executeOnUserInputHooks executes on-user-input hooks for the current agent
func (r *LocalRuntime) executeOnUserInputHooks(ctx context.Context, sessionID, logContext string) {
a, _ := r.team.Agent(r.currentAgent)
if a == nil {
return
}

hooksExec := r.getHooksExecutor(a)
if hooksExec == nil || !hooksExec.HasOnUserInputHooks() {
return
}

slog.Debug("Executing on-user-input hooks", "context", logContext)
input := &hooks.Input{
SessionID: sessionID,
Cwd: r.workingDir,
}

result, err := hooksExec.ExecuteOnUserInput(ctx, input)
if err != nil {
slog.Warn("On-user-input hook execution failed", "error", err)
} else {
slog.Debug("On-user-input hooks executed successfully")
}
_ = result // Hook result not used
}

// getAgentModelID returns the model ID for an agent, or empty string if no model is set.
func getAgentModelID(a *agent.Agent) string {
if model := a.Model(); model != nil {
Expand Down Expand Up @@ -867,6 +894,8 @@ func (r *LocalRuntime) finalizeEventChannel(ctx context.Context, sess *session.S

events <- StreamStopped(sess.ID, r.currentAgent)

r.executeOnUserInputHooks(ctx, sess.ID, "stream stopped")

telemetry.RecordSessionEnd(ctx)
}

Expand Down Expand Up @@ -1619,6 +1648,8 @@ func (r *LocalRuntime) askUserForConfirmation(
slog.Debug("Tools not approved, waiting for resume", "tool", toolName, "session_id", sess.ID)
events <- ToolCallConfirmation(toolCall, tool, a.Name())

r.executeOnUserInputHooks(ctx, sess.ID, "tool confirmation")

select {
case req := <-r.resumeChan:
switch req.Type {
Expand Down Expand Up @@ -2022,6 +2053,8 @@ func (r *LocalRuntime) elicitationHandler(ctx context.Context, req *mcp.ElicitPa
return tools.ElicitationResult{}, fmt.Errorf("no events channel available for elicitation")
}

r.executeOnUserInputHooks(ctx, "", "elicitation")

slog.Debug("Sending elicitation request event to client", "message", req.Message, "mode", req.Mode, "requested_schema", req.RequestedSchema, "url", req.URL)
slog.Debug("Elicitation request meta", "meta", req.Meta)

Expand Down