From 620adb6c28c0ed593a5c633fca6349fc33271b7b Mon Sep 17 00:00:00 2001 From: Dorin Geman Date: Wed, 25 Feb 2026 16:13:53 +0200 Subject: [PATCH] feat(hooks): add on_user_input Signed-off-by: Dorin Geman --- agent-schema.json | 7 ++++++ docs/TODO.md | 2 +- docs/pages/configuration/agents.html | 1 + docs/pages/configuration/hooks.html | 30 +++++++++++++++---------- examples/hooks.yaml | 10 +++++++++ pkg/config/latest/types.go | 13 ++++++++++- pkg/hooks/config.go | 9 ++++++++ pkg/hooks/executor.go | 16 ++++++++++++++ pkg/hooks/hooks_test.go | 26 ++++++++++++++++++++++ pkg/hooks/types.go | 10 ++++++++- pkg/runtime/runtime.go | 33 ++++++++++++++++++++++++++++ 11 files changed, 142 insertions(+), 15 deletions(-) diff --git a/agent-schema.json b/agent-schema.json index 27c30049a..b229778e8 100644 --- a/agent-schema.json +++ b/agent-schema.json @@ -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 diff --git a/docs/TODO.md b/docs/TODO.md index 44a67ec28..e8fb81262 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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)* diff --git a/docs/pages/configuration/agents.html b/docs/pages/configuration/agents.html index 11984a7f1..1152e0467 100644 --- a/docs/pages/configuration/agents.html +++ b/docs/pages/configuration/agents.html @@ -33,6 +33,7 @@

Full Schema

post_tool_use: [list] session_start: [list] session_end: [list] + on_user_input: [list] permissions: # Optional: tool execution control allow: [list] deny: [list] diff --git a/docs/pages/configuration/hooks.html b/docs/pages/configuration/hooks.html index bfc1bef90..29b4dffc1 100644 --- a/docs/pages/configuration/hooks.html +++ b/docs/pages/configuration/hooks.html @@ -18,7 +18,7 @@

Overview

Hook Types

-

There are four hook event types:

+

There are five hook event types:

@@ -27,6 +27,7 @@

Hook Types

+
EventWhen it firesCan block?
post_tool_useAfter a tool completes successfullyNo
session_startWhen a session begins or resumesNo
session_endWhen a session terminatesNo
on_user_inputWhen the agent is waiting for user inputNo
@@ -61,7 +62,12 @@

Configuration

# Run when session ends session_end: - type: command - command: "./scripts/cleanup.sh" + command: "./scripts/cleanup.sh" + + # Run when agent is waiting for user input + on_user_input: + - type: command + command: "./scripts/notify.sh"

Matcher Patterns

@@ -96,17 +102,17 @@

Hook Input

Input Fields by Event Type

- + - - - - - - - - - + + + + + + + + +
Fieldpre_tool_usepost_tool_usesession_startsession_end
Fieldpre_tool_usepost_tool_usesession_startsession_endon_user_input
session_id
cwd
hook_event_name
tool_name
tool_use_id
tool_input
tool_response
source
reason
session_id
cwd
hook_event_name
tool_name
tool_use_id
tool_input
tool_response
source
reason
diff --git a/examples/hooks.yaml b/examples/hooks.yaml index a7bfd02f9..5b24b15c1 100644 --- a/examples/hooks.yaml +++ b/examples/hooks.yaml @@ -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"' diff --git a/pkg/config/latest/types.go b/pkg/config/latest/types.go index 71ba84883..a8092b266 100644 --- a/pkg/config/latest/types.go +++ b/pkg/config/latest/types.go @@ -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 @@ -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. @@ -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 } diff --git a/pkg/hooks/config.go b/pkg/hooks/config.go index 2fd7ba8fc..299cc5fe7 100644 --- a/pkg/hooks/config.go +++ b/pkg/hooks/config.go @@ -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 } diff --git a/pkg/hooks/executor.go b/pkg/hooks/executor.go index a673a32e3..46dc243f6 100644 --- a/pkg/hooks/executor.go +++ b/pkg/hooks/executor.go @@ -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 @@ -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 +} diff --git a/pkg/hooks/hooks_test.go b/pkg/hooks/hooks_test.go index 7166bd67d..9617de44a 100644 --- a/pkg/hooks/hooks_test.go +++ b/pkg/hooks/hooks_test.go @@ -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 { @@ -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() diff --git a/pkg/hooks/types.go b/pkg/hooks/types.go index f9858f233..5a792bc6f 100644 --- a/pkg/hooks/types.go +++ b/pkg/hooks/types.go @@ -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 @@ -81,6 +85,9 @@ 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 @@ -88,7 +95,8 @@ 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 diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go index 45950b832..a6dc4313f 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -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 { @@ -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) } @@ -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 { @@ -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)