Skip to content

Commit c34390e

Browse files
committed
refactor(hooks/runtime): simplify approval plumbing and trim docstrings
Pure cleanup pass over the two preceding feature commits. No features removed, no public API changes. * `LocalRuntime.runTool` now returns `toolApprovalOutcome` directly, so callers don't repeat `stop, msg := runTool(); return outcome{...}` in seven places. * Local `runTool` closure in `processToolCalls` is renamed to `invoke` (no more shadowing the method) and extracted into a new `toolInvoker` helper for clarity. * `executeWithApproval` and `askUserForConfirmation` now `return invoke()` in every approval path \u2014 every one of those paths used to be a two-line tuple-unpack-and-pack. * `processToolCalls` synthesised-error loops are extracted into a `synthesizeRemaining` closure and wrapped in a `switch` with `canceled`/`stopRun`/`default` arms so the control flow is visible at a glance. * `loop_detector`: inline `parseLoopDetectorArgs`; replace the per-call `map[string]struct{}` exempt set with `slices.Contains` over a slice (no allocations on the hot path); shorter state field names (`sig`, `count`). * `max_iterations`: inline `parseMaxIterationsArgs`; collapse the `(int, bool)` parse-result into a single `limit\u003c=0\u200a\u2192\u200ano-op` check. * `json.go`: previously held `canonicalJSON` and `joinNonEmpty` helpers that were already deleted as unused; the file is now just `sortKeys` + `canonicalToolInput`. Trimmed the verbose comments added in the previous two commits to match the surrounding house style. The contract is the same; the prose just earned its keep: * `emitHookDrivenShutdown`, `executeBeforeLLMCallHooks`, `executeSessionEndHooks`, `loopDetectorExemptTools`, the `maxConsecutive` normalisation block, the `builtinsState` field comment. * Package doc and `State` / `Register` / `AgentDefaults` doc in pkg/hooks/builtins. * `hooks.EventPostToolUse`, `hooks.EventBeforeLLMCall`, and `hooks.DecisionBlockValue` docs. 9 files changed, 238 insertions(+), 395 deletions(-) Lint clean. `go test ./...` and `go test -race ./pkg/hooks/... ./pkg/runtime/...` all pass. Assisted-By: docker-agent
1 parent 9830b4e commit c34390e

6 files changed

Lines changed: 174 additions & 269 deletions

File tree

pkg/hooks/builtins/builtins.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Package builtins contains the stock in-process hook implementations
1+
// Package builtins contains the in-process hook implementations
22
// shipped with docker-agent.
33
//
44
// Available builtins:

pkg/hooks/builtins/loop_detector.go

Lines changed: 42 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"log/slog"
7+
"slices"
78
"strconv"
89
"sync"
910

