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 throughsession/event. Use one when the fact must survive a reload. - Live
agent/*events carry a liveAgentand 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/start → step/start → … → step/end → turn/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:
| Member | Meaning |
|---|---|
id | The single identity shared with session |
options | Provider route and model (provider, model, maxTokens) |
session | The live session; its log is the durable source of truth |
inbox | The agent-owned projection of durable pending work |
status | 'idle' | 'running' |
ctx | Agent-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):
| Event | Mode | Meaning |
|---|---|---|
agent/created | emit | A fully configured agent + live session were published |
agent/disposed | emit | An agent left the registry |
agent/status | emit | Status flipped idle ⇄ running |
agent/inbox/inserted | emit | One message entered the live inbox |
agent/inbox/claimed | emit | One message left the inbox inside its open turn |
agent/inbox/discarded | emit | One message was discarded from the live inbox |
agent/session-start | emit | The session lifecycle began, once before the first turn (source: startup/resume/clear/compact) |
agent/pre-step | waterfall | Reject a proposed step or replace the messages entering it |
agent/request | waterfall | Replace the frozen call configuration |
agent/request-error | waterfall | Handle one failed model-request attempt before retry/close |
agent/turn-stopping | serial | Turn is about to close; a listener that objects steers |
agent/error | emit | A 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 aContexttagged with an opaqueScopeKey, 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:
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:
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 events — agent/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:
setup?: (agentCtx: Context) => AgentSetupCommit | Promise<AgentSetupCommit | void> | voidEverything 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):
- Insert the agent into the registry (
enter) — scope-effective, no announce yet. - Announce (
agent/created) — synchronous listener failure vetoes publication. - Emit
agent/session-start { source }— the first startup-driving extension point. - 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:
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(theSessionEventMap).