Skip to content

Commit 7cdf467

Browse files
authored
Merge pull request #2542 from dgageot/board/runtime-code-extension-points-analysis-776bc127
feat(hooks): add three observability events around runtime transitions
2 parents 747d6c5 + 5ac1cd9 commit 7cdf467

11 files changed

Lines changed: 527 additions & 15 deletions

agent-schema.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,27 @@
567567
"items": {
568568
"$ref": "#/definitions/HookDefinition"
569569
}
570+
},
571+
"on_agent_switch": {
572+
"type": "array",
573+
"description": "Hooks that run whenever the runtime moves the active agent to a new one (transfer_task, handoff, or the return after a transferred task completes). Receives from_agent, to_agent, and agent_switch_kind in the input. Observational; useful for audit, transcript, and metrics pipelines that track which agent ran which tools.",
574+
"items": {
575+
"$ref": "#/definitions/HookDefinition"
576+
}
577+
},
578+
"on_session_resume": {
579+
"type": "array",
580+
"description": "Hooks that run when the user explicitly approves the runtime to continue past its configured max_iterations limit. Receives previous_max_iterations and new_max_iterations in the input. Observational; useful for alerting on extended-runtime sessions or for billing/quota pipelines that meter resumes.",
581+
"items": {
582+
"$ref": "#/definitions/HookDefinition"
583+
}
584+
},
585+
"on_tool_approval_decision": {
586+
"type": "array",
587+
"description": "Hooks that run after the runtime's tool approval chain (yolo / permissions / readonly / ask) resolves a verdict for a tool call, before the call is executed (allow) or its error response is recorded (deny / canceled). Receives approval_decision (\"allow\" | \"deny\" | \"canceled\") and approval_source (a stable classifier of which step decided). Observational; gives audit pipelines a single \"who approved what\" record without re-implementing the chain.",
588+
"items": {
589+
"$ref": "#/definitions/HookDefinition"
590+
}
570591
}
571592
},
572593
"additionalProperties": false

pkg/config/latest/types.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1670,6 +1670,23 @@ type HooksConfig struct {
16701670
// max_iterations limit. Fires alongside Notification with
16711671
// level="warning".
16721672
OnMaxIterations []HookDefinition `json:"on_max_iterations,omitempty" yaml:"on_max_iterations,omitempty"`
1673+
1674+
// OnAgentSwitch hooks run whenever the runtime moves the active
1675+
// agent to a new one — transfer_task, handoff, or the return
1676+
// after a transferred task completes. Observational; useful for
1677+
// audit, transcript, and metrics pipelines.
1678+
OnAgentSwitch []HookDefinition `json:"on_agent_switch,omitempty" yaml:"on_agent_switch,omitempty"`
1679+
1680+
// OnSessionResume hooks run when the user explicitly approves the
1681+
// runtime to continue past its configured max_iterations limit.
1682+
// Observational; useful for alerting on extended-runtime sessions.
1683+
OnSessionResume []HookDefinition `json:"on_session_resume,omitempty" yaml:"on_session_resume,omitempty"`
1684+
1685+
// OnToolApprovalDecision hooks run after the runtime's tool
1686+
// approval chain resolves a verdict for a tool call. Observational;
1687+
// gives audit pipelines a structured "who approved what" record
1688+
// without re-implementing the chain.
1689+
OnToolApprovalDecision []HookDefinition `json:"on_tool_approval_decision,omitempty" yaml:"on_tool_approval_decision,omitempty"`
16731690
}
16741691

16751692
// IsEmpty returns true if no hooks are configured
@@ -1688,7 +1705,10 @@ func (h *HooksConfig) IsEmpty() bool {
16881705
len(h.Stop) == 0 &&
16891706
len(h.Notification) == 0 &&
16901707
len(h.OnError) == 0 &&
1691-
len(h.OnMaxIterations) == 0
1708+
len(h.OnMaxIterations) == 0 &&
1709+
len(h.OnAgentSwitch) == 0 &&
1710+
len(h.OnSessionResume) == 0 &&
1711+
len(h.OnToolApprovalDecision) == 0
16921712
}
16931713

16941714
// HookMatcherConfig represents a hook matcher with its hooks.
@@ -1822,6 +1842,27 @@ func (h *HooksConfig) validate() error {
18221842
}
18231843
}
18241844

