Skip to content

The two event domains

Runtime events split into two domains, and picking the right one is the first decision:

  • Durable session events (turn/*, step/*, user/message, assistant/*, tool/*) live on the append-only session log and broadcast through session/event. Use one when the fact must survive a reload.
  • Live agent/* events carry a live Agent and are scope-filtered to it. Use one to observe or intercept work in flight.

This page is the live half. The durable sequence is described in the repo's docs/agent-lifecycle.md.

Turn and step terminology

From docs/architecture.md:

  • A step is one model request plus the tools it calls.
  • A turn is zero or more steps: it opens before its first input is claimed and closes once nothing is owed.

The durable boundaries are turn/startstep/start → … → step/endturn/end, logged via session.append in the driver. A rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt.

The Agent interface

packages/core/agent/src/runtime-types.ts defines the public Agent:

MemberMeaning
idThe single identity shared with session
optionsProvider route and model (provider, model, maxTokens)
sessionThe live session; its log is the durable source of truth
inboxThe agent-owned projection of durable pending work
status'idle' | 'running'
ctxAgent-scoped context; contributions unwind on disposal
send(message, target, wakeup)Route input to an inbox boundary, optionally wake the driver
followup(message)Queue an ordinary follow-up turn and wake
steer(message)Submit steering for the nearest step
inject(message)Queue model-facing context for the next pre-step, no wake
cancel(cause, options)Abort the active turn or between-turn task
whenIdle()Resolve after the whole-agent activity reaches quiescence
runMaintenance(task)Run one non-turn maintenance task from the true idle phase

The lifecycle states

AgentStatus is only two values: 'idle' (no driver active) and 'running' (waking input started cancellable pre-step processing and the driver is draining, closing, or checkpointing turns). Disposal removes the agent from the registry — it is not a third observable status.

The live events (from packages/core/agent/src/runtime-types.ts):

EventModeMeaning
agent/createdemitA fully configured agent + live session were published
agent/disposedemitAn agent left the registry
agent/statusemitStatus flipped idlerunning
agent/inbox/insertedemitOne message entered the live inbox
agent/inbox/claimedemitOne message left the inbox inside its open turn
agent/inbox/discardedemitOne message was discarded from the live inbox
agent/session-startemitThe session lifecycle began, once before the first turn (source: startup/resume/clear/compact)
agent/pre-stepwaterfallReject a proposed step or replace the messages entering it
agent/requestwaterfallReplace the frozen call configuration
agent/request-errorwaterfallHandle one failed model-request attempt before retry/close
agent/turn-stoppingserialTurn is about to close; a listener that objects steers
agent/erroremitA step or turn errored

The waterfall events (pre-step, request, request-error) and llm/stream, plus the three tools/* events, all require listeners to call next() to delegate; agent/turn-stopping is serial and has no next().

Scope key = live agent

packages/core/scope/src/index.ts is the library that makes per-agent events routing-safe. The key is the agent object itself:

  • createScope(ctx, key) mints a Context tagged with an opaque ScopeKey, plus an exact/shared disposer.
  • scopeTarget(base, key) builds a routing-only carrier whose filter admits listeners for a matching key or any of its ancestors (bindScopeParent). Events flow up the chain, never down — so one standing composition observes every agent composed under it.
  • scopeOf(ctx) reads the nearest scope tag.

In packages/core/agent-loop/src/agent.ts, each ReactLoopAgent mints its own scope and derives agent.ctx:

ts
this.scope = createScope(loopCtx, this)
this.ctx = this.scope.ctx.extend({ agent: this })
this.dispatch = agentEvents(loopCtx, this)

The scope is where the "setup window" composes a scoped world: everything registered through agent.ctx (scoped tools, prompt sections, restrict(), listeners, awaited child plugins) is agent-local, exists before the first prompt assembly, and unwinds on disposal.

The event dispatch model

packages/core/agent/src/dispatch.ts couples the agent subject to its scope carrier so the two cannot diverge:

ts
export function agentEvents(ctx, agent, carrier = agentCarrier(agent)): AgentEventDispatch {
  // fused dispatch by emit / serial / waterfall, injecting `agent` into the payload
}

The carrier is passed as the Cordis listener-filter this; the emit path resolves the filtered callback set itself and contains synchronous throws and returned-promise rejections per listener, so a notification cannot veto lifecycle progress. The loop driver builds the dispatcher once in the agent's constructor and reuses it on the hot path.

Registry-subject eventsagent/created, agent/disposed, and the paired session/created/session/disposed/session/event — are dispatched through the registry (packages/core/agent/src/index.ts), which filters by the carrier so agent-scoped listeners receive only this agent while unscoped listeners observe globally.

The setup window: CreateAgentOptions.setup

Programmatic creation flows through AgentRegistry.create → the registered AgentFactory (@deepseek-ai/dsh-agent-loop). The factory mints agentCtx, awaits setup BEFORE inserting or announcing either the session or agent, so observers can never see a partially configured world:

ts
setup?: (agentCtx: Context) => AgentSetupCommit | Promise<AgentSetupCommit | void> | void

Everything registered through agentCtx (scoped tools, prompt sections/variables, listeners, awaited child plugins) exists before session/created, agent/created, agent/session-start, and the first prompt assembly. Setup is composition-only, it never drives — drive the agent only after creation resolves. A setup throw, commit throw, or owner disposal rolls the scope back without publishing either id.

resume mirrors this window for persisted sessions: persistence is loaded first, then agentCtx is minted and setup awaited while the reconstructed session and agent stay unpublished.

Publication order

The factory's publish(source) sequence (packages/core/agent-loop/src/index.ts):

  1. Insert the agent into the registry (enter) — scope-effective, no announce yet.
  2. Announce (agent/created) — synchronous listener failure vetoes publication.
  3. Emit agent/session-start { source } — the first startup-driving extension point.
  4. Start the machine (the driver).

A synchronous agent/created listener failure rolls back and pairs any begun creation announcement with agent/disposed or session/disposed. The launcher owns configured agents' exact session identities via CONFIGURED_AGENT_IDENTITIES_KEY, set with ctx.provide() before any Loader entry mounts.

Driving one turn

ReactLoopAgent.kick()turn() loops while turn() returns true. A turn:

text
turn/start { turn }
  preStep(target, {turn, step})
    claim inbox batch                agent/inbox/claimed per message
    system-prompt/assemble waterfall
    agent/pre-step waterfall  → enter(messages) | reject
  if enter: for each message: user/message append
    step/start
      buildRequest -> agent/request waterfall -> llm/stream -> assistant/chunk*
        -> assistant/message
      tool calls -> tools/pre-execute|execute|post-execute -> tool/result*
    step/end
    if turnEnds && next-step inbox empty:
      agent/turn-stopping (serial)    — a listener may steer()
  turn/end { reason }

Each step reads the prompt sections and tool schemas that plugins registered, and derives model history from the log (deriveMessages()). Model-visible means logged: anything that reaches a model request must be reconstructable from the log.

Teardown

AgentHandle.dispose() (returned only to the consumer owner that created the agent) stops the loop, awaits its exit, unwinds the scoped world — every registration made through agent.ctx disposed in reverse order — and only then unregisters the agent (emitting agent/disposed) and removes its session from the store. cancel(cause) is the abrupt path: with no keepInbox it clears queued and steering work and aborts the active turn or between-turn task. whenIdle() follows both the active activity and any replacement work released behind it.

Further reading

  • Architecture at a Glance — the core-packages table and event taxonomy.
  • The extension (Cordis) system — typed events and reversible effects that drive the loop.
  • Repo docs: docs/agent-lifecycle.md (the durable sequence), docs/event-producer-consumer.md (who dispatches/listens to each event), docs/subsystems/core.md.
  • Source: packages/core/agent/src/runtime-types.ts, packages/core/agent/src/dispatch.ts, packages/core/agent-loop/src/agent.ts.
  • Source: packages/core/scope/src/index.ts, packages/core/session/src/types.ts (the SessionEventMap).