The agent loop is the harness's default, concrete implementation of the public Agent contract. Everything the public Agent interface declares is the contract; this page is the machine that implements it — the ReactLoopAgent class in packages/core/agent-loop/src/agent.ts. It is one of the packages in the core spine, and it lives in agent-loop specifically because it is the shipped product loop: extension plugins depend on agent (never on agent-loop) so the driver stays swappable.
| Package | Owns |
|---|---|
@deepseek-ai/dsh-agent | The Agent interface, live registry, agent/* events, inbox, initiator scope (ctx.agents) |
@deepseek-ai/dsh-agent-loop | The concrete driver, tool-call scheduler, agent factory (ctx.agentLoop; the factory registers via ctx.agents.setFactory) |
@deepseek-ai/dsh-session | The append-only SessionEvent log (ctx.sessions) |
@deepseek-ai/dsh-system-prompt | Prompt assembly (.loopCtx.systemPrompt) |
Two nested units: turn and step
The loop uses two nested boundaries, both recorded as durable session events.
A step is the operating unit: one model request plus the tool executions that request produced. It opens with a step/start event and closes with step/end. Within a step the loop streams the model response chunk by chunk, and if the assistant emitted tool-call content blocks it executes them.
A turn is one drain of admitted input: it opens with turn/start, then runs one or more steps while continuations exist, and closes with turn/end carrying a TurnEndReason. A turn begins when waking input is claimed and ends when the model owes no further response (no live tool calls, no fresh steering) or a terminal policy fires. The phase machine in ReactLoopAgent is a three-state discriminated union:
type Phase =
| { kind: 'idle'; lastTurn: number }
| { kind: 'maintenance'; abort: AbortController; lastTurn: number; wakeRequested: boolean }
| { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean }status reports idle for both idle and maintenance phases, and running while draining. Every phase transition publishes agent/status.
The driver's main flow
kick() is the driver entry point: it drains turns until no continuation remains, then unwinds back to idle. turn() opens one turn and runs its step loop. This is the real main flow from agent.ts:
private async kick(): Promise<void> {
try {
while (await this.turn()) {}
} catch (_error) {
// Reported failures and cancellation are contained at the driver boundary.
} finally {
if (this.phase.kind === 'running') {
const { turn, wakeRequested } = this.phase
this.setPhase({ kind: 'idle', lastTurn: turn })
if (wakeRequested && this.inbox.hasPending) this.wakeDriver()
}
}
}turn() claims the first proposed step, appends step/start, records claimed messages as user/message events, runs step(), appends step/end, and decides whether to continue. The continuation policy and the agent/turn-stopping interception appear in the middle of that loop:
while (true) {
signal.throwIfAborted()
const step = phase.step + 1
const decision = await this.preStep(target, { turn, step })
if (decision.kind === 'reject') { turnEnds = { kind: 'blocked' }; return false }
if (turnEnds && decision.messages.length === 0) break
if (phase.step === 0 && decision.messages.length === 0) {
turnEnds = { kind: 'completed' }; return false
}
this.session.append('step/start', { turn, step })
phase.step = step
try {
for (const message of decision.messages) {
this.session.append('user/message', message, { surfaceOp: 'append' })
}
const stepEnd = await this.step(decision.assembly)
if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd
} finally {
this.session.append('step/end', { turn, step })
}
signal.throwIfAborted()
if (turnEnds && this.inbox.nextStep.length === 0) {
await this.dispatch.serial('agent/turn-stopping', { turn, signal })
signal.throwIfAborted()
}
if (turnEnds && this.inbox.nextStep.length === 0) break
target = 'next-step'
}Where the model is called
step(assembly) renders the system prompt, then enters a request loop. Each iteration calls buildRequest(), opens the provider stream, appends raw chunks to the log, and assembles blocks. Tool results re-enter by being appended as user/message context into the next-step inbox:
while (true) {
const { request, preparedCall } = await this.buildRequest(
turn, step, assembly.tools, system, this.session.deriveMessages(), signal,
)
const assembler = new BlockAssembler()
const chunkSeqs: number[] = []
const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
for await (const chunk of stream) {
signal.throwIfAborted()
chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq)
assembler.push(chunk)
}
// error/aborted finish → agent/request-error waterfall, maybe retry
const message = createAssistantMessage({ content: assembler.blocks(), source: { provider: request.provider, model: request.model } })
this.session.append('assistant/message', { turn, step, message }, { surfaceOp: 'append', sourceEventSeqs: chunkSeqs })
if (finish.kind === 'max-tokens') return { kind: 'max-tokens' }
const toolCalls = message.content.filter(block => block.type === 'tool-call')
if (toolCalls.length === 0) return { kind: 'completed' }
const { concluded } = await executeToolCalls(this.loopCtx, turn, step, toolCalls, signal,
context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]))
return concluded ? { kind: 'completed' } : null
}The model call itself rides the ctx.llm.stream() / prepareCall() seam (documented on model-selection.md); every observable fact is appended to the session log first, so replay is a pure re-derivation.
Where tool results re-enter
executeToolCalls (in packages/core/agent-loop/src/tool-calls.ts) is the bridge back into model history. It plans one ToolExecutionInput per assistant tool-call block, dispatches through the scoped tool registry in model order, and commits each result. The commit linked to its call event is what the next step derives from:
function appendToolResult(session, turn, step, block, result, callSeq) {
const message = createToolResultMessage({ callId: block.id, content: result.content, isError: result.isError })
session.append('tool/result', {
turn, step, message,
...result.error?.info ? { error: result.error.info } : {},
...result.meta !== undefined ? { meta: result.meta } : {},
}, { surfaceOp: 'append', sourceEventSeqs: [callSeq] })
}The sourceEventSeqs: [callSeq] cites the recording tool/call event — that adjacency is what replay needs. A tool result may carry concludesTurn, which ends the turn at its step; otherwise the returned additionalContexts are spliced into the next-step inbox so the following step reads them as user/message context. See tool-presentation.md for the full pipeline.
Loop termination policies
A turn ends when TurnEndReason says so. The durable reasons (from packages/core/session/src/types.ts):
| Reason | Meaning |
|---|---|
completed | All steps finished normally; no live tool calls remain and no fresh steering was submitted |
max-tokens | At least one step hit its output-token ceiling; sticky across an otherwise-completed later step |
aborted | A cancellation request interrupted the live turn (carries a TurnEndCancelCause) |
blocked | The first proposed step was rejected by agent/pre-step |
error | A step/turn failed; always a structured LlmFailure (UNKNOWN code flattens non-LlmError throws via errorChain) |
interrupted | Persistence closed a crash-orphaned turn on reload — never emitted by the loop |
The only loop-driven early-stop of a tool loop is data, not policy: a tool/result whose concludesTurn is true ends the turn at that step. The inverse hook is agent/turn-stopping, a serial listener that runs before the boundary commits and may steer fresh work in.
Error handling
Failures are funneled through throwError and reported live at agent/error before routing to driver containment. Model-request failures enter the agent/request-error waterfall, where a listener may return { kind: 'retry' } (the default undefined is terminal). The LlmError keeps its structured facts verbatim in the durable turn/end; anything else flattens to text under the UNKNOWN code. Cancellation is contained: abort records synthetic error results for skipped tool calls so replay stays valid.
Sessions and scope
The loop appends directly to this.session (the append-only SessionEvent log) and derives history on demand via this.session.deriveMessages() — it never carries transcript state of its own. It runs each driver inside ctx.agents.withInitiator(), so extension code reached from the loop can read the originating Agent. Its scope is createScope(loopCtx, this) — the live ReactLoopAgent object is both the scope key and the initiating agent, and every assembly passes scope: agent (via assembleContextFor).
Further reading
- Agent core — the public
Agenthandle, registry, andagent/*events the loop implements. - Session management — the
SessionEventlog the loop writes and derives from. - Scope system — the two-level scoped registration the loop builds per-agent scoping on.
- System prompt assembly — what
preSteprenders as the request prefix. - Model selection — how the loop picks and freezes provider/model per request.
- Repo source:
packages/core/agent-loop/src/agent.ts,packages/core/agent-loop/src/tool-calls.ts.