From 0c6a0cc57371b65930beaa056a87050eacc5d297 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Mon, 16 Mar 2026 20:07:16 +0100 Subject: [PATCH 1/2] Add stop and notification hooks, wire up session lifecycle hooks Wire up session_start and session_end hooks that were defined but never called from the runtime. session_start fires at the beginning of RunStream, session_end fires in finalizeEventChannel with a cancellation-resistant context so cleanup hooks run even on Ctrl+C. Add a new 'stop' hook that fires when the model finishes responding and is about to return control to the user. The hook receives the model's final response content in the stop_response field, enabling response validation, logging, and analytics. Add a new 'notification' hook that fires when the agent emits user-facing errors or warnings (model failures, degenerate loops, max iterations). The hook receives notification_level and notification_message fields. Also wire on_user_input at the max-iterations-reached pause point where it was previously missing. Update documentation, JSON schema, config types, and add examples. Assisted-By: docker-agent --- agent-schema.json | 16 ++- docs/configuration/agents/index.md | 2 + docs/configuration/hooks/index.md | 171 +++++++++++++++++++++----- examples/hooks_notification.yaml | 40 ++++++ examples/hooks_session_lifecycle.yaml | 42 +++++++ examples/hooks_stop.yaml | 36 ++++++ pkg/config/latest/types.go | 24 +++- pkg/hooks/config.go | 18 +++ pkg/hooks/executor.go | 34 ++++- pkg/hooks/hooks_test.go | 100 +++++++++++++++ pkg/hooks/types.go | 26 +++- pkg/runtime/loop.go | 26 +++- pkg/runtime/runtime.go | 102 +++++++++++++++ 13 files changed, 596 insertions(+), 41 deletions(-) create mode 100644 examples/hooks_notification.yaml create mode 100644 examples/hooks_session_lifecycle.yaml create mode 100644 examples/hooks_stop.yaml diff --git a/agent-schema.json b/agent-schema.json index cd14a43fe..6a0f9c558 100644 --- a/agent-schema.json +++ b/agent-schema.json @@ -411,6 +411,20 @@ "items": { "$ref": "#/definitions/HookDefinition" } + }, + "stop": { + "type": "array", + "description": "Hooks that run when the model finishes responding and is about to hand control back to the user. Can perform post-response validation or logging.", + "items": { + "$ref": "#/definitions/HookDefinition" + } + }, + "notification": { + "type": "array", + "description": "Hooks that run when the agent sends a notification (error, warning) to the user. Can send external notifications or log events.", + "items": { + "$ref": "#/definitions/HookDefinition" + } } }, "additionalProperties": false @@ -1559,4 +1573,4 @@ "additionalProperties": false } } -} \ No newline at end of file +} diff --git a/docs/configuration/agents/index.md b/docs/configuration/agents/index.md index 6475a3229..63c31506a 100644 --- a/docs/configuration/agents/index.md +++ b/docs/configuration/agents/index.md @@ -42,6 +42,8 @@ agents: session_start: [list] session_end: [list] on_user_input: [list] + stop: [list] + notification: [list] structured_output: # Optional: constrain output format name: string schema: object diff --git a/docs/configuration/hooks/index.md b/docs/configuration/hooks/index.md index d7669727e..341c2a11f 100644 --- a/docs/configuration/hooks/index.md +++ b/docs/configuration/hooks/index.md @@ -21,20 +21,24 @@ Hooks allow you to execute shell commands or scripts at key points in an agent's - Block dangerous operations based on custom rules - Set up the environment when a session starts - Clean up resources when a session ends +- Log or validate model responses before returning to the user +- Send external notifications on agent errors or warnings ## Hook Types -There are five hook event types: +There are seven hook event types: -| Event | When it fires | Can block? | -| --------------- | ---------------------------------------- | ---------- | -| `pre_tool_use` | Before a tool call executes | Yes | -| `post_tool_use` | After a tool completes successfully | No | -| `session_start` | When a session begins or resumes | No | -| `session_end` | When a session terminates | No | -| `on_user_input` | When the agent is waiting for user input | No | +| Event | When it fires | Can block? | +| ---------------- | ------------------------------------------------------ | ---------- | +| `pre_tool_use` | Before a tool call executes | Yes | +| `post_tool_use` | After a tool completes successfully | No | +| `session_start` | When a session begins or resumes | No | +| `session_end` | When a session terminates | No | +| `on_user_input` | When the agent is waiting for user input | No | +| `stop` | When the model finishes responding | No | +| `notification` | When the agent emits a notification (error or warning) | No | ## Configuration @@ -74,6 +78,16 @@ agents: on_user_input: - type: command command: "./scripts/notify.sh" + + # Run when the model finishes responding + stop: + - type: command + command: "./scripts/log-response.sh" + + # Run on agent errors and warnings + notification: + - type: command + command: "./scripts/alert.sh" ``` ## Matcher Patterns @@ -107,22 +121,29 @@ Hooks receive JSON input via stdin with context about the event: ### Input Fields by Event Type -| Field | pre_tool_use | post_tool_use | session_start | session_end | on_user_input | -| ----------------- | ------------ | ------------- | ------------- | ----------- | ------------- | -| `session_id` | ✓ | ✓ | ✓ | ✓ | ✓ | -| `cwd` | ✓ | ✓ | ✓ | ✓ | ✓ | -| `hook_event_name` | ✓ | ✓ | ✓ | ✓ | ✓ | -| `tool_name` | ✓ | ✓ | | | | -| `tool_use_id` | ✓ | ✓ | | | | -| `tool_input` | ✓ | ✓ | | | | -| `tool_response` | | ✓ | | | | -| `source` | | | ✓ | | | -| `reason` | | | | ✓ | | +| Field | pre_tool_use | post_tool_use | session_start | session_end | on_user_input | stop | notification | +| ---------------------- | ------------ | ------------- | ------------- | ----------- | ------------- | ---- | ------------ | +| `session_id` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| `cwd` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| `hook_event_name` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| `tool_name` | ✓ | ✓ | | | | | | +| `tool_use_id` | ✓ | ✓ | | | | | | +| `tool_input` | ✓ | ✓ | | | | | | +| `tool_response` | | ✓ | | | | | | +| `source` | | | ✓ | | | | | +| `reason` | | | | ✓ | | | | +| `stop_response` | | | | | | ✓ | | +| `notification_level` | | | | | | | ✓ | +| `notification_message` | | | | | | | ✓ | The `source` field for `session_start` can be: `startup`, `resume`, `clear`, or `compact`. The `reason` field for `session_end` can be: `clear`, `logout`, `prompt_input_exit`, or `other`. +The `stop_response` field contains the model's final text response. + +The `notification_level` field can be: `error` or `warning`. + ## Hook Output Hooks communicate back via JSON output to stdout: @@ -165,6 +186,10 @@ The `hook_specific_output` for `pre_tool_use` supports: | `permission_decision_reason` | string | Explanation for the decision | | `updated_input` | object | Modified tool input (replaces original) | +### Plain Text Output + +For `session_start`, `post_tool_use`, and `stop` hooks, plain text written to stdout (i.e., output that is not valid JSON) is captured as additional context for the agent. + ## Exit Codes Hook exit codes have special meaning: @@ -175,7 +200,37 @@ Hook exit codes have special meaning: | `2` | Blocking error — stop the operation | | Other | Error — logged but execution continues | -## Example: Validation Script +## Timeout + +Hooks have a default timeout of 60 seconds. You can customize this per hook: + +```yaml +hooks: + pre_tool_use: + - matcher: "*" + hooks: + - type: command + command: "./slow-validation.sh" + timeout: 120 # 2 minutes +``` + +
+
⚠️ Performance +
+

