Repository navigation
Proposal: add agent/turn-starting and agent/turn-aborting hooks for turn-boundary observability
#506
liuyang0508
started this conversation in
Ideas
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
TL;DR
Add two new plugin hooks to the agent loop so other plugins can observe turn boundaries without subscribing to step-level events and reconstructing turn boundaries themselves. No behavior change to existing events, session log shape, or
turnEndssemantics. Three vitest fixtures demonstrate the new contract.Background
Today
ReactLoopAgent.turn()fires one turn-level event —agent/turn-stopping— right before a turn ends naturally. There is no symmetric hook at turn start, and no hook fired in the abort path.Plugins that want turn-level visibility today have to subscribe to
step/start,step/end, and reconstruct turn boundaries themselves. This couples plugin code to internal step ordering, which the AGENTS.md warns against ("Plugins, not loop changes").The two new hooks fill this gap.
Proposal
New hooks
agent/turn-startingserial{ agent: Agent; turn: number; signal: AbortSignal }agentinjected by dispatcherpreStepclaim (which transitions the turn from idle to running) andphase.turn = turn, before the firststep/startlog entry — i.e. on the first iteration of the step loop, gated bytarget === 'next-turn' && step === 1agent/turn-abortingserial{ agent: Agent; turn: number; cause: AgentCancelCause; signal: AbortSignal }agentinjected by dispatchersignal.abortedis true, immediately beforethrow errorThe
agentfield is declared in the payload type but is auto-injected by the fused dispatcher (AgentEventDispatchinpackages/core/agent/src/dispatch.ts) — mirrors every existingagent/...event. This is the convention: declared shape matches delivered shape.Both hooks use
serialsemantics (mirror the existingagent/turn-stopping): plugins observe synchronously, but cannot veto the turn. Plugins that want to "refuse" a turn throw from inside the listener; plugins that want to cancel mid-turn callagent.cancel(cause)using theagentfield injected into the payload.Where the hooks fire (in source order)
In
packages/core/agent-loop/src/agent.ts, the currentturn()flow becomes:Why
serial, notwaterfallwaterfalllets listeners veto by not callingnext(). For "veto turn start" this is too strong — a plugin that forgets to callnext()would silently kill every turn.serialis observation-only and matches the existingagent/turn-stoppingprecedent.Plugin-initiated abort is not a feature of these hooks. If a future need appears, it should be a separate
agent/turn-cancel-requestwaterfall event with explicit veto semantics.Files touched
packages/core/agent-loop/src/agent.tsemitTurnStarting,emitTurnAborting); call them at 2 insertion points inturn()and 1 insertion point in the catch block. Method shape matches the existingemit('agent/error', ...)style.packages/core/agent/src/runtime-types.tsinterface Eventsdeclaration-merge block. JSDoc follows the existingagent/turn-stoppingtemplate:@mode serial,@param payload.agent,@param payload.turn,@param payload.signal, plus the "Scope-filtered dispatch (@deepseek-ai/dsh-scope): agent-scoped listeners receive only that agent." line.packages/core/scope/src/scoped-events.generated.ts+packages/extensions/tool-cordis/src/api-catalog.tspnpm run gen-scoped-eventsandpnpm run gen-cordis-catalog.docs/subsystems/core.md+docs/subsystems/core.zh.md+docs/subsystems/core.i18n.yaml+docs/event-producer-consumer.mdpnpm run gen-doc-graphs. The agent-loop events table and producer/consumer mapping gain two rows.packages/core/agent-loop/tests/loop.spec.tsNo changes to
preStep,step,buildRequest,tool-calls,kick,inbox, or session schema.Error handling guarantees
Three throw paths through the new hooks, each with a defined outcome:
Plugin throws inside
agent/turn-starting— falls through to the existing error path.turnEnds = { kind: 'error', error: { message, code: 'UNKNOWN' } }.agent/errorfires.agent/turn-abortingdoes not fire (not an abort). Session log records the full turn with reasonerror.Plugin throws inside
agent/turn-aborting— caught locally, re-emitted asagent/error, not rethrown. The originalerrorfrom the catch (the abort) continues to propagate. This is required so a misbehaving listener cannot mask the abort cause.External abort arrives during
agent/turn-starting— thesignal.throwIfAborted()after the hook throws. catch enters the aborted branch.agent/turn-abortingfires with the abort cause (this is the intended "abort lifecycle closes" path).The code shape for path 2:
Behavior guarantees
What stays the same:
turnEndssticky max-tokens semantics unchanged.kick()loop termination unchanged.cancel()API unchanged.What's new:
ctx.on('agent/turn-starting', ...)/ctx.on('agent/turn-aborting', ...)get type-complete payloads after the declaration-merge update.Test plan
Three vitest fixtures appended to
packages/core/agent-loop/tests/loop.spec.ts. Each follows the existingharness()+MockAdapter+ctx.on('agent/status')pattern.Fixture 1 —
turn-startingfires exactly once per natural turnSetup: a
MockAdapterreturning one text response. A listener subscribes toagent/turn-startingand recordsturnnumbers. Send one message, wait for idle.Asserts:
turn === 1.turn/endevent hasreason.kind === 'completed'.Proves: hook position is correct (between
turn/startand first step); one fire per turn (not per step).Fixture 2 —
turn-abortingcarries the cancel cause before rethrowSetup: a
MockAdapterthat returns the'hang'marker (a response the adapter never resolves — lets the turn enter its first step and park there). Listener subscribes toagent/turn-abortingand recordscause. Send a message, yield ~30 ms so the turn reaches its first step andagent.statusisrunning, then callagent.cancel({ kind: 'user' }). Wait for idle.Asserts:
cause === { kind: 'user' }.turn/endevent hasreason.kind === 'aborted'.Proves: external cancel → abort signal → catch aborted branch → hook fires before throw → cause preserved through session log.
Fixture 3 — Plugin throw in
turn-startingroutes to error pathSetup: a
MockAdapterwith zero responses. A listener onagent/turn-startingthrowsnew Error('plugin refuses to start turn'). A listener onagent/errorrecords errors. Send a message, wait for idle.Asserts:
turn/endevent hasreason.kind === 'error'.agent/errorfired exactly once.Proves: the "veto turn start" capability plugins actually have — throw from the listener — produces a clean error path with full session log + error event, instead of silently dropping the turn.
Fixture 4 — Plugin throw in
turn-abortingis caught and re-emitted asagent/errorSetup: same
'hang'adapter + 30 ms status guard as Fixture 2. A listener onagent/turn-abortingthrows a sentinel error after recording the cause. A listener onagent/errorcaptures the captured error.Asserts:
cause === { kind: 'user' }before throwing.agent/errorevent was emitted, carrying the sentinel error (proves the catch inemitTurnAbortingre-routes the throw).turn/endevent hasreason.kind === 'aborted'(NOTerror— proves the listener's throw did not corrupt the turn-end or replace the original abort cause).Proves: path 2 of the error-handling guarantees — a misbehaving
turn-abortinglistener cannot mask the abort or downgrade the turn-end reason.Open questions for the maintainers
Naming:
agent/turn-startingvsagent/turn-begin/agent/turn-open— happy to follow whatever convention is preferred. Theagent/...prefix mirrorsagent/pre-stepandagent/turn-stopping.Payload for
turn-aborting: shouldcausebe the rawsignal.reason(typed asAgentCancelCause) or wrapped in{ kind, ... }? Currently proposed as the rawAgentCancelCausevalue, matching how the agent's owncancel()method accepts it.Documentation entry:
docs/architecture.mdis the proposed home. If there's a more specific "agent-loop events" catalog file, that's where it should land.What this proposal does NOT include
agent/turn-cancel-requestwaterfall if ever).turnEnds!non-null assertion or step/turn-end finally block (orthogonal, not touched).Working fork
A reference implementation lives on the branch
feat/agent-turn-starting-aborting-hooksof the forkliuyang0508/deepseek-harness. Five functional commits land the proposal end-to-end (plus a marker commit9864594that bootstraps the branch frommaster):ad09941agent/turn-startingandagent/turn-abortingevents0231384scoped-eventsandcordiscatalog90bded1agent/turn-startingandagent/turn-abortinghooks2954b4fagent/turn-startingandagent/turn-abortinghooks3d35452turn-abortinglistener throw →agent/errorrouteDiff against
master: compare view (10 files changed).Local checks on the branch:
pnpm exec vitest run packages/core/agent-loop— 333 tests pass, including 4 new fixtures (fires agent/turn-starting exactly once per turn in a natural completion,fires agent/turn-aborting with the abort cause before rethrow,records a turn-end with reason=error when a turn-starting listener throws,routes a turn-aborting listener throw through agent/error without corrupting turn-end).pnpm typecheck— exit 0pnpm lint— exit 0pnpm run gen-doc-graphs verify-doc-graphs— exit 0 (declare-and-dispatch atomicity preserved)pnpm exec vitest run packages/core/agent-loop --coverage --coverage.include='packages/core/agent-loop/src/**/*.{ts,tsx}'—agent.tsline coverage 100%, function coverage 100% for the new helpers. (The remaining uncovered line atagent.ts:143is inrunMaintenance, pre-existing code not touched by this change.)All reactions