Skip to content

Commit 86bc6f0

Browse files
committed
feat(hooks): add on_session_resume event
Fires when the user explicitly approves the runtime to continue past its configured max_iterations limit. The runtime already supports user-driven resume via the resumeChan / ResumeTypeApprove path, but the resumption was visible only as a runtime-internal behaviour (the iteration counter quietly extended). The hook makes the event accessible for: - alerting on extended-runtime sessions ("agent X has resumed past its iteration cap N times this hour") - billing/quota pipelines that meter resumes separately from initial budget - audit transcripts that distinguish "agent stopped at N" from "agent stopped at N, user resumed, then stopped at N+10" Two new typed Input fields, PreviousMaxIterations and NewMaxIterations, carry the granted runtime so audit pipelines can compute the delta directly without reconstructing it from the iteration counter. The hook fires alongside the existing AgentSwitching / Notification machinery in the resume branch, so consumers that already track ResumeTypeApprove via other channels see no behaviour change. Assisted-By: docker-agent
1 parent 231c75f commit 86bc6f0

7 files changed

Lines changed: 131 additions & 2 deletions

File tree

agent-schema.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,13 @@
574574
"items": {
575575
"$ref": "#/definitions/HookDefinition"
576576
}
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+
}
577584
}
578585
},
579586
"additionalProperties": false

pkg/config/latest/types.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1676,6 +1676,11 @@ type HooksConfig struct {
16761676
// after a transferred task completes. Observational; useful for
16771677
// audit, transcript, and metrics pipelines.
16781678
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"`
16791684
}
16801685

16811686
// IsEmpty returns true if no hooks are configured
@@ -1695,7 +1700,8 @@ func (h *HooksConfig) IsEmpty() bool {
16951700
len(h.Notification) == 0 &&
16961701
len(h.OnError) == 0 &&
16971702
len(h.OnMaxIterations) == 0 &&
1698-
len(h.OnAgentSwitch) == 0
1703+
len(h.OnAgentSwitch) == 0 &&
1704+
len(h.OnSessionResume) == 0
16991705
}
17001706

17011707
// HookMatcherConfig represents a hook matcher with its hooks.
@@ -1836,6 +1842,13 @@ func (h *HooksConfig) validate() error {
18361842
}
18371843
}
18381844

1845+
// Validate OnSessionResume hooks
1846+
for i, hook := range h.OnSessionResume {
1847+
if err := hook.validate("on_session_resume", i); err != nil {
1848+
return err
1849+
}
1850+
}
1851+
18391852
return nil
18401853
}
18411854

pkg/hooks/executor.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ func compileEvents(c *Config) map[EventType][]matcher {
8686
EventOnError: flat(c.OnError),
8787
EventOnMaxIterations: flat(c.OnMaxIterations),
8888
EventOnAgentSwitch: flat(c.OnAgentSwitch),
89+
EventOnSessionResume: flat(c.OnSessionResume),
8990
}
9091
}
9192

pkg/hooks/types.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ const (
5353
// agent ran which tools without subscribing to the runtime event
5454
// channel.
5555
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"
5661
)
5762

5863
// consumesContext reports whether the runtime emit site for e routes
@@ -100,6 +105,14 @@ type Input struct {
100105
FromAgent string `json:"from_agent,omitempty"`
101106
ToAgent string `json:"to_agent,omitempty"`
102107
AgentSwitchKind string `json:"agent_switch_kind,omitempty"`
108+
109+
// OnSessionResume specific: the iteration cap that was reached
110+
// (PreviousMaxIterations) and the new cap after the user approved
111+
// continuation (NewMaxIterations). Carrying both lets audit
112+
// pipelines compute how much extra runtime was granted without
113+
// reconstructing it from the iteration counter.
114+
PreviousMaxIterations int `json:"previous_max_iterations,omitempty"`
115+
NewMaxIterations int `json:"new_max_iterations,omitempty"`
103116
}
104117

105118
// ToJSON serializes the input.

pkg/runtime/hooks.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,20 @@ func (r *LocalRuntime) executeOnAgentSwitchHooks(ctx context.Context, a *agent.A
201201
}, nil)
202202
}
203203