Hooks run synchronously and can slow down agent execution. Keep hook scripts fast and efficient. Consider using suppress_output: true for logging hooks to reduce noise.

+ +
+ +
+
ℹ️ Session End and Cancellation +
+

session_end hooks are designed to run even when the session is interrupted (e.g., Ctrl+C). They are still subject to their configured timeout.

+ +
+ +## Examples + +### Validation Script A simple pre-tool-use hook that blocks dangerous shell commands: @@ -201,7 +256,7 @@ echo '{"decision": "allow"}' exit 0 ``` -## Example: Audit Logging +### Audit Logging A post-tool-use hook that logs all tool calls: @@ -222,24 +277,74 @@ echo '{"continue": true}' exit 0 ``` -## Timeout +### Session Lifecycle -Hooks have a default timeout of 60 seconds. You can customize this per hook: +Session start and end hooks for environment setup and cleanup: ```yaml hooks: - pre_tool_use: - - matcher: "*" - hooks: - - type: command - command: "./slow-validation.sh" - timeout: 120 # 2 minutes + session_start: + - type: command + timeout: 10 + command: | + INPUT=$(cat) + SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"') + echo "Session $SESSION_ID started at $(date)" >> /tmp/agent-session.log + echo '{"hook_specific_output":{"additional_context":"Session initialized."}}' + + session_end: + - type: command + timeout: 10 + command: | + INPUT=$(cat) + SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"') + REASON=$(echo "$INPUT" | jq -r '.reason // "unknown"') + echo "Session $SESSION_ID ended ($REASON) at $(date)" >> /tmp/agent-session.log ``` -
-
⚠️ Performance -
-