@@ -13,70 +14,57 @@ import (
1314
// LoopDetector is the registered name of the loop_detector builtin.
1415
const LoopDetector = "loop_detector"
1516

16-
// defaultLoopDetectorThreshold is the consecutive-identical-call count
17-
// at which the detector trips when no explicit threshold is configured.
18-
// Five matches the historical default of the inline tool-loop detector
19-
// previously baked into pkg/runtime.
17+
// defaultLoopDetectorThreshold matches the historical default of the
18+
// inline tool-loop detector previously baked into pkg/runtime.
2019
const defaultLoopDetectorThreshold = 5
2120

2221
// loopDetectorBuiltin is the post_tool_use builtin that terminates the
23-
// run when the model issues the same tool call (name + canonical
24-
// arguments) `threshold` times in a row.
22+
// run when the model issues the same tool call (name + canonical args)
23+
// `threshold` times in a row.
2524
//
26-
// State is per-session, keyed by [hooks.Input.SessionID], so concurrent
27-
// runs on a shared runtime can't cross-contaminate each other's
28-
// counters. The state map is bounded by the number of *active* sessions
29-
// rather than all-time sessions \u2014 the runtime calls `clearSession` from
30-
// session_end so the entry is dropped when the run finishes.
25+
// Args layout: `[threshold, exempt1, exempt2, ...]`. An invalid or
26+
// missing threshold falls back to [defaultLoopDetectorThreshold].
3127
//
32-
// Semantics differ from the previous inline detector in one specific
33-
// way: signatures are tracked **per call**, not per batch. For the
34-
// common stuck-agent case (the model repeatedly emits a single tool
35-
// call with identical arguments) the trip point is unchanged. A model
36-
// stuck in an alternating multi-tool batch like `[A, B] [A, B] [A, B]`
37-
// is no longer flagged because each B resets A's counter; users who
38-
// hit that pattern should rely on `max_iterations` (interactive UX) or
39-
// the new max_iterations builtin (hard stop) instead.
28+
// State is per-session, keyed by [hooks.Input.SessionID], and cleared
29+
// from session_end via [State.ClearSession].
4030
//
41-
// Polling tools listed in `args` after the threshold (e.g.
42-
// `view_background_agent`) are silently ignored: a polling call
43-
// neither increments nor resets the counter, so a looping model can't
44-
// evade detection by sneaking a single polling call between identical
45-
// stuck calls.
31+
// Detection is per-call, not per-batch: single-tool repetition and
32+
// parallel-identical batches still trip; alternating multi-tool
33+
// patterns like `[A,B] [A,B]` do not — those should be caught by
34+
// max_iterations or manual threshold tuning. Tools listed in `args`
35+
// after the threshold (e.g. background-task pollers) neither
36+
// increment nor reset the counter.
4637
type loopDetectorBuiltin struct {
4738
mu sync.Mutex
48-
states map[string]*loopDetectorState // keyed by SessionID
39+
states map[string]*loopState // SessionID -> state
4940
}
5041

51-
type loopDetectorState struct {
52-
lastSignature string
53-
consecutive int
42+
type loopState struct {
43+
sig string
44+
count int
5445
}
5546

5647
func newLoopDetector() *loopDetectorBuiltin {
57-
return &loopDetectorBuiltin{states: map[string]*loopDetectorState{}}
48+
return &loopDetectorBuiltin{states: map[string]*loopState{}}
5849
}
5950

60-
// hook is registered as the [hooks.BuiltinFunc] for
61-
// [hooks.EventPostToolUse]. Args layout: `[threshold, exempt1, exempt2, ...]`
62-
// where `threshold` is an optional positive integer (defaults to
63-
// [defaultLoopDetectorThreshold] when missing or invalid) and the
64-
// remaining strings are tool names to exempt from detection.
6551
func (d *loopDetectorBuiltin) hook(_ context.Context, in *hooks.Input, args []string) (*hooks.Output, error) {
6652
if in == nil || in.SessionID == "" || in.ToolName == "" {
6753
// Defensive: post_tool_use always carries SessionID and
68-
// ToolName today. Skipping unbounded, unkeyed events keeps
69-
// the state map from filling with anonymous entries.
54+
// ToolName today. Skipping unkeyed events keeps the state
55+
// map from filling with anonymous entries.
7056
return nil, nil
7157
}
7258

73-
threshold, exempt := parseLoopDetectorArgs(args)
74-
75-
if _, ok := exempt[in.ToolName]; ok {
76-
// Polling-style tools never count toward the consecutive
77-
// total and never reset it: see the type-level comment for
78-
// why visibility-zero (rather than counter-reset) is the
79-
// right behaviour.
59+
threshold := defaultLoopDetectorThreshold
60+
var exempt []string
61+
if len(args) > 0 {
62+
if n, err := strconv.Atoi(args[0]); err == nil && n > 0 {
63+
threshold = n
64+
}
65+
exempt = args[1:]
66+
}
67+
if slices.Contains(exempt, in.ToolName) {
8068
return nil, nil
8169
}
8270

@@ -85,69 +73,37 @@ func (d *loopDetectorBuiltin) hook(_ context.Context, in *hooks.Input, args []st
8573
d.mu.Lock()
8674
state, ok := d.states[in.SessionID]
8775
if !ok {
88-
state = &loopDetectorState{}
76+
state = &loopState{}
8977
d.states[in.SessionID] = state
9078
}
91-
if sig == state.lastSignature {
92-
state.consecutive++
79+
if sig == state.sig {
80+
state.count++
9381
} else {
94-
state.lastSignature = sig
95-
state.consecutive = 1
82+
state.sig = sig
83+
state.count = 1
9684
}
97-
tripped := state.consecutive >= threshold
98-
count := state.consecutive
85+
count := state.count
9986
d.mu.Unlock()
10087

101-
if !tripped {
88+
if count < threshold {
10289
return nil, nil
10390
}
10491

10592
slog.Warn("loop_detector tripped",
10693
"tool", in.ToolName, "consecutive", count,
10794
"threshold", threshold, "session_id", in.SessionID)
10895

109-
reason := fmt.Sprintf(
110-
"Agent terminated: detected %d consecutive identical calls to %s. "+
111-
"This indicates a degenerate loop where the model is not making progress.",
112-
count, in.ToolName)
113-
114-
// "block" is the post_tool_use deny verdict: aggregate() turns it
115-
// into Result.Allowed=false, which the runtime translates into
116-
// the standard Error / notification / on_error fan-out before
117-
// terminating the run.
11896
return &hooks.Output{
11997
Decision: hooks.DecisionBlockValue,
120-
Reason: reason,
98+
Reason: fmt.Sprintf(
99+
"Agent terminated: detected %d consecutive identical calls to %s. "+
100+
"This indicates a degenerate loop where the model is not making progress.",
101+
count, in.ToolName),
121102
}, nil
122103
}
123104

124-
// clearSession drops a session's state entry, called from a
125-
// session_end hook so long-running runtimes don't accumulate
126-
// per-session entries indefinitely.
127105
func (d *loopDetectorBuiltin) clearSession(sessionID string) {
128106
d.mu.Lock()
129107
delete(d.states, sessionID)
130108
d.mu.Unlock()
131109
}
132-
133-
// parseLoopDetectorArgs splits builtin args into (threshold, exempt
134-
// tool name set). An invalid or non-positive threshold falls back to
135-
// [defaultLoopDetectorThreshold] silently \u2014 a misconfigured YAML
136-
// shouldn't disable the detector entirely.
137-
func parseLoopDetectorArgs(args []string) (int, map[string]struct{}) {
138-
threshold := defaultLoopDetectorThreshold
139-
exempt := map[string]struct{}{}
140-
141-
if len(args) == 0 {
142-
return threshold, exempt
143-
}
144-
if n, err := strconv.Atoi(args[0]); err == nil && n > 0 {
145-
threshold = n
146-
}
147-
for _, name := range args[1:] {
148-
if name != "" {
149-
exempt[name] = struct{}{}
150-
}
151-
}
152-
return threshold, exempt
153-
}

pkg/hooks/builtins/max_iterations.go

Lines changed: 16 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,16 @@ const MaxIterations = "max_iterations"
1717
// and signals a terminating verdict once the configured limit is
1818
// exceeded.
1919
//
20-
// This is intentionally a *hard stop*: it has no resume protocol and
21-
// emits no special runtime event. The runtime translates the deny
22-
// verdict into the standard Error / notification / on_error fan-out
23-
// from [LocalRuntime.emitHookDrivenShutdown], same as any other
24-
// hook-driven shutdown. The legacy `agent.MaxIterations` flag, which
25-
// has its own special UX (MaxIterationsReachedEvent + a resume
26-
// dialog), is unchanged and continues to live inline in loop.go;
27-
// this builtin is the way to express "stop after N model calls,
28-
// period" in YAML without that interactive dance.
20+
// This is a hard stop with no resume protocol — distinct from the
21+
// agent.MaxIterations flag, which has its own special UX
22+
// (MaxIterationsReachedEvent + a resume dialog) and stays in
23+
// pkg/runtime. Use this builtin to express "stop after N model calls,
24+
// period" in YAML.
2925
//
30-
// State is per-session, keyed by [hooks.Input.SessionID]. The runtime
31-
// calls [maxIterationsBuiltin.clearSession] from session_end so a
32-
// long-running shared runtime does not accumulate counters
33-
// indefinitely.
26+
// Args layout: `[limit]`. Missing or invalid args make the hook a
27+
// no-op so a misconfigured YAML doesn't accidentally cap a run at
28+
// zero. State is per-session, keyed by [hooks.Input.SessionID], and
29+
// cleared from session_end via [State.ClearSession].
3430
type maxIterationsBuiltin struct {
3531
mu sync.Mutex
3632
counts map[string]int // SessionID -> calls observed
@@ -40,16 +36,13 @@ func newMaxIterations() *maxIterationsBuiltin {
4036
return &maxIterationsBuiltin{counts: map[string]int{}}
4137
}
4238

43-
// hook is registered as the [hooks.BuiltinFunc] for
44-
// [hooks.EventBeforeLLMCall]. The single arg is the limit (a positive
45-
// integer). Missing / invalid args make the hook a no-op so a
46-
// misconfigured YAML doesn't accidentally cap a run at zero.
4739
func (b *maxIterationsBuiltin) hook(_ context.Context, in *hooks.Input, args []string) (*hooks.Output, error) {
48-
if in == nil || in.SessionID == "" {
40+
if in == nil || in.SessionID == "" || len(args) == 0 {
4941
return nil, nil
5042
}
51-
limit, ok := parseMaxIterationsArgs(args)
52-
if !ok {
43+
limit, err := strconv.Atoi(args[0])
44+
if err != nil || limit <= 0 {
45+
slog.Debug("max_iterations: ignoring invalid limit", "arg", args[0], "error", err)
5346
return nil, nil
5447
}
5548

@@ -65,42 +58,16 @@ func (b *maxIterationsBuiltin) hook(_ context.Context, in *hooks.Input, args []s
6558
slog.Warn("max_iterations tripped",
6659
"count", count, "limit", limit, "session_id", in.SessionID)
6760

68-
reason := fmt.Sprintf(
69-
"Agent terminated: max_iterations builtin reached its limit of %d model call(s).",
70-
limit)
71-
7261
return &hooks.Output{
7362
Decision: hooks.DecisionBlockValue,
74-
Reason: reason,
63+
Reason: fmt.Sprintf(
64+
"Agent terminated: max_iterations builtin reached its limit of %d model call(s).",
65+
limit),
7566
}, nil
7667
}
7768

78-
// clearSession drops a session's counter, called from a session_end
79-
// hook so a long-running runtime serving many sessions doesn't grow
80-
// the state map without bound.
8169
func (b *maxIterationsBuiltin) clearSession(sessionID string) {
8270
b.mu.Lock()
8371
delete(b.counts, sessionID)
8472
b.mu.Unlock()
8573
}
86-
87-
// parseMaxIterationsArgs returns (limit, true) when args[0] is a
88-
// positive integer, or (0, false) for any other input. The "valid"
89-
// boolean lets the caller distinguish "no limit configured" (no-op)
90-
// from "limit explicitly set to 0" (which would also be a no-op but
91-
// reads as a config error and is logged at debug).
92-
func parseMaxIterationsArgs(args []string) (int, bool) {
93-
if len(args) == 0 {
94-
return 0, false
95-
}
96-
n, err := strconv.Atoi(args[0])
97-
switch {
98-
case err != nil:
99-
slog.Debug("max_iterations: ignoring non-integer limit", "arg", args[0], "error", err)
100-
return 0, false
101-
case n <= 0:
102-
slog.Debug("max_iterations: ignoring non-positive limit", "limit", n)
103-
return 0, false
104-
}
105-
return n, true
106-
}

pkg/hooks/types.go

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,20 @@ const (
1616
// EventPreToolUse fires before a tool call. Can allow/deny/modify it.
1717
EventPreToolUse EventType = "pre_tool_use"
1818
// EventPostToolUse fires after a tool completes successfully.
19-
// Returning decision="block" (or continue=false / exit code 2) stops
20-
// the agent's run loop after the current tool batch finishes — useful
21-
// for circuit-breaker patterns like a tool-call loop detector. The
22-
// runtime emits the [Result.Message] as an Error event and fires
23-
// notification / on_error before exiting.
19+
// Returning decision="block" (or continue=false / exit code 2)
20+
// stops the run loop after the current tool batch — useful for
21+
// circuit-breaker patterns like a tool-call loop detector.
2422
EventPostToolUse EventType = "post_tool_use"
2523
// EventSessionStart fires when a session begins or resumes.
2624
EventSessionStart EventType = "session_start"
2725
// EventTurnStart fires at the start of every agent turn (each model
2826
// call). AdditionalContext is injected transiently and never persisted.
2927
EventTurnStart EventType = "turn_start"
3028
// EventBeforeLLMCall fires immediately before each model call.
31-
// Returning decision="block" (or continue=false / exit code 2) stops
32-
// the agent's run loop before the model is invoked — useful for hard
33-
// budget guards like a max-iterations builtin. The runtime emits the
34-
// [Result.Message] as an Error event and fires notification / on_error
35-
// before exiting. Use turn_start to contribute system messages; this
36-
// event's AdditionalContext is not consumed.
29+
// Returning decision="block" (or continue=false / exit code 2)
30+
// stops the run loop before the model is invoked — useful for hard
31+
// budget guards. Use turn_start to contribute system messages;
32+
// this event's AdditionalContext is not consumed.
3733
EventBeforeLLMCall EventType = "before_llm_call"
3834
// EventAfterLLMCall fires immediately after a successful model call,
3935
// before the response is recorded. Failed calls fire EventOnError.
@@ -180,7 +176,7 @@ type Output struct {
180176
// SystemMessage is a warning to show the user.
181177
SystemMessage string `json:"system_message,omitempty"`
182178
// Decision is for blocking operations ("block", ...).
183-
// See [DecisionBlockValue] for the canonical "block" string.
179+
// In-process builtin hooks should use [DecisionBlockValue].
184180
Decision string `json:"decision,omitempty"`
185181
// Reason explains the decision.
186182
Reason string `json:"reason,omitempty"`
@@ -193,8 +189,6 @@ func (o *Output) ShouldContinue() bool { return o.Continue == nil || *o.Continue
193189

194190
// DecisionBlockValue is the canonical value of [Output.Decision] used
195191
// by hooks to signal a deny/terminate verdict on the current event.
196-
// In-process builtin hooks should set Decision to this constant rather
197-
// than the bare string literal.
198192
const DecisionBlockValue = "block"
199193

200194
// IsBlocked reports whether the decision is "block".

pkg/runtime/loop.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -393,9 +393,7 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c
393393

394394
// before_llm_call hooks fire just before the model is invoked.
395395
// A terminating verdict (e.g. from the max_iterations builtin)
396-
// stops the run loop here, before any tokens are spent on the
397-
// model call. Use turn_start to contribute system messages;
398-
// this event's AdditionalContext is intentionally not consumed.
396+
// stops the run loop here, before any tokens are spent.
399397
if stop, msg := r.executeBeforeLLMCallHooks(ctx, sess, a); stop {
400398
slog.Warn("before_llm_call hook signalled run termination",
401399
"agent", a.Name(), "session_id", sess.ID, "reason", msg)

0 commit comments

Comments
 (0)