204+
// executeOnSessionResumeHooks fires on_session_resume when the user
205+
// explicitly approves continuation past the configured
206+
// max_iterations limit. Observational; failures are logged. The hook
207+
// runs alongside the existing event-channel signalling so audit /
208+
// quota / alerting pipelines can react without subscribing to the
209+
// per-session channel.
210+
func (r *LocalRuntime) executeOnSessionResumeHooks(ctx context.Context, a *agent.Agent, sessionID string, prevMax, newMax int) {
211+
r.dispatchHook(ctx, a, hooks.EventOnSessionResume, &hooks.Input{
212+
SessionID: sessionID,
213+
PreviousMaxIterations: prevMax,
214+
NewMaxIterations: newMax,
215+
}, nil)
216+
}
217+
204218
// executeBeforeLLMCallHooks fires before_llm_call just before each
205219
// model call. A terminating verdict (decision="block" / continue=false
206220
// / 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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package runtime
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/docker/docker-agent/pkg/agent"
10+
"github.com/docker/docker-agent/pkg/hooks"
11+
"github.com/docker/docker-agent/pkg/team"
12+
)
13+
14+
// runtimeWithRecordedSessionResume mirrors runtimeWithRecordedAgentSwitch
15+
// for the on_session_resume event. Same pattern: register a recording
16+
// builtin on the runtime's private registry post-construction so the
17+
// test can assert on dispatched input without exposing a runtime
18+
// option that production callers shouldn't reach for.
19+
func runtimeWithRecordedSessionResume(t *testing.T) (*LocalRuntime, *recordingBuiltin) {
20+
t.Helper()
21+
22+
rb := &recordingBuiltin{}
23+
prov := &mockProvider{id: "test/mock-model", stream: &mockStream{}}
24+
a := agent.New("root", "instructions",
25+
agent.WithModel(prov),
26+
agent.WithHooks(&hooks.Config{
27+
OnSessionResume: []hooks.Hook{{
28+
Type: hooks.HookTypeBuiltin,
29+
Command: "test_record_session_resume",
30+
}},
31+
}),
32+
)
33+
tm := team.New(team.WithAgents(a))
34+
35+
r, err := NewLocalRuntime(tm, WithModelStore(mockModelStore{}))
36+
require.NoError(t, err)
37+
38+
require.NoError(t, r.hooksRegistry.RegisterBuiltin("test_record_session_resume", rb.hook))
39+
r.buildHooksExecutors()
40+
41+
return r, rb
42+
}
43+
44+
// TestExecuteOnSessionResumeHooks_ForwardsLimits pins the contract:
45+
// PreviousMaxIterations and NewMaxIterations both reach the hook
46+
// verbatim. Audit pipelines compute the granted-runtime delta from
47+
// these directly without rebuilding it from the iteration counter.
48+
func TestExecuteOnSessionResumeHooks_ForwardsLimits(t *testing.T) {
49+
t.Parallel()
50+
51+
r, rb := runtimeWithRecordedSessionResume(t)
52+
a := r.CurrentAgent()
53+
require.NotNil(t, a)
54+
55+
r.executeOnSessionResumeHooks(t.Context(), a, "session-y", 5, 15)
56+
57+
got := rb.snapshot()
58+
require.Len(t, got, 1)
59+
in := got[0]
60+
assert.Equal(t, "session-y", in.SessionID)
61+
assert.Equal(t, 5, in.PreviousMaxIterations)
62+
assert.Equal(t, 15, in.NewMaxIterations)
63+
}
64+
65+
// TestExecuteOnSessionResumeHooks_NoopWhenNoHookRegistered keeps the
66+
// cheap-when-unused property symmetric with on_agent_switch: no
67+
// dispatch, no panic, no error when no hook is configured.
68+
func TestExecuteOnSessionResumeHooks_NoopWhenNoHookRegistered(t *testing.T) {
69+
t.Parallel()
70+
71+
prov := &mockProvider{id: "test/mock-model", stream: &mockStream{}}
72+
a := agent.New("root", "instructions", agent.WithModel(prov))
73+
tm := team.New(team.WithAgents(a))
74+
75+
r, err := NewLocalRuntime(tm, WithModelStore(mockModelStore{}))
76+
require.NoError(t, err)
77+
78+
r.executeOnSessionResumeHooks(t.Context(), a, "s", 5, 15)
79+
}

0 commit comments

Comments
 (0)