Skip to content

Commit d9c5b53

Browse files
authored
Merge pull request #2592 from rumpl/feat/turn-end-hook
Add turn_end hook
2 parents 4fd820d + 41f29c9 commit d9c5b53

11 files changed

Lines changed: 795 additions & 157 deletions

File tree

‎agent-schema.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,13 @@
620620
"$ref": "#/definitions/HookDefinition"
621621
}
622622
},
623+
"turn_end": {
624+
"type": "array",
625+
"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.",
626+
"items": {
627+
"$ref": "#/definitions/HookDefinition"
628+
}
629+
},
623630
"before_llm_call": {
624631
"type": "array",
625632
"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.",

‎docs/configuration/hooks/index.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ docker-agent dispatches the following hook events:
4343
| `session_start` | When a session begins or resumes | No |
4444
| `user_prompt_submit` | Once per user message, after submission and before the model runs | Yes |
4545
| `turn_start` | At the start of every agent turn (each model call) | No |
46+
| `turn_end` | At the end of every agent turn — fires no matter why the turn ended | No |
4647
| `before_llm_call` | Just before every model call (after `turn_start`) | Yes |
4748
| `after_llm_call` | After every successful model call, before the response is recorded | No |
4849
| `session_end` | When a session terminates | No |
@@ -225,6 +226,7 @@ In addition to the common fields, each event ships its own payload:
225226
| `session_start` | `source` — one of `startup`, `resume`, `clear`, `compact` |
226227
| `user_prompt_submit` | `prompt` — the text the user just submitted |
227228
| `turn_start` | _none_ (just the common fields) |
229+
| `turn_end` | `agent_name`, `reason` — one of `normal`, `continue`, `steered`, `error`, `canceled`, `hook_blocked`, `loop_detected` |
228230
| `before_llm_call` | _none_ |
229231
| `after_llm_call` | `agent_name`, `stop_response`, `last_user_message` |
230232
| `session_end` | `reason` — one of `clear`, `logout`, `prompt_input_exit`, `other` |
@@ -495,6 +497,24 @@ Use `on_error` and `on_max_iterations` instead of `notification` when you want a
495497

496498
`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.
497499

500+
### Turn-End: per-turn finalizer
501+
502+
`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.
503+
504+
The `reason` field classifies the exit:
505+
506+
| `reason` | When |
507+
| --------------- | ---- |
508+
| `normal` | Model finished cleanly with no follow-up |
509+
| `continue` | More iterations to come (e.g. tool calls, follow-up message) |
510+
| `steered` | Drained steered messages prompted a re-entry |
511+
| `error` | Model call failed (`handleStreamError` exited the loop) |
512+
| `canceled` | Context was cancelled (e.g. Ctrl+C) |
513+
| `hook_blocked` | `before_llm_call` or `post_tool_use` denied the call |
514+
| `loop_detected` | The consecutive-tool-call loop detector terminated the turn |
515+
516+
`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`.
517+
498518
### Before/After-LLM-Call: budget guards and model auditing
499519

500520
`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.

‎examples/hooks.yaml‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
# session_start - one-time setup; AdditionalContext PERSISTS in the session
1717
# user_prompt_submit- runs once per user message, before the first LLM call
1818
# turn_start - per-turn context; AdditionalContext is TRANSIENT
19+
# turn_end - per-turn finalizer; fires no matter why the turn ended
1920
# before_llm_call - just before each model call (observability, guardrails)
2021
# after_llm_call - just after a successful model call
2122
# session_end - cleanup when the session terminates
@@ -62,6 +63,7 @@
6263
# /tmp/agent-session.log (session_start, session_end)
6364
# /tmp/agent-prompts.log (user_prompt_submit)
6465
# /tmp/agent-llm-calls.log (before_llm_call, after_llm_call)
66+
# /tmp/agent-turns.log (turn_end)
6567
# /tmp/agent-tool-results.log (post_tool_use)
6668
# /tmp/agent-permissions.log (permission_request)
6769
# /tmp/agent-compactions.log (pre_compact)
@@ -225,6 +227,35 @@ agents:
225227
- GUIDELINES.md
226228
- PROJECT.md
227229

230+
# ====================================================================
231+
# TURN-END - runs ONCE per turn after the iteration finishes — the
232+
# symmetric counterpart of turn_start. Fires no matter why the turn
233+
# ended: a normal stop, an error, a hook-driven shutdown, the loop
234+
# detector, or context cancellation. The reason is reported via
235+
# the .reason field:
236+
#
237+
# normal - model finished cleanly, no follow-up
238+
# continue - more iterations to come (e.g. tool calls)
239+
# steered - drained steered messages prompted a re-entry
240+
# error - model call failed (handleStreamError)
241+
# canceled - context cancellation (Ctrl+C, parent ctx done)
242+
# hook_blocked - before_llm_call or post_tool_use signalled stop
243+
# loop_detected - degenerate consecutive-tool-call loop
244+
#
245+
# Observational; the result is ignored. Use it to time turns,
246+
# accumulate per-turn metrics (token usage, tool counts), or notify
247+
# external observability pipelines.
248+
# ====================================================================
249+
turn_end:
250+
- type: command
251+
timeout: 5
252+
command: |
253+
INPUT=$(cat)
254+
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
255+
AGENT=$(echo "$INPUT" | jq -r '.agent_name // "unknown"')
256+
REASON=$(echo "$INPUT" | jq -r '.reason // "unknown"')
257+
echo "[$(date)] [←] $SESSION_ID $AGENT turn ended (reason=$REASON)" >> /tmp/agent-turns.log
258+
228259
# ====================================================================
229260
# BEFORE-LLM-CALL - fires just before every model invocation, AFTER
230261
# turn_start has assembled the messages slice. Use for observability

‎pkg/config/latest/types.go‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1709,6 +1709,15 @@ type HooksConfig struct {
17091709
// turn instead of bloating the message history on every resume.
17101710
TurnStart []HookDefinition `json:"turn_start,omitempty" yaml:"turn_start,omitempty"`
17111711

1712+
// TurnEnd hooks run once per agent turn when the turn finishes —
1713+
// the symmetric counterpart of TurnStart. Fires no matter why the
1714+
// turn ended: a normal stop, an error, a hook-driven shutdown, the
1715+
// loop detector, or context cancellation. The reason is reported
1716+
// in the hook input's reason field ("normal", "continue",
1717+
// "steered", "error", "canceled", "hook_blocked",
1718+
// "loop_detected"). Observational; output is ignored.
1719+
TurnEnd []HookDefinition `json:"turn_end,omitempty" yaml:"turn_end,omitempty"`
1720+
17121721
// BeforeLLMCall hooks run just before each model call (after
17131722
// turn_start). Use this for observability, cost guardrails, or
17141723
// auditing without contributing system messages — turn_start is the
@@ -1815,6 +1824,7 @@ func (h *HooksConfig) IsEmpty() bool {
18151824
len(h.SessionStart) == 0 &&
18161825
len(h.UserPromptSubmit) == 0 &&
18171826
len(h.TurnStart) == 0 &&
1827+
len(h.TurnEnd) == 0 &&
18181828
len(h.BeforeLLMCall) == 0 &&
18191829
len(h.AfterLLMCall) == 0 &&
18201830
len(h.SessionEnd) == 0 &&
@@ -1971,6 +1981,13 @@ func (h *HooksConfig) validate() error {
19711981
}
19721982
}
19731983

1984+
// Validate TurnEnd hooks
1985+
for i, hook := range h.TurnEnd {
1986+
if err := hook.validate("turn_end", i); err != nil {
1987+
return err
1988+
}
1989+
}
1990+
19741991
// Validate BeforeLLMCall hooks
19751992
for i, hook := range h.BeforeLLMCall {
19761993
if err := hook.validate("before_llm_call", i); err != nil {

‎pkg/hooks/dispatch_test.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ var onlyHooks = map[EventType]*Config{
2525
EventPostToolUse: {PostToolUse: matcherWildcard},
2626
EventSessionStart: {SessionStart: trueHook},
2727
EventTurnStart: {TurnStart: trueHook},
28+
EventTurnEnd: {TurnEnd: trueHook},
2829
EventBeforeLLMCall: {BeforeLLMCall: trueHook},
2930
EventAfterLLMCall: {AfterLLMCall: trueHook},
3031
EventSessionEnd: {SessionEnd: trueHook},

‎pkg/hooks/executor.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ func compileEvents(c *Config) map[EventType][]matcher {
7979
EventSessionStart: flat(c.SessionStart),
8080
EventUserPromptSubmit: flat(c.UserPromptSubmit),
8181
EventTurnStart: flat(c.TurnStart),
82+
EventTurnEnd: flat(c.TurnEnd),
8283
EventBeforeLLMCall: flat(c.BeforeLLMCall),
8384
EventAfterLLMCall: flat(c.AfterLLMCall),
8485
EventSessionEnd: flat(c.SessionEnd),

‎pkg/hooks/hooks_test.go‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,13 @@ func TestConfigIsEmpty(t *testing.T) {
124124
},
125125
expected: false,
126126
},
127+
{
128+
name: "with turn_end",
129+
config: Config{
130+
TurnEnd: []Hook{{Type: HookTypeCommand}},
131+
},
132+
expected: false,
133+
},
127134
}
128135

129136
for _, tt := range tests {
@@ -706,7 +713,7 @@ func TestPlainStdoutBecomesAdditionalContext(t *testing.T) {
706713
observationalEvents := []EventType{
707714
EventBeforeLLMCall, EventAfterLLMCall, EventOnError,
708715
EventOnMaxIterations, EventNotification, EventOnUserInput, EventSessionEnd,
709-
EventBeforeCompaction, EventAfterCompaction,
716+
EventBeforeCompaction, EventAfterCompaction, EventTurnEnd,
710717
}
711718

712719
for _, ev := range contextEvents {
@@ -750,6 +757,8 @@ func configWithFlatHook(ev EventType, h Hook) *Config {
750757
cfg.SessionStart = []Hook{h}
751758
case EventTurnStart:
752759
cfg.TurnStart = []Hook{h}
760+
case EventTurnEnd:
761+
cfg.TurnEnd = []Hook{h}
753762
case EventBeforeLLMCall:
754763
cfg.BeforeLLMCall = []Hook{h}
755764
case EventAfterLLMCall:

‎pkg/hooks/types.go‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ const (
5151
// EventTurnStart fires at the start of every agent turn (each model
5252
// call). AdditionalContext is injected transiently and never persisted.
5353
EventTurnStart EventType = "turn_start"
54+
// EventTurnEnd fires once per agent turn (each model call) when the
55+
// turn finishes — symmetric to [EventTurnStart]. It runs no matter
56+
// why the turn ended: a normal stop, an error, a hook-driven
57+
// shutdown, the loop detector, or context cancellation. The reason
58+
// is reported in [Input.Reason] using one of the turnEndReason*
59+
// constants in the runtime package ("normal", "continue",
60+
// "steered", "error", "canceled", "hook_blocked",
61+
// "loop_detected"). Observational; output is ignored.
62+
EventTurnEnd EventType = "turn_end"
5463
// EventBeforeLLMCall fires immediately before each model call.
5564
// Returning decision="block" (or continue=false / exit code 2)
5665
// stops the run loop before the model is invoked — useful for hard
@@ -209,6 +218,8 @@ type Input struct {
209218
// PreCompact specific: "manual", "auto", "overflow", "tool_overflow".
210219
Source string `json:"source,omitempty"`
211220
// SessionEnd specific: "clear", "logout", "prompt_input_exit", "other".
221+
// TurnEnd specific: "normal", "continue", "steered", "error",
222+
// "canceled", "hook_blocked", "loop_detected".
212223
Reason string `json:"reason,omitempty"`
213224
// Stop / AfterLLMCall / SubagentStop: the model's final response content.
214225
StopResponse string `json:"stop_response,omitempty"`

‎pkg/runtime/hooks.go‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,48 @@ func (r *LocalRuntime) executeTurnStartHooks(ctx context.Context, sess *session.
132132
}, events))
133133
}
134134

135+
// Reason values reported in [hooks.Input.Reason] when [hooks.EventTurnEnd]
136+
// fires. The runtime guarantees that turn_end runs once per turn that
137+
// fired turn_start, no matter how the turn exited; the reason classifies
138+
// which exit path the runtime took.
139+
const (
140+
// turnEndReasonNormal — the model finished the turn cleanly and the
141+
// run loop is about to break out (no further iterations).
142+
turnEndReasonNormal = "normal"
143+
// turnEndReasonContinue — the turn finished cleanly and the loop is
144+
// about to start a new iteration (e.g. after tool calls, or after a
145+
// stop with a queued follow-up).
146+
turnEndReasonContinue = "continue"
147+
// turnEndReasonSteered — the turn finished and was followed by
148+
// drained steered messages, prompting a new iteration.
149+
turnEndReasonSteered = "steered"
150+
// turnEndReasonError — the model call failed and the runtime is
151+
// shutting down the run (handleStreamError returned non-retry).
152+
turnEndReasonError = "error"
153+
// turnEndReasonCanceled — the turn ended because the stream context
154+
// was cancelled (e.g. user Ctrl+C). Includes deferred firing on
155+
// any return path while ctx is done.
156+
turnEndReasonCanceled = "canceled"
157+
// turnEndReasonHookBlocked — a hook (before_llm_call or
158+
// post_tool_use) signalled run termination via a deny verdict.
159+
turnEndReasonHookBlocked = "hook_blocked"
160+
// turnEndReasonLoopDetected — the consecutive-tool-call loop
161+
// detector terminated the turn.
162+
turnEndReasonLoopDetected = "loop_detected"
163+
)
164+
165+
// executeTurnEndHooks fires turn_end once per turn — symmetric to
166+
// turn_start. Observational; the result is discarded. Reason is one
167+
// of the turnEndReason* constants above and is reported via
168+
// [hooks.Input.Reason] so handlers can branch on the exit path.
169+
func (r *LocalRuntime) executeTurnEndHooks(ctx context.Context, sess *session.Session, a *agent.Agent, reason string, events chan Event) {
170+
r.dispatchHook(ctx, a, hooks.EventTurnEnd, &hooks.Input{
171+
SessionID: sess.ID,
172+
AgentName: a.Name(),
173+
Reason: reason,
174+
}, events)
175+
}
176+
135177
// contextMessages converts a context-providing hook's AdditionalContext
136178
// into a one-element transient system-message slice ready to thread
137179
// through [session.Session.GetMessages]. Returns nil for empty results

0 commit comments

Comments
 (0)