Skip to content

feat(hooks): add three observability events around runtime transitions#2542

Merged
dgageot merged 3 commits intodocker:mainfrom
dgageot:board/runtime-code-extension-points-analysis-776bc127
Apr 27, 2026
Merged

feat(hooks): add three observability events around runtime transitions#2542
dgageot merged 3 commits intodocker:mainfrom
dgageot:board/runtime-code-extension-points-analysis-776bc127

Conversation

@dgageot
Copy link
Copy Markdown
Member

@dgageot dgageot commented Apr 27, 2026

Three small, additive observability events covering runtime transitions
that today are visible only via the per-session event channel. Each
gives audit / transcript / metrics / billing pipelines a structured,
typed entry point without forcing them to subscribe to the channel
and reconstruct state from the existing event stream.

Event Typed Input fields What it unlocks
`on_agent_switch` `FromAgent`, `ToAgent`, `AgentSwitchKind` Track which agent ran which tools across `transfer_task`, `transfer_task_return`, and `handoff` without correlating `AgentSwitching(start, ...)` / `AgentSwitching(end, ...)` triples.
`on_session_resume` `PreviousMaxIterations`, `NewMaxIterations` Alert on extended-runtime sessions; bill / quota-track per user-approved continuation past `max_iterations` without parsing notification messages or watching the resume channel.
`on_tool_approval_decision` `ApprovalDecision`, `ApprovalSource` One structured "who approved what" record per tool call, covering all 11 outcomes of the approval chain (yolo / 4 permissions outcomes / readonly / 4 user-confirmation outcomes / context-canceled). Replaces the audit code that today correlates `ToolCall` + `ToolCallConfirmation` + `ToolCallResponse` + `HookBlocked`.

Mechanics (per commit)

Each commit follows the now-established one-event template and is
deliberately minimal:

  • one constant in `pkg/hooks/types.go`
  • one entry in `compileEvents`
  • one field (with `validate` + `IsEmpty`) in `latest.HooksConfig`
  • one entry in `agent-schema.json`
  • one `executeOnXxxHooks` wrapper in `pkg/runtime/hooks.go` using the
    existing `dispatchHook` helper
  • typed Input fields for the event-specific data (no
    `notification_message` string-parsing)
  • call sites wired alongside the corresponding existing
    event-channel emissions, so consumers that already listen to
    `AgentSwitching` / `MaxIterationsReached` / `ToolCallConfirmation` +
    `HookBlocked` see no behaviour change

For `on_tool_approval_decision`, the source classifier is fired at
every return path of `executeWithApproval` and `askUserForConfirmation`
so a single hook gets exactly one record per tool call regardless of
which step decided. Stable string constants live on the runtime side
(`ApprovalSource*`, `ApprovalDecision*`, `agentSwitchKind*`) so the
contract between the runtime and the hook protocol is discoverable
and a typo trips a compile error.

Tests

A `recordingBuiltin` helper (introduced in the first commit, reused
by the next two) registers itself on the runtime's private
`hooksRegistry` post-construction. The pattern (`runtimeWithRecorded*`)
keeps the assertions close to a real call path without exposing a
`WithHooksRegistry` option that production callers would reach for.
Each event has its own `pkg/runtime/on_*_test.go` covering:

  • the dispatched Input fields (verdict / source / from / to / kind /
    iteration limits all forwarded verbatim)
  • the cheap-when-unused property (no panic / no error when no hook is
    configured, since the executor lookup short-circuits)
  • for `on_tool_approval_decision`: the `allowSourceFor` /
    `denySourceFor` mapping is pinned, including the
    unknown-label-defaults-to-team-permissions safety behaviour.

`mise test` and `mise lint` are both clean.

Commits

  • `feat(hooks): add on_agent_switch event`
  • `feat(hooks): add on_session_resume event`
  • `feat(hooks): add on_tool_approval_decision event`

dgageot added 3 commits April 27, 2026 17:27
Fires whenever the runtime moves the active agent to a new one:

  - transfer_task        - delegating to a sub-agent

  - transfer_task_return - returning to the caller after a

                           transferred task completes

  - handoff              - handing off the conversation to

                           another agent in the handoffs list

Until now this transition was visible only via the runtime

AgentSwitching event, which forces consumers to subscribe to the

per-session event channel and parse a (start, from, to) triple.

The hook makes the transition accessible via standard hook config

for audit, transcript, and metrics pipelines.

Three new typed Input fields carry the transition: FromAgent,

ToAgent, and AgentSwitchKind. AgentSwitchKind is a stable

classifier suitable for switch statements; the constants

agentSwitchKind* document the contract from the runtime side.

The hook is dispatched on the source agent's executor (the agent

doing the delegation/handoff), so an agent's audit policy follows

the agent itself rather than depending on which target it points

at. Existing AgentSwitching event consumers are unaffected.

Assisted-By: docker-agent
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
Fires 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). Until now this verdict was implicit \u2014 reconstructible

only by correlating ToolCall, ToolCallConfirmation, ToolCallResponse,

and HookBlocked events from the runtime channel. The hook gives

audit pipelines a single, structured "who approved what" record.

Two new typed Input fields:

  - ApprovalDecision: "allow" | "deny" | "canceled"

  - ApprovalSource:   stable classifier for which step decided

                      (yolo, session_permissions_allow,

                      session_permissions_deny, team_permissions_allow,

                      team_permissions_deny, readonly_hint,

                      user_approved, user_approved_session,

                      user_approved_tool, user_rejected,

                      context_canceled)

Constants live on the runtime side as Approval{Decision,Source}*

so the contract between executeWithApproval and the hook protocol

is discoverable from one place. allowSourceFor / denySourceFor map

the existing permissionChecker.source labels onto the public

classifiers; unknown labels default to team_permissions to avoid

silent misclassification on future label changes.

The hook is fired at every return path of executeWithApproval and

askUserForConfirmation, so a single hook gets exactly one record

per tool call regardless of which step decided. Existing event

consumers see no change.

Assisted-By: docker-agent
@dgageot dgageot requested a review from a team as a code owner April 27, 2026 15:45
@dgageot dgageot merged commit 7cdf467 into docker:main Apr 27, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants