Skip to content

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.

PackageOwns
@deepseek-ai/dsh-agentThe Agent interface, live registry, agent/* events, inbox, initiator scope (ctx.agents)
@deepseek-ai/dsh-agent-loopThe concrete driver, tool-call scheduler, agent factory (ctx.agentLoop; the factory registers via ctx.agents.setFactory)
@deepseek-ai/dsh-sessionThe append-only SessionEvent log (ctx.sessions)
@deepseek-ai/dsh-system-promptPrompt 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:

ts
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:

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:

ts
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:

ts
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:

ts
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):

ReasonMeaning
completedAll steps finished normally; no live tool calls remain and no fresh steering was submitted
max-tokensAt least one step hit its output-token ceiling; sticky across an otherwise-completed later step
abortedA cancellation request interrupted the live turn (carries a TurnEndCancelCause)
blockedThe first proposed step was rejected by agent/pre-step
errorA step/turn failed; always a structured LlmFailure (UNKNOWN code flattens non-LlmError throws via errorChain)
interruptedPersistence 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 Agent handle, registry, and agent/* events the loop implements.
  • Session management — the SessionEvent log the loop writes and derives from.
  • Scope system — the two-level scoped registration the loop builds per-agent scoping on.
  • System prompt assembly — what preStep renders 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.