@@ -11,60 +11,50 @@ import (
1111 "github.com/docker/docker-agent/pkg/session"
1212)
1313
14- // hooksExec returns the cached [hooks.Executor] for a, building one on
15- // first lookup. Returns nil when the agent has no user-configured hooks
16- // and no agent-flag (AddDate / AddEnvironmentInfo / AddPromptFiles) maps
17- // to a builtin. Callers can then short-circuit without paying for a
18- // no-op dispatch .
14+ // buildHooksExecutors builds a [hooks.Executor] for every agent in the
15+ // team that has user-configured hooks or an agent-flag that maps to a
16+ // builtin (AddDate / AddEnvironmentInfo / AddPromptFiles). Agents with
17+ // no hooks have no entry; lookups fall through to nil so callers can
18+ // short-circuit cheaply .
1919//
20- // The cache is keyed by agent name. Entries (including the nil sentinel)
21- // are stable for the lifetime of the runtime, so repeated dispatches
22- // during a turn don't re-translate agent flags into builtin hook entries
23- // or rebuild matcher tables.
20+ // Called once from [NewLocalRuntime] after r.workingDir, r.env and
21+ // r.hooksRegistry are finalized; the resulting map is read-only for
22+ // the lifetime of the runtime, so per-dispatch lookups don't need to
23+ // lock.
24+ func (r * LocalRuntime ) buildHooksExecutors () {
25+ r .hooksExecByAgent = make (map [string ]* hooks.Executor )
26+ for _ , name := range r .team .AgentNames () {
27+ a , err := r .team .Agent (name )
28+ if err != nil {
29+ continue
30+ }
31+ cfg := builtins .ApplyAgentDefaults (hooks .FromConfig (a .Hooks ()), builtins.AgentDefaults {
32+ AddDate : a .AddDate (),
33+ AddEnvironmentInfo : a .AddEnvironmentInfo (),
34+ AddPromptFiles : a .AddPromptFiles (),
35+ })
36+ if cfg == nil {
37+ continue
38+ }
39+ r .hooksExecByAgent [name ] = hooks .NewExecutorWithRegistry (cfg , r .workingDir , r .env , r .hooksRegistry )
40+ }
41+ }
42+
43+ // hooksExec returns the pre-built [hooks.Executor] for a, or nil when
44+ // the agent has no hooks (see [buildHooksExecutors]).
2445func (r * LocalRuntime ) hooksExec (a * agent.Agent ) * hooks.Executor {
2546 if a == nil {
2647 return nil
2748 }
28- name := a .Name ()
29-
30- r .hooksExecMu .RLock ()
31- if exec , ok := r .hooksExecByAgent [name ]; ok {
32- r .hooksExecMu .RUnlock ()
33- return exec
34- }
35- r .hooksExecMu .RUnlock ()
36-
37- r .hooksExecMu .Lock ()
38- defer r .hooksExecMu .Unlock ()
39- // Re-check under the write lock to avoid double-build under contention.
40- if exec , ok := r .hooksExecByAgent [name ]; ok {
41- return exec
42- }
43-
44- cfg := builtins .ApplyAgentDefaults (hooks .FromConfig (a .Hooks ()), builtins.AgentDefaults {
45- AddDate : a .AddDate (),
46- AddEnvironmentInfo : a .AddEnvironmentInfo (),
47- AddPromptFiles : a .AddPromptFiles (),
48- })
49-
50- var exec * hooks.Executor
51- if cfg != nil {
52- exec = hooks .NewExecutorWithRegistry (cfg , r .workingDir , r .env , r .hooksRegistry )
53- }
54- if r .hooksExecByAgent == nil {
55- r .hooksExecByAgent = make (map [string ]* hooks.Executor )
56- }
57- r .hooksExecByAgent [name ] = exec
58- return exec
49+ return r .hooksExecByAgent [a .Name ()]
5950}
6051
6152// dispatchHook is the common dispatch path shared by every hook
62- // callsite: resolve the cached executor, short-circuit if no hook is
63- // configured for event, then dispatch and emit any [Result.SystemMessage]
64- // as a Warning event. Errors are logged at warn level and surfaced as
65- // nil results so callers can use a single nil check to mean "nothing
66- // useful came back" — covering the not-configured, no-agent, and
67- // dispatch-failed cases uniformly.
53+ // callsite: resolve the pre-built executor, dispatch, and emit any
54+ // [Result.SystemMessage] as a Warning event. Errors are logged at warn
55+ // level and surfaced as nil results so callers can use a single nil
56+ // check to mean "nothing useful came back" — covering the
57+ // not-configured, no-agent, and dispatch-failed cases uniformly.
6858//
6959// events may be nil for fire-and-forget callsites (notification,
7060// on_error, on_max_iterations, ...) where there's no Warning channel
@@ -79,11 +69,10 @@ func (r *LocalRuntime) dispatchHook(
7969 events chan Event ,
8070) * hooks.Result {
8171 exec := r .hooksExec (a )
82- if exec == nil || ! exec . Has ( event ) {
72+ if exec == nil {
8373 return nil
8474 }
8575
86- slog .Debug ("Executing hooks" , "event" , event , "agent" , a .Name (), "session_id" , input .SessionID )
8776 result , err := exec .Dispatch (ctx , event , input )
8877 if err != nil {
8978 slog .Warn ("Hook execution failed" , "event" , event , "agent" , a .Name (), "error" , err )
@@ -150,41 +139,31 @@ func (r *LocalRuntime) executeStopHooks(ctx context.Context, sess *session.Sessi
150139 }, events )
151140}
152141
153- // executeNotificationHooks runs notification hooks when the agent emits
154- // a user-facing notification. Hook output is informational — it does
155- // not suppress or rewrite the notification.
156- func (r * LocalRuntime ) executeNotificationHooks (ctx context.Context , a * agent.Agent , sessionID , level , message string ) {
157- if level != "error" && level != "warning" {
158- slog .Error ("Invalid notification level" , "level" , level , "expected" , "error|warning" )
159- return
160- }
161- r .dispatchHook (ctx , a , hooks .EventNotification , & hooks.Input {
162- SessionID : sessionID ,
163- NotificationLevel : level ,
164- NotificationMessage : message ,
165- }, nil )
142+ // notifyError fires both notification(level=error) and on_error in one
143+ // call. They're always emitted together (an error is always also a
144+ // user-facing notification), so collapsing them into one call expresses
145+ // intent more directly than firing two events at every callsite.
146+ func (r * LocalRuntime ) notifyError (ctx context.Context , a * agent.Agent , sessionID , message string ) {
147+ r .notify (ctx , a , hooks .EventNotification , sessionID , "error" , message )
148+ r .notify (ctx , a , hooks .EventOnError , sessionID , "error" , message )
166149}
167150
168- // executeOnErrorHooks fires on_error when the runtime hits an error
169- // during a turn (model failures, tool-call loops). Fires alongside the
170- // broader notification event; on_error is the structured entry point
171- // for users who want to react only to errors.
172- func (r * LocalRuntime ) executeOnErrorHooks (ctx context.Context , a * agent.Agent , sessionID , message string ) {
173- r .dispatchHook (ctx , a , hooks .EventOnError , & hooks.Input {
174- SessionID : sessionID ,
175- NotificationLevel : "error" ,
176- NotificationMessage : message ,
177- }, nil )
151+ // notifyMaxIterations fires both notification(level=warning) and
152+ // on_max_iterations. Same rationale as [notifyError]: the two are
153+ // always emitted together when the iteration limit is reached.
154+ func (r * LocalRuntime ) notifyMaxIterations (ctx context.Context , a * agent.Agent , sessionID , message string ) {
155+ r .notify (ctx , a , hooks .EventNotification , sessionID , "warning" , message )
156+ r .notify (ctx , a , hooks .EventOnMaxIterations , sessionID , "warning" , message )
178157}
179158
180- // executeOnMaxIterationsHooks fires on_max_iterations when the runtime
181- // reaches its configured max_iterations limit. Fires alongside the
182- // broader notification event; on_max_iterations is the structured entry
183- // point for users who want to react only to that condition .
184- func (r * LocalRuntime ) executeOnMaxIterationsHooks (ctx context.Context , a * agent.Agent , sessionID , message string ) {
185- r .dispatchHook (ctx , a , hooks . EventOnMaxIterations , & hooks.Input {
159+ // notify is the shared dispatch path for the (level, message)-shaped
160+ // hook events: notification, on_error, on_max_iterations. They all
161+ // take the same Input fields and are observational (no Result is
162+ // honored), so a single helper covers them all .
163+ func (r * LocalRuntime ) notify (ctx context.Context , a * agent.Agent , event hooks. EventType , sessionID , level , message string ) {
164+ r .dispatchHook (ctx , a , event , & hooks.Input {
186165 SessionID : sessionID ,
187- NotificationLevel : "warning" ,
166+ NotificationLevel : level ,
188167 NotificationMessage : message ,
189168 }, nil )
190169}
@@ -215,11 +194,10 @@ func (r *LocalRuntime) executeAfterLLMCallHooks(ctx context.Context, sess *sessi
215194
216195// executeOnUserInputHooks fires on_user_input when the runtime is about
217196// to wait for the user (tool confirmation, elicitation, max iterations,
218- // stream stopped). Resolves the agent from r.team itself so callsites
219- // in code paths without an agent handle (like the elicitation handler)
220- // stay short.
197+ // stream stopped). Resolves the agent itself so callsites in code paths
198+ // without an agent handle (like the elicitation handler) stay short.
221199func (r * LocalRuntime ) executeOnUserInputHooks (ctx context.Context , sessionID , logContext string ) {
222- a , _ := r .team . Agent ( r . CurrentAgentName () )
200+ a := r .CurrentAgent ( )
223201 if a == nil {
224202 return
225203 }
0 commit comments