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 @@ -620,6 +620,13 @@
"$ref": "#/definitions/HookDefinition"
}
},
"turn_end": {
"type": "array",
"description": "Hooks that run once per agent turn when the turn finishes — the symmetric counterpart of turn_start. Fires no matter why the turn ended: a normal stop, an error, a hook-driven shutdown, the loop detector, or context cancellation. The reason is reported via the hook input's reason field ('normal', 'continue', 'steered', 'error', 'canceled', 'hook_blocked', 'loop_detected'). Observational; output is ignored.",
"items": {
"$ref": "#/definitions/HookDefinition"
}
},
"before_llm_call": {
"type": "array",
"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.",
Expand Down
20 changes: 20 additions & 0 deletions docs/configuration/hooks/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ docker-agent dispatches the following hook events:
| `session_start` | When a session begins or resumes | No |
| `user_prompt_submit` | Once per user message, after submission and before the model runs | Yes |
| `turn_start` | At the start of every agent turn (each model call) | No |
| `turn_end` | At the end of every agent turn — fires no matter why the turn ended | No |
| `before_llm_call` | Just before every model call (after `turn_start`) | Yes |
| `after_llm_call` | After every successful model call, before the response is recorded | No |
| `session_end` | When a session terminates | No |
Expand Down Expand Up @@ -225,6 +226,7 @@ In addition to the common fields, each event ships its own payload:
| `session_start` | `source` — one of `startup`, `resume`, `clear`, `compact` |
| `user_prompt_submit` | `prompt` — the text the user just submitted |
| `turn_start` | _none_ (just the common fields) |
| `turn_end` | `agent_name`, `reason` — one of `normal`, `continue`, `steered`, `error`, `canceled`, `hook_blocked`, `loop_detected` |
| `before_llm_call` | _none_ |
| `after_llm_call` | `agent_name`, `stop_response`, `last_user_message` |
| `session_end` | `reason` — one of `clear`, `logout`, `prompt_input_exit`, `other` |
Expand Down Expand Up @@ -495,6 +497,24 @@ Use `on_error` and `on_max_iterations` instead of `notification` when you want a

`turn_start` fires at the start of every agent turn (each model call). Anything you contribute via `additional_context` (or plain stdout) is appended as a **transient** system message for that turn only — it is *not* persisted to the session. Use it for fast-moving signals like the date, current git state, or per-turn prompt files. The built-in hooks `add_date`, `add_prompt_files`, `add_git_status`, and `add_git_diff` all target this event.

### Turn-End: per-turn finalizer

`turn_end` is the symmetric counterpart of `turn_start`. It fires once per turn when the iteration finishes — no matter why. The runtime guarantees the dispatch on every exit path (a normal stop, an error, a hook-driven shutdown, the loop detector, even context cancellation), and it uses `context.WithoutCancel` internally so handlers run to completion on Ctrl+C.

The `reason` field classifies the exit:

| `reason` | When |
| --------------- | ---- |
| `normal` | Model finished cleanly with no follow-up |
| `continue` | More iterations to come (e.g. tool calls, follow-up message) |
| `steered` | Drained steered messages prompted a re-entry |
| `error` | Model call failed (`handleStreamError` exited the loop) |
| `canceled` | Context was cancelled (e.g. Ctrl+C) |
| `hook_blocked` | `before_llm_call` or `post_tool_use` denied the call |
| `loop_detected` | The consecutive-tool-call loop detector terminated the turn |

`turn_end` is observational — the result is ignored. Use it to time turns, accumulate per-turn metrics (token usage, tool counts), or notify external observability pipelines symmetrically with `turn_start`.

### Before/After-LLM-Call: budget guards and model auditing

`before_llm_call` fires immediately before every model call (after `turn_start` has assembled the messages). It cannot contribute context — use `turn_start` for that — but it can **stop the run** by returning `decision: block` (or exit code 2). The built-in `max_iterations` hook implements a hard cap on top of this event.
Expand Down
31 changes: 31 additions & 0 deletions examples/hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
# session_start - one-time setup; AdditionalContext PERSISTS in the session
# user_prompt_submit- runs once per user message, before the first LLM call
# turn_start - per-turn context; AdditionalContext is TRANSIENT
# turn_end - per-turn finalizer; fires no matter why the turn ended
# before_llm_call - just before each model call (observability, guardrails)
# after_llm_call - just after a successful model call
# session_end - cleanup when the session terminates
Expand Down Expand Up @@ -62,6 +63,7 @@
# /tmp/agent-session.log (session_start, session_end)
# /tmp/agent-prompts.log (user_prompt_submit)
# /tmp/agent-llm-calls.log (before_llm_call, after_llm_call)
# /tmp/agent-turns.log (turn_end)
# /tmp/agent-tool-results.log (post_tool_use)
# /tmp/agent-permissions.log (permission_request)
# /tmp/agent-compactions.log (pre_compact)
Expand Down Expand Up @@ -225,6 +227,35 @@ agents:
- GUIDELINES.md
- PROJECT.md

# ====================================================================
# TURN-END - runs ONCE per turn after the iteration finishes — the
# symmetric counterpart of turn_start. Fires no matter why the turn
# ended: a normal stop, an error, a hook-driven shutdown, the loop
# detector, or context cancellation. The reason is reported via
# the .reason field:
#
# normal - model finished cleanly, no follow-up
# continue - more iterations to come (e.g. tool calls)
# steered - drained steered messages prompted a re-entry
# error - model call failed (handleStreamError)
# canceled - context cancellation (Ctrl+C, parent ctx done)
# hook_blocked - before_llm_call or post_tool_use signalled stop
# loop_detected - degenerate consecutive-tool-call loop
#
# Observational; the result is ignored. Use it to time turns,
# accumulate per-turn metrics (token usage, tool counts), or notify
# external observability pipelines.
# ====================================================================
turn_end:
- type: command
timeout: 5
command: |
INPUT=$(cat)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
AGENT=$(echo "$INPUT" | jq -r '.agent_name // "unknown"')
REASON=$(echo "$INPUT" | jq -r '.reason // "unknown"')
echo "[$(date)] [←] $SESSION_ID $AGENT turn ended (reason=$REASON)" >> /tmp/agent-turns.log

# ====================================================================
# BEFORE-LLM-CALL - fires just before every model invocation, AFTER
# turn_start has assembled the messages slice. Use for observability
Expand Down
17 changes: 17 additions & 0 deletions pkg/config/latest/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -1709,6 +1709,15 @@ type HooksConfig struct {
// turn instead of bloating the message history on every resume.
TurnStart []HookDefinition `json:"turn_start,omitempty" yaml:"turn_start,omitempty"`

// TurnEnd hooks run once per agent turn when the turn finishes —
// the symmetric counterpart of TurnStart. Fires no matter why the
// turn ended: a normal stop, an error, a hook-driven shutdown, the
// loop detector, or context cancellation. The reason is reported
// in the hook input's reason field ("normal", "continue",
// "steered", "error", "canceled", "hook_blocked",
// "loop_detected"). Observational; output is ignored.
TurnEnd []HookDefinition `json:"turn_end,omitempty" yaml:"turn_end,omitempty"`

// BeforeLLMCall hooks run just before each model call (after
// turn_start). Use this for observability, cost guardrails, or
// auditing without contributing system messages — turn_start is the
Expand Down Expand Up @@ -1815,6 +1824,7 @@ func (h *HooksConfig) IsEmpty() bool {
len(h.SessionStart) == 0 &&
len(h.UserPromptSubmit) == 0 &&
len(h.TurnStart) == 0 &&
len(h.TurnEnd) == 0 &&
len(h.BeforeLLMCall) == 0 &&
len(h.AfterLLMCall) == 0 &&
len(h.SessionEnd) == 0 &&
Expand Down Expand Up @@ -1971,6 +1981,13 @@ func (h *HooksConfig) validate() error {
}
}

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

// Validate BeforeLLMCall hooks
for i, hook := range h.BeforeLLMCall {
if err := hook.validate("before_llm_call", i); err != nil {
Expand Down
1 change: 1 addition & 0 deletions pkg/hooks/dispatch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ var onlyHooks = map[EventType]*Config{
EventPostToolUse: {PostToolUse: matcherWildcard},
EventSessionStart: {SessionStart: trueHook},
EventTurnStart: {TurnStart: trueHook},
EventTurnEnd: {TurnEnd: trueHook},
EventBeforeLLMCall: {BeforeLLMCall: trueHook},
EventAfterLLMCall: {AfterLLMCall: trueHook},
EventSessionEnd: {SessionEnd: trueHook},
Expand Down
1 change: 1 addition & 0 deletions pkg/hooks/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ func compileEvents(c *Config) map[EventType][]matcher {
EventSessionStart: flat(c.SessionStart),
EventUserPromptSubmit: flat(c.UserPromptSubmit),
EventTurnStart: flat(c.TurnStart),
EventTurnEnd: flat(c.TurnEnd),
EventBeforeLLMCall: flat(c.BeforeLLMCall),
EventAfterLLMCall: flat(c.AfterLLMCall),
EventSessionEnd: flat(c.SessionEnd),
Expand Down
11 changes: 10 additions & 1 deletion pkg/hooks/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@ func TestConfigIsEmpty(t *testing.T) {
},
expected: false,
},
{
name: "with turn_end",
config: Config{
TurnEnd: []Hook{{Type: HookTypeCommand}},
},
expected: false,
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -706,7 +713,7 @@ func TestPlainStdoutBecomesAdditionalContext(t *testing.T) {
observationalEvents := []EventType{
EventBeforeLLMCall, EventAfterLLMCall, EventOnError,
EventOnMaxIterations, EventNotification, EventOnUserInput, EventSessionEnd,
EventBeforeCompaction, EventAfterCompaction,
EventBeforeCompaction, EventAfterCompaction, EventTurnEnd,
}

for _, ev := range contextEvents {
Expand Down Expand Up @@ -750,6 +757,8 @@ func configWithFlatHook(ev EventType, h Hook) *Config {
cfg.SessionStart = []Hook{h}
case EventTurnStart:
cfg.TurnStart = []Hook{h}
case EventTurnEnd:
cfg.TurnEnd = []Hook{h}
case EventBeforeLLMCall:
cfg.BeforeLLMCall = []Hook{h}
case EventAfterLLMCall:
Expand Down
11 changes: 11 additions & 0 deletions pkg/hooks/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ const (
// EventTurnStart fires at the start of every agent turn (each model
// call). AdditionalContext is injected transiently and never persisted.
EventTurnStart EventType = "turn_start"
// EventTurnEnd fires once per agent turn (each model call) when the
// turn finishes — symmetric to [EventTurnStart]. It runs no matter
// why the turn ended: a normal stop, an error, a hook-driven
// shutdown, the loop detector, or context cancellation. The reason
// is reported in [Input.Reason] using one of the turnEndReason*
// constants in the runtime package ("normal", "continue",
// "steered", "error", "canceled", "hook_blocked",
// "loop_detected"). Observational; output is ignored.
EventTurnEnd EventType = "turn_end"
// EventBeforeLLMCall fires immediately before each model call.
// Returning decision="block" (or continue=false / exit code 2)
// stops the run loop before the model is invoked — useful for hard
Expand Down Expand Up @@ -209,6 +218,8 @@ type Input struct {
// PreCompact specific: "manual", "auto", "overflow", "tool_overflow".
Source string `json:"source,omitempty"`
// SessionEnd specific: "clear", "logout", "prompt_input_exit", "other".
// TurnEnd specific: "normal", "continue", "steered", "error",
// "canceled", "hook_blocked", "loop_detected".
Reason string `json:"reason,omitempty"`
// Stop / AfterLLMCall / SubagentStop: the model's final response content.
StopResponse string `json:"stop_response,omitempty"`
Expand Down
42 changes: 42 additions & 0 deletions pkg/runtime/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,48 @@ func (r *LocalRuntime) executeTurnStartHooks(ctx context.Context, sess *session.
}, events))
}

// Reason values reported in [hooks.Input.Reason] when [hooks.EventTurnEnd]
// fires. The runtime guarantees that turn_end runs once per turn that
// fired turn_start, no matter how the turn exited; the reason classifies
// which exit path the runtime took.
const (
// turnEndReasonNormal — the model finished the turn cleanly and the
// run loop is about to break out (no further iterations).
turnEndReasonNormal = "normal"
// turnEndReasonContinue — the turn finished cleanly and the loop is
// about to start a new iteration (e.g. after tool calls, or after a
// stop with a queued follow-up).
turnEndReasonContinue = "continue"
// turnEndReasonSteered — the turn finished and was followed by
// drained steered messages, prompting a new iteration.
turnEndReasonSteered = "steered"
// turnEndReasonError — the model call failed and the runtime is
// shutting down the run (handleStreamError returned non-retry).
turnEndReasonError = "error"
// turnEndReasonCanceled — the turn ended because the stream context
// was cancelled (e.g. user Ctrl+C). Includes deferred firing on
// any return path while ctx is done.
turnEndReasonCanceled = "canceled"
// turnEndReasonHookBlocked — a hook (before_llm_call or
// post_tool_use) signalled run termination via a deny verdict.
turnEndReasonHookBlocked = "hook_blocked"
// turnEndReasonLoopDetected — the consecutive-tool-call loop
// detector terminated the turn.
turnEndReasonLoopDetected = "loop_detected"
)

// executeTurnEndHooks fires turn_end once per turn — symmetric to
// turn_start. Observational; the result is discarded. Reason is one
// of the turnEndReason* constants above and is reported via
// [hooks.Input.Reason] so handlers can branch on the exit path.
func (r *LocalRuntime) executeTurnEndHooks(ctx context.Context, sess *session.Session, a *agent.Agent, reason string, events chan Event) {
r.dispatchHook(ctx, a, hooks.EventTurnEnd, &hooks.Input{
SessionID: sess.ID,
AgentName: a.Name(),
Reason: reason,
}, events)
}

// contextMessages converts a context-providing hook's AdditionalContext
// into a one-element transient system-message slice ready to thread
// through [session.Session.GetMessages]. Returns nil for empty results
Expand Down
Loading
Loading