Hooks run synchronously and can slow down agent execution. Keep hook scripts fast and efficient. Consider using suppress_output: true for logging hooks to reduce noise.

+### Response Logging with Stop Hook + +Log every model response for analytics or compliance: + +```yaml +hooks: + stop: + - type: command + timeout: 10 + command: | + INPUT=$(cat) + SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"') + RESPONSE_LENGTH=$(echo "$INPUT" | jq -r '.stop_response // ""' | wc -c | tr -d ' ') + echo "[$(date)] Session $SESSION_ID - Response: $RESPONSE_LENGTH chars" >> /tmp/agent-responses.log +``` + +The `stop` hook is useful for: + +- **Response quality checks** — validate that responses meet criteria before returning +- **Analytics** — track response lengths, patterns, or content +- **Compliance logging** — record all agent outputs for audit + +### Error Notifications + +Send alerts when the agent encounters errors: + +```yaml +hooks: + notification: + - type: command + timeout: 10 + command: | + INPUT=$(cat) + LEVEL=$(echo "$INPUT" | jq -r '.notification_level // "unknown"') + MESSAGE=$(echo "$INPUT" | jq -r '.notification_message // "no message"') + echo "[$(date)] [$LEVEL] $MESSAGE" >> /tmp/agent-notifications.log +``` + +The `notification` hook fires when: + +- The model returns an error (all models failed) +- A degenerate tool call loop is detected +- The maximum iteration limit is reached
diff --git a/examples/hooks_notification.yaml b/examples/hooks_notification.yaml new file mode 100644 index 000000000..762760d47 --- /dev/null +++ b/examples/hooks_notification.yaml @@ -0,0 +1,40 @@ +#!/usr/bin/env docker agent run +# +# Notification Hook Example +# +# This example demonstrates the notification hook, which fires whenever +# the agent sends a notification to the user — such as errors, warnings, +# or when the agent pauses for user input (max iterations reached). +# +# The hook receives JSON on stdin with: +# - notification_level: "error" or "warning" +# - notification_message: the notification content +# +# Use cases: +# - Send Slack/Teams notifications when errors occur +# - Log all agent notifications for audit trails +# - Send desktop notifications (e.g., via osascript on macOS) +# +# Try it: +# - Run the agent and trigger an error condition to see the notification +# - Check /tmp/agent-notifications.log for logged notifications +# + +agents: + root: + model: openai/gpt-4o + description: An agent with notification hooks + instruction: | + You are a helpful assistant. + toolsets: + - type: shell + + hooks: + notification: + - type: command + timeout: 10 + command: | + INPUT=$(cat) + LEVEL=$(echo "$INPUT" | jq -r '.notification_level // "unknown"') + MESSAGE=$(echo "$INPUT" | jq -r '.notification_message // "no message"') + echo "[$(date)] [$LEVEL] $MESSAGE" >> /tmp/agent-notifications.log diff --git a/examples/hooks_session_lifecycle.yaml b/examples/hooks_session_lifecycle.yaml new file mode 100644 index 000000000..a2d4ded36 --- /dev/null +++ b/examples/hooks_session_lifecycle.yaml @@ -0,0 +1,42 @@ +#!/usr/bin/env docker agent run +# +# Session Lifecycle Hooks Example +# +# This example demonstrates session_start and session_end hooks. +# These hooks run when the agent session begins and ends, allowing +# you to set up the environment, load context, or perform cleanup. +# +# Try these scenarios: +# - Start the agent and see the session start message +# - Ask a question, then exit to see the session end message +# - Check /tmp/agent-session.log for the session log +# + +agents: + root: + model: openai/gpt-4o + description: An agent with session lifecycle hooks + instruction: | + You are a helpful assistant. When the user asks what happened at startup, + tell them about the session hooks that ran. + toolsets: + - type: shell + + hooks: + session_start: + - type: command + timeout: 10 + command: | + INPUT=$(cat) + SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"') + echo "🚀 Session $SESSION_ID started at $(date)" >> /tmp/agent-session.log + echo '{"hook_specific_output":{"additional_context":"Session initialized. Log file: /tmp/agent-session.log"}}' + + session_end: + - type: command + timeout: 10 + command: | + INPUT=$(cat) + SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"') + REASON=$(echo "$INPUT" | jq -r '.reason // "unknown"') + echo "👋 Session $SESSION_ID ended (reason: $REASON) at $(date)" >> /tmp/agent-session.log diff --git a/examples/hooks_stop.yaml b/examples/hooks_stop.yaml new file mode 100644 index 000000000..13e29de2b --- /dev/null +++ b/examples/hooks_stop.yaml @@ -0,0 +1,36 @@ +#!/usr/bin/env docker agent run +# +# Stop Hook Example +# +# This example demonstrates the stop hook, which fires whenever the model +# finishes its response and is about to return control to the user. +# +# The hook receives the model's final response content via stdin as JSON +# (in the "stop_response" field), enabling use cases like: +# - Response quality validation +# - Logging and analytics +# - Sending notifications when the agent replies +# +# Try these scenarios: +# - Ask the agent a question and see the stop hook log the response length +# - Check /tmp/agent-responses.log for a log of all responses +# + +agents: + root: + model: openai/gpt-4o + description: An agent with a stop hook that logs responses + instruction: | + You are a helpful assistant. Answer questions concisely. + toolsets: + - type: shell + + hooks: + stop: + - type: command + timeout: 10 + command: | + INPUT=$(cat) + SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"') + RESPONSE_LENGTH=$(echo "$INPUT" | jq -r '.stop_response // ""' | wc -c | tr -d ' ') + echo "[$(date)] Session $SESSION_ID - Response length: $RESPONSE_LENGTH chars" >> /tmp/agent-responses.log diff --git a/pkg/config/latest/types.go b/pkg/config/latest/types.go index 679a73a70..ff052ba89 100644 --- a/pkg/config/latest/types.go +++ b/pkg/config/latest/types.go @@ -1291,6 +1291,12 @@ type HooksConfig struct { // OnUserInput hooks run when the agent needs user input OnUserInput []HookDefinition `json:"on_user_input,omitempty" yaml:"on_user_input,omitempty"` + + // Stop hooks run when the model finishes responding and is about to hand control back to the user + Stop []HookDefinition `json:"stop,omitempty" yaml:"stop,omitempty"` + + // Notification hooks run when the agent sends a notification (error, warning) to the user + Notification []HookDefinition `json:"notification,omitempty" yaml:"notification,omitempty"` } // IsEmpty returns true if no hooks are configured @@ -1302,7 +1308,9 @@ func (h *HooksConfig) IsEmpty() bool { len(h.PostToolUse) == 0 && len(h.SessionStart) == 0 && len(h.SessionEnd) == 0 && - len(h.OnUserInput) == 0 + len(h.OnUserInput) == 0 && + len(h.Stop) == 0 && + len(h.Notification) == 0 } // HookMatcherConfig represents a hook matcher with its hooks. @@ -1365,6 +1373,20 @@ func (h *HooksConfig) validate() error { } } + // Validate Stop hooks + for i, hook := range h.Stop { + if err := hook.validate("stop", i); err != nil { + return err + } + } + + // Validate Notification hooks + for i, hook := range h.Notification { + if err := hook.validate("notification", i); err != nil { + return err + } + } + return nil } diff --git a/pkg/hooks/config.go b/pkg/hooks/config.go index b1565ac7e..af80df7ff 100644 --- a/pkg/hooks/config.go +++ b/pkg/hooks/config.go @@ -71,5 +71,23 @@ func FromConfig(cfg *latest.HooksConfig) *Config { }) } + // Convert Stop + for _, h := range cfg.Stop { + result.Stop = append(result.Stop, Hook{ + Type: HookType(h.Type), + Command: h.Command, + Timeout: h.Timeout, + }) + } + + // Convert Notification + for _, h := range cfg.Notification { + result.Notification = append(result.Notification, 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 427a9200d..2e3dc4a4e 100644 --- a/pkg/hooks/executor.go +++ b/pkg/hooks/executor.go @@ -188,6 +188,28 @@ func (e *Executor) ExecuteOnUserInput(ctx context.Context, input *Input) (*Resul return e.executeHooks(ctx, e.config.OnUserInput, input, EventOnUserInput) } +// ExecuteStop runs stop hooks when the model finishes responding +func (e *Executor) ExecuteStop(ctx context.Context, input *Input) (*Result, error) { + if e.config == nil || len(e.config.Stop) == 0 { + return &Result{Allowed: true}, nil + } + + input.HookEventName = EventStop + + return e.executeHooks(ctx, e.config.Stop, input, EventStop) +} + +// ExecuteNotification runs notification hooks when the agent emits a notification +func (e *Executor) ExecuteNotification(ctx context.Context, input *Input) (*Result, error) { + if e.config == nil || len(e.config.Notification) == 0 { + return &Result{Allowed: true}, nil + } + + input.HookEventName = EventNotification + + return e.executeHooks(ctx, e.config.Notification, input, EventNotification) +} + // 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 @@ -373,7 +395,7 @@ func (e *Executor) aggregateResults(results []hookResult, eventType EventType) ( } } else if r.stdout != "" { // Plain text stdout is added as context for some events - if eventType == EventSessionStart || eventType == EventPostToolUse { + if eventType == EventSessionStart || eventType == EventPostToolUse || eventType == EventStop { additionalContexts = append(additionalContexts, strings.TrimSpace(r.stdout)) } } @@ -417,3 +439,13 @@ func (e *Executor) HasSessionEndHooks() bool { func (e *Executor) HasOnUserInputHooks() bool { return e.config != nil && len(e.config.OnUserInput) > 0 } + +// HasStopHooks returns true if there are any stop hooks configured +func (e *Executor) HasStopHooks() bool { + return e.config != nil && len(e.config.Stop) > 0 +} + +// HasNotificationHooks returns true if there are any notification hooks configured +func (e *Executor) HasNotificationHooks() bool { + return e.config != nil && len(e.config.Notification) > 0 +} diff --git a/pkg/hooks/hooks_test.go b/pkg/hooks/hooks_test.go index 9617de44a..4e307d45b 100644 --- a/pkg/hooks/hooks_test.go +++ b/pkg/hooks/hooks_test.go @@ -96,6 +96,20 @@ func TestConfigIsEmpty(t *testing.T) { }, expected: false, }, + { + name: "with stop", + config: Config{ + Stop: []Hook{{Type: HookTypeCommand}}, + }, + expected: false, + }, + { + name: "with notification", + config: Config{ + Notification: []Hook{{Type: HookTypeCommand}}, + }, + expected: false, + }, } for _, tt := range tests { @@ -221,6 +235,8 @@ func TestNewExecutor(t *testing.T) { assert.False(t, exec.HasPostToolUseHooks()) assert.False(t, exec.HasSessionStartHooks()) assert.False(t, exec.HasSessionEndHooks()) + assert.False(t, exec.HasStopHooks()) + assert.False(t, exec.HasNotificationHooks()) } func TestExecutorNilConfig(t *testing.T) { @@ -506,6 +522,90 @@ func TestExecuteOnUserInput(t *testing.T) { assert.True(t, result.Allowed) } +func TestExecuteStop(t *testing.T) { + t.Parallel() + + config := &Config{ + Stop: []Hook{ + {Type: HookTypeCommand, Command: "echo 'model stopped'", Timeout: 5}, + }, + } + + exec := NewExecutor(config, t.TempDir(), nil) + input := &Input{ + SessionID: "test-session", + StopResponse: "Here is the answer to your question.", + } + + result, err := exec.ExecuteStop(t.Context(), input) + require.NoError(t, err) + assert.True(t, result.Allowed) + assert.Contains(t, result.AdditionalContext, "model stopped") +} + +func TestExecuteStopReceivesResponseContent(t *testing.T) { + t.Parallel() + + config := &Config{ + Stop: []Hook{ + {Type: HookTypeCommand, Command: "cat | jq -r '.stop_response'", Timeout: 5}, + }, + } + + exec := NewExecutor(config, t.TempDir(), nil) + input := &Input{ + SessionID: "test-session", + StopResponse: "final answer content", + } + + result, err := exec.ExecuteStop(t.Context(), input) + require.NoError(t, err) + assert.True(t, result.Allowed) + assert.Contains(t, result.AdditionalContext, "final answer content") +} + +func TestExecuteNotification(t *testing.T) { + t.Parallel() + + config := &Config{ + Notification: []Hook{ + {Type: HookTypeCommand, Command: "echo 'notification received'", Timeout: 5}, + }, + } + + exec := NewExecutor(config, t.TempDir(), nil) + input := &Input{ + SessionID: "test-session", + NotificationLevel: "error", + NotificationMessage: "Something went wrong", + } + + result, err := exec.ExecuteNotification(t.Context(), input) + require.NoError(t, err) + assert.True(t, result.Allowed) +} + +func TestExecuteNotificationReceivesLevel(t *testing.T) { + t.Parallel() + + config := &Config{ + Notification: []Hook{ + {Type: HookTypeCommand, Command: "cat | jq -r '.notification_level'", Timeout: 5}, + }, + } + + exec := NewExecutor(config, t.TempDir(), nil) + input := &Input{ + SessionID: "test-session", + NotificationLevel: "warning", + NotificationMessage: "Watch out", + } + + result, err := exec.ExecuteNotification(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 5a792bc6f..e32e84744 100644 --- a/pkg/hooks/types.go +++ b/pkg/hooks/types.go @@ -32,6 +32,15 @@ const ( // 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" + + // Stop is triggered when the model finishes its response and is about + // to hand control back to the user. Can perform post-response validation, + // logging, or cleanup. + EventStop EventType = "stop" + + // Notification is triggered when the agent emits a notification to the user, + // such as errors or warnings. Can send external notifications or log events. + EventNotification EventType = "notification" ) // HookType represents the type of hook action @@ -88,6 +97,12 @@ type Config struct { // OnUserInput hooks run when the agent needs user input OnUserInput []Hook `json:"on_user_input,omitempty" yaml:"on_user_input,omitempty"` + + // Stop hooks run when the model finishes responding + Stop []Hook `json:"stop,omitempty" yaml:"stop,omitempty"` + + // Notification hooks run when the agent sends a notification (error, warning) to the user + Notification []Hook `json:"notification,omitempty" yaml:"notification,omitempty"` } // IsEmpty returns true if no hooks are configured @@ -96,7 +111,9 @@ func (c *Config) IsEmpty() bool { len(c.PostToolUse) == 0 && len(c.SessionStart) == 0 && len(c.SessionEnd) == 0 && - len(c.OnUserInput) == 0 + len(c.OnUserInput) == 0 && + len(c.Stop) == 0 && + len(c.Notification) == 0 } // Input represents the JSON input passed to hooks via stdin @@ -119,6 +136,13 @@ type Input struct { // SessionEnd specific Reason string `json:"reason,omitempty"` // "clear", "logout", "prompt_input_exit", "other" + + // Stop specific + StopResponse string `json:"stop_response,omitempty"` // The model's final response content + + // Notification specific + NotificationLevel string `json:"notification_level,omitempty"` // "error" or "warning" + NotificationMessage string `json:"notification_message,omitempty"` // The notification content } // ToJSON serializes the input to JSON diff --git a/pkg/runtime/loop.go b/pkg/runtime/loop.go index 3f768e958..a8db664af 100644 --- a/pkg/runtime/loop.go +++ b/pkg/runtime/loop.go @@ -52,7 +52,13 @@ func (r *LocalRuntime) finalizeEventChannel(ctx context.Context, sess *session.S defer close(events) - events <- StreamStopped(sess.ID, r.resolveSessionAgent(sess).Name()) + a := r.resolveSessionAgent(sess) + + // Execute session end hooks with a context that won't be cancelled so + // cleanup hooks run even when the stream was interrupted (e.g. Ctrl+C). + r.executeSessionEndHooks(context.WithoutCancel(ctx), sess, a) + + events <- StreamStopped(sess.ID, a.Name()) r.executeOnUserInputHooks(ctx, sess.ID, "stream stopped") @@ -85,6 +91,9 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c a := r.resolveSessionAgent(sess) + // Execute session start hooks + r.executeSessionStartHooks(ctx, sess, a, events) + // Emit team information events <- TeamInfo(r.agentDetailsFromTeam(), a.Name()) @@ -165,6 +174,10 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c events <- MaxIterationsReached(runtimeMaxIterations) + maxIterMsg := fmt.Sprintf("Maximum iterations reached (%d)", runtimeMaxIterations) + r.executeNotificationHooks(ctx, a, sess.ID, "warning", maxIterMsg) + r.executeOnUserInputHooks(ctx, sess.ID, "max iterations reached") + // Wait for user decision (resume / reject) select { case req := <-r.resumeChan: @@ -307,7 +320,9 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c slog.Error("All models failed", "agent", a.Name(), "error", err) // Track error in telemetry telemetry.RecordError(ctx, err.Error()) - events <- Error(modelerrors.FormatError(err)) + errMsg := modelerrors.FormatError(err) + events <- Error(errMsg) + r.executeNotificationHooks(ctx, a, sess.ID, "error", errMsg) streamSpan.End() return } @@ -345,10 +360,12 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c slog.Warn("Repetitive tool call loop detected", "agent", a.Name(), "tool", toolName, "consecutive", loopDetector.consecutive, "session_id", sess.ID) - events <- Error(fmt.Sprintf( + errMsg := fmt.Sprintf( "Agent terminated: detected %d consecutive identical calls to %s. "+ "This indicates a degenerate loop where the model is not making progress.", - loopDetector.consecutive, toolName)) + loopDetector.consecutive, toolName) + events <- Error(errMsg) + r.executeNotificationHooks(ctx, a, sess.ID, "error", errMsg) return } @@ -357,6 +374,7 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c if res.Stopped { slog.Debug("Conversation stopped", "agent", a.Name()) + r.executeStopHooks(ctx, sess, a, res.Content, events) break } diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go index 5d01d55f8..2907a6020 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -649,6 +649,108 @@ func (r *LocalRuntime) getHooksExecutor(a *agent.Agent) *hooks.Executor { return hooks.NewExecutor(hooksCfg, r.workingDir, r.env) } +// executeSessionStartHooks executes session start hooks for the given agent. +// It logs the hook output as additional context and emits warnings for system messages. +func (r *LocalRuntime) executeSessionStartHooks(ctx context.Context, sess *session.Session, a *agent.Agent, events chan Event) { + hooksExec := r.getHooksExecutor(a) + if hooksExec == nil || !hooksExec.HasSessionStartHooks() { + return + } + + slog.Debug("Executing session start hooks", "agent", a.Name(), "session_id", sess.ID) + input := &hooks.Input{ + SessionID: sess.ID, + Cwd: r.workingDir, + Source: "startup", + } + + result, err := hooksExec.ExecuteSessionStart(ctx, input) + if err != nil { + slog.Warn("Session start hook execution failed", "agent", a.Name(), "error", err) + return + } + + if result.SystemMessage != "" { + events <- Warning(result.SystemMessage, a.Name()) + } + if result.AdditionalContext != "" { + slog.Debug("Session start hook provided additional context", "context", result.AdditionalContext) + } +} + +// executeSessionEndHooks executes session end hooks for the given agent. +func (r *LocalRuntime) executeSessionEndHooks(ctx context.Context, sess *session.Session, a *agent.Agent) { + hooksExec := r.getHooksExecutor(a) + if hooksExec == nil || !hooksExec.HasSessionEndHooks() { + return + } + + slog.Debug("Executing session end hooks", "agent", a.Name(), "session_id", sess.ID) + input := &hooks.Input{ + SessionID: sess.ID, + Cwd: r.workingDir, + Reason: "stream_ended", + } + + _, err := hooksExec.ExecuteSessionEnd(ctx, input) + if err != nil { + slog.Warn("Session end hook execution failed", "agent", a.Name(), "error", err) + } +} + +// executeStopHooks executes stop hooks when the model finishes responding. +// The stop hook receives the model's final response content. +func (r *LocalRuntime) executeStopHooks(ctx context.Context, sess *session.Session, a *agent.Agent, responseContent string, events chan Event) { + hooksExec := r.getHooksExecutor(a) + if hooksExec == nil || !hooksExec.HasStopHooks() { + return + } + + slog.Debug("Executing stop hooks", "agent", a.Name(), "session_id", sess.ID) + input := &hooks.Input{ + SessionID: sess.ID, + Cwd: r.workingDir, + StopResponse: responseContent, + } + + result, err := hooksExec.ExecuteStop(ctx, input) + if err != nil { + slog.Warn("Stop hook execution failed", "agent", a.Name(), "error", err) + return + } + + if result.SystemMessage != "" { + events <- Warning(result.SystemMessage, a.Name()) + } +} + +// executeNotificationHooks executes notification hooks when the agent emits a user-facing +// notification (e.g., errors or warnings). Hook output is logged but does not affect the +// notification itself. Individual hooks are subject to their configured timeout. +func (r *LocalRuntime) executeNotificationHooks(ctx context.Context, a *agent.Agent, sessionID, level, message string) { + if a == nil { + return + } + + hooksExec := r.getHooksExecutor(a) + if hooksExec == nil || !hooksExec.HasNotificationHooks() { + return + } + + slog.Debug("Executing notification hooks", "level", level, "session_id", sessionID) + input := &hooks.Input{ + SessionID: sessionID, + Cwd: r.workingDir, + NotificationLevel: level, + NotificationMessage: message, + } + + _, err := hooksExec.ExecuteNotification(ctx, input) + if err != nil { + slog.Warn("Notification hook execution failed", "error", err) + } +} + // 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.CurrentAgentName()) From f298d98da8358304447c80bed42cd5d561419700 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Tue, 17 Mar 2026 09:12:59 +0100 Subject: [PATCH 2/2] Fix session hook issues from PR review - Propagate session_start hook additional_context as a system message into the session so the agent can actually use it - Validate notification hook level against allowed values (error/warning) - Upgrade session_end hook error logging from Warn to Error for visibility Assisted-By: docker-agent --- pkg/runtime/runtime.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go index 2907a6020..a01928dae 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -675,6 +675,7 @@ func (r *LocalRuntime) executeSessionStartHooks(ctx context.Context, sess *sessi } if result.AdditionalContext != "" { slog.Debug("Session start hook provided additional context", "context", result.AdditionalContext) + sess.AddMessage(session.SystemMessage(result.AdditionalContext)) } } @@ -694,7 +695,7 @@ func (r *LocalRuntime) executeSessionEndHooks(ctx context.Context, sess *session _, err := hooksExec.ExecuteSessionEnd(ctx, input) if err != nil { - slog.Warn("Session end hook execution failed", "agent", a.Name(), "error", err) + slog.Error("Session end hook execution failed", "agent", a.Name(), "error", err) } } @@ -732,6 +733,11 @@ func (r *LocalRuntime) executeNotificationHooks(ctx context.Context, a *agent.Ag return } + if level != "error" && level != "warning" { + slog.Error("Invalid notification level", "level", level, "expected", "error|warning") + return + } + hooksExec := r.getHooksExecutor(a) if hooksExec == nil || !hooksExec.HasNotificationHooks() { return