1845+
// Validate OnAgentSwitch hooks
1846+
for i, hook := range h.OnAgentSwitch {
1847+
if err := hook.validate("on_agent_switch", i); err != nil {
1848+
return err
1849+
}
1850+
}
1851+
1852+
// Validate OnSessionResume hooks
1853+
for i, hook := range h.OnSessionResume {
1854+
if err := hook.validate("on_session_resume", i); err != nil {
1855+
return err
1856+
}
1857+
}
1858+
1859+
// Validate OnToolApprovalDecision hooks
1860+
for i, hook := range h.OnToolApprovalDecision {
1861+
if err := hook.validate("on_tool_approval_decision", i); err != nil {
1862+
return err
1863+
}
1864+
}
1865+
18251866
return nil
18261867
}
18271868

pkg/hooks/executor.go

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -73,18 +73,21 @@ func compileEvents(c *Config) map[EventType][]matcher {
7373
return []matcher{{hooks: hooks}}
7474
}
7575
return map[EventType][]matcher{
76-
EventPreToolUse: compileMatchers(c.PreToolUse),
77-
EventPostToolUse: compileMatchers(c.PostToolUse),
78-
EventSessionStart: flat(c.SessionStart),
79-
EventTurnStart: flat(c.TurnStart),
80-
EventBeforeLLMCall: flat(c.BeforeLLMCall),
81-
EventAfterLLMCall: flat(c.AfterLLMCall),
82-
EventSessionEnd: flat(c.SessionEnd),
83-
EventOnUserInput: flat(c.OnUserInput),
84-
EventStop: flat(c.Stop),
85-
EventNotification: flat(c.Notification),
86-
EventOnError: flat(c.OnError),
87-
EventOnMaxIterations: flat(c.OnMaxIterations),
76+
EventPreToolUse: compileMatchers(c.PreToolUse),
77+
EventPostToolUse: compileMatchers(c.PostToolUse),
78+
EventSessionStart: flat(c.SessionStart),
79+
EventTurnStart: flat(c.TurnStart),
80+
EventBeforeLLMCall: flat(c.BeforeLLMCall),
81+
EventAfterLLMCall: flat(c.AfterLLMCall),
82+
EventSessionEnd: flat(c.SessionEnd),
83+
EventOnUserInput: flat(c.OnUserInput),
84+
EventStop: flat(c.Stop),
85+
EventNotification: flat(c.Notification),
86+
EventOnError: flat(c.OnError),
87+
EventOnMaxIterations: flat(c.OnMaxIterations),
88+
EventOnAgentSwitch: flat(c.OnAgentSwitch),
89+
EventOnSessionResume: flat(c.OnSessionResume),
90+
EventOnToolApprovalDecision: flat(c.OnToolApprovalDecision),
8891
}
8992
}
9093

pkg/hooks/types.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,26 @@ const (
4545
EventOnError EventType = "on_error"
4646
// EventOnMaxIterations fires when the runtime reaches its max_iterations limit.
4747
EventOnMaxIterations EventType = "on_max_iterations"
48+
// EventOnAgentSwitch fires whenever the runtime moves the active
49+
// agent to a new one — either delegating a task (transfer_task),
50+
// handing off the conversation (handoff), or returning to the
51+
// caller after a transferred task completes. Observational; useful
52+
// for audit, transcript, and metrics pipelines that track which
53+
// agent ran which tools without subscribing to the runtime event
54+
// channel.
55+
EventOnAgentSwitch EventType = "on_agent_switch"
56+
// EventOnSessionResume fires when the user explicitly approves the
57+
// runtime to continue past its configured max_iterations limit.
58+
// Observational; useful for alerting on extended-runtime sessions
59+
// or for pipelines that bill / quota-track per resume.
60+
EventOnSessionResume EventType = "on_session_resume"
61+
// EventOnToolApprovalDecision fires after the runtime's tool
62+
// approval chain (yolo / permissions / readonly / ask) has resolved
63+
// a verdict for a tool call, before the call is executed (for
64+
// allow) or its error response is recorded (for deny / canceled).
65+
// Observational; gives audit pipelines a single, structured "who
66+
// approved what" record without re-implementing the chain.
67+
EventOnToolApprovalDecision EventType = "on_tool_approval_decision"
4868
)
4969

5070
// consumesContext reports whether the runtime emit site for e routes
@@ -83,6 +103,33 @@ type Input struct {
83103
// Notification specific.
84104
NotificationLevel string `json:"notification_level,omitempty"`
85105
NotificationMessage string `json:"notification_message,omitempty"`
106+
107+
// OnAgentSwitch specific: the agent the runtime is moving away
108+
// from (FromAgent) and the one it's switching to (ToAgent), plus
109+
// the cause of the transition ("transfer_task", "handoff",
110+
// "transfer_task_return"). Empty FromAgent is valid for the
111+
// initial switch into the team's default agent.
112+
FromAgent string `json:"from_agent,omitempty"`
113+
ToAgent string `json:"to_agent,omitempty"`
114+
AgentSwitchKind string `json:"agent_switch_kind,omitempty"`
115+
116+
// OnSessionResume specific: the iteration cap that was reached
117+
// (PreviousMaxIterations) and the new cap after the user approved
118+
// continuation (NewMaxIterations). Carrying both lets audit
119+
// pipelines compute how much extra runtime was granted without
120+
// reconstructing it from the iteration counter.
121+
PreviousMaxIterations int `json:"previous_max_iterations,omitempty"`
122+
NewMaxIterations int `json:"new_max_iterations,omitempty"`
123+
124+
// OnToolApprovalDecision specific: the verdict resolved by the
125+
// approval chain ("allow", "deny", "canceled") and a stable
126+
// classifier for what produced it ("yolo",
127+
// "session_permissions_allow", "session_permissions_deny",
128+
// "team_permissions_allow", "team_permissions_deny",
129+
// "readonly_hint", "user_approved", "user_approved_session",
130+
// "user_approved_tool", "user_rejected", "context_canceled").
131+
ApprovalDecision string `json:"approval_decision,omitempty"`
132+
ApprovalSource string `json:"approval_source,omitempty"`
86133
}
87134

88135
// ToJSON serializes the input.

pkg/runtime/agent_delegation.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,13 +311,15 @@ func (r *LocalRuntime) handleTaskTransfer(ctx context.Context, sess *session.Ses
311311

312312
// Emit agent switching start event
313313
evts <- AgentSwitching(true, a.Name(), params.Agent)
314+
r.executeOnAgentSwitchHooks(ctx, a, sess.ID, a.Name(), params.Agent, agentSwitchKindTransferTask)
314315

315316
r.setCurrentAgent(params.Agent)
316317
defer func() {
317318
r.setCurrentAgent(a.Name())
318319

319320
// Emit agent switching end event
320321
evts <- AgentSwitching(false, params.Agent, a.Name())
322+
r.executeOnAgentSwitchHooks(ctx, a, sess.ID, params.Agent, a.Name(), agentSwitchKindTransferTaskReturn)
321323

322324
// Restore original agent info in sidebar
323325
evts <- AgentInfo(a.Name(), getAgentModelID(a), a.Description(), a.WelcomeMessage())
@@ -345,7 +347,7 @@ func (r *LocalRuntime) handleTaskTransfer(ctx context.Context, sess *session.Ses
345347
return r.runSubSessionForwarding(ctx, sess, s, span, evts, a.Name())
346348
}
347349

348-
func (r *LocalRuntime) handleHandoff(_ context.Context, _ *session.Session, toolCall tools.ToolCall, _ chan Event) (*tools.ToolCallResult, error) {
350+
func (r *LocalRuntime) handleHandoff(ctx context.Context, sess *session.Session, toolCall tools.ToolCall, _ chan Event) (*tools.ToolCallResult, error) {
349351
var params builtin.HandoffArgs
350352
if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &params); err != nil {
351353
return nil, fmt.Errorf("invalid arguments: %w", err)
@@ -367,6 +369,7 @@ func (r *LocalRuntime) handleHandoff(_ context.Context, _ *session.Session, tool
367369
return nil, err
368370
}
369371

372+
r.executeOnAgentSwitchHooks(ctx, currentAgent, sess.ID, ca, next.Name(), agentSwitchKindHandoff)
370373
r.setCurrentAgent(next.Name())
371374
handoffMessage := "The agent " + ca + " handed off the conversation to you. " +
372375
"Your available handoff agents and tools are specified in the system messages that follow. " +

pkg/runtime/hooks.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/docker/docker-agent/pkg/hooks"
1010
"github.com/docker/docker-agent/pkg/hooks/builtins"
1111
"github.com/docker/docker-agent/pkg/session"
12+
"github.com/docker/docker-agent/pkg/tools"
1213
)
1314

1415
// buildHooksExecutors builds a [hooks.Executor] for every agent in the
@@ -177,6 +178,84 @@ func (r *LocalRuntime) notify(ctx context.Context, a *agent.Agent, event hooks.E
177178
}, nil)
178179
}
179180

181+
// Agent-switch kinds passed via [hooks.Input.AgentSwitchKind] to
182+
// describe what kind of transition triggered the on_agent_switch
183+
// event. Constants instead of literals so the hook contract is
184+
// discoverable from the runtime side and a typo trips a compile
185+
// error.
186+
const (
187+
agentSwitchKindTransferTask = "transfer_task"
188+
agentSwitchKindTransferTaskReturn = "transfer_task_return"
189+
agentSwitchKindHandoff = "handoff"
190+
)
191+
192+
// executeOnAgentSwitchHooks fires on_agent_switch when the runtime
193+
// changes the active agent. Observational; failures are logged. The
194+
// hook runs alongside the existing [AgentSwitching] event, so users
195+
// who already consume that event see no behaviour change.
196+
func (r *LocalRuntime) executeOnAgentSwitchHooks(ctx context.Context, a *agent.Agent, sessionID, fromAgent, toAgent, kind string) {
197+
r.dispatchHook(ctx, a, hooks.EventOnAgentSwitch, &hooks.Input{
198+
SessionID: sessionID,
199+
FromAgent: fromAgent,
200+
ToAgent: toAgent,
201+
AgentSwitchKind: kind,
202+
}, nil)
203+
}
204+
205+
// executeOnSessionResumeHooks fires on_session_resume when the user
206+
// explicitly approves continuation past the configured
207+
// max_iterations limit. Observational; failures are logged. The hook
208+
// runs alongside the existing event-channel signalling so audit /
209+
// quota / alerting pipelines can react without subscribing to the
210+
// per-session channel.
211+
func (r *LocalRuntime) executeOnSessionResumeHooks(ctx context.Context, a *agent.Agent, sessionID string, prevMax, newMax int) {
212+
r.dispatchHook(ctx, a, hooks.EventOnSessionResume, &hooks.Input{
213+
SessionID: sessionID,
214+
PreviousMaxIterations: prevMax,
215+
NewMaxIterations: newMax,
216+
}, nil)
217+
}
218+
219+
// Verdicts and sources for [hooks.EventOnToolApprovalDecision]. Constants
220+
// instead of literals so the contract between executeWithApproval and
221+
// the hook protocol is discoverable from the runtime side and a typo
222+
// trips a compile error.
223+
const (
224+
ApprovalDecisionAllow = "allow"
225+
ApprovalDecisionDeny = "deny"
226+
ApprovalDecisionCanceled = "canceled"
227+
228+
ApprovalSourceYolo = "yolo"
229+
ApprovalSourceSessionPermissionsAllow = "session_permissions_allow"
230+
ApprovalSourceSessionPermissionsDeny = "session_permissions_deny"
231+
ApprovalSourceTeamPermissionsAllow = "team_permissions_allow"
232+
ApprovalSourceTeamPermissionsDeny = "team_permissions_deny"
233+
ApprovalSourceReadOnlyHint = "readonly_hint"
234+
ApprovalSourceUserApproved = "user_approved"
235+
ApprovalSourceUserApprovedSession = "user_approved_session"
236+
ApprovalSourceUserApprovedTool = "user_approved_tool"
237+
ApprovalSourceUserRejected = "user_rejected"
238+
ApprovalSourceContextCanceled = "context_canceled"
239+
)
240+
241+
// executeOnToolApprovalDecisionHooks fires on_tool_approval_decision
242+
// after the runtime's approval chain has resolved a verdict for a
243+
// tool call. Fired once per call from each return path of
244+
// [executeWithApproval], so a single hook gets one record per tool
245+
// call regardless of which step decided.
246+
func (r *LocalRuntime) executeOnToolApprovalDecisionHooks(
247+
ctx context.Context,
248+
sess *session.Session,
249+
a *agent.Agent,
250+
toolCall tools.ToolCall,
251+
decision, source string,
252+
) {
253+
input := newHooksInput(sess, toolCall)
254+
input.ApprovalDecision = decision
255+
input.ApprovalSource = source
256+
r.dispatchHook(ctx, a, hooks.EventOnToolApprovalDecision, input, nil)
257+
}
258+
180259
// executeBeforeLLMCallHooks fires before_llm_call just before each
181260
// model call. A terminating verdict (decision="block" / continue=false
182261
// / exit 2) stops the run loop — see [hooks.EventBeforeLLMCall] for

pkg/runtime/loop.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,9 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c
302302
case req := <-r.resumeChan:
303303
if req.Type == ResumeTypeApprove {
304304
slog.Debug("User chose to continue after max iterations", "agent", a.Name())
305-
runtimeMaxIterations = iteration + 10
305+
newMax := iteration + 10
306+
r.executeOnSessionResumeHooks(ctx, a, sess.ID, runtimeMaxIterations, newMax)
307+
runtimeMaxIterations = newMax
306308
} else {
307309
slog.Debug("User rejected continuation", "agent", a.Name())
308310

0 commit comments

Comments
 (0)