The Agent Loop: One Message, One Step, One Log
Eight stops tracing a single user message from the inbox through a model call, a tool dispatch, and back into the log
What you will learn
- How input reaches the driver through one inbox and how a turn is opened and closed
- What a "step" is (one model request plus its tool calls) vs. a "turn" (zero or more steps)
- How the `agent/pre-step` waterfall lets plugins rewrite or reject the claimed messages
- How the loop derives model history from the log before it builds the request
- How `agent/request` proposes the provider/model and the LLM seam streams the reply
- How a tool call is dispatched through `tools/pre-execute → tools/execute → tools/post-execute`
- Why "model-visible means logged" is enforced by a runtime invariant
Prerequisites
- You can read TypeScript and basic async/await
- The Mental Model and Boot tours are recommended first (they define the `ctx` keys and the plugin tree)
All Input Flows Through One Inbox
packages/core/agent-loop/src/agent.ts:114send inserts into a single inbox; followup, steer, and inject differ only in target and whether they wake the driver
There is one inbox. A message either lands in next-turn (starts a new turn) or next-step (joins the current one). The two flags are target and wakeup: followup and steer wake the driver immediately; inject only queues context and waits for another message to do the waking. That single queue is why steering, follow-up, and injected context do not need separate delivery paths.
No separate inbox per feature. One queue, two targets, three verbs. ---
// Waking input cannot join an aborted activity, so it starts the next turn.
// Captured before the insertion so a reentrant cancel from a splice observer cannot reclassify it.
const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted
const resolvedTarget = wakingAfterAbort ? 'next-turn' : target
this.inbox.splice(resolvedTarget, Infinity, 0, [message])
if (wakeup) this.wakeDriver(wakingAfterAbort)
}
followup(input: UserMessage): void {
this.send(input, 'next-turn', true)
}
steer(input: UserMessage): void {
this.send(input, 'next-step', true)
}
inject(input: UserMessage): void {
this.send(input, 'next-step', false)
}A Turn Is Opened Before Its First Step
packages/core/agent-loop/src/agent.ts:245turn() appends turn/start, then enters a step loop; a reject or empty first claim still closes a durable turn
A turn is a durable fact: turn/start is appended to the log before any step. The loop then repeatedly prepares a step. Two important edge cases are visible here. If pre-step rejects, the turn ends blocked — but the turn/start is already logged, so the _attempt_ survives a reload. If the first claim is rewritten empty, it still "spends no step" yet still closes a durable turn.
A turn is the log's unit. Even a turn that does no work is a turn in the log. ---
/** Open one turn before claiming its first proposed step. */
private async turn(): Promise<boolean> {
if (this.phase.kind !== 'running') {
this.throwError(new Error(`agent "${this.id}": turn without driver reservation`))
}
const phase = this.phase
const { signal } = phase.abort
signal.throwIfAborted()
const turn = phase.turn + 1
try {
this.session.append('turn/start', { turn })
} catch (error: unknown) {
this.throwError(error)
}
phase.turn = turn
let turnEnds: TurnEndReason | null = null
let target: InboxTarget = 'next-turn'
try {
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
}`pre-step` Decides What the Model Sees
packages/core/agent-loop/src/agent.ts:225agent/pre-step is a waterfall: plugins can rewrite the claimed messages or reject them outright
Three things happen here. First, the claimed message is taken from the inbox. Second, the system prompt is assembled from every registered section. Third, the agent/pre-step waterfall runs: defaults to "enter with context appended", but any plugin can rewrite the claimed messages or reject the step. This is the extension point for context policies, system prompts, and tool schemas.
agent/pre-step is where every "what should the model see this step?" decision is made. It is the most important waterfall in the loop.
---
private async preStep(target: InboxTarget, position: { turn: number; step: number }): Promise<PreparedStep> {
/* v8 ignore next -- private callers establish the running phase before proposing a step */
if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": pre-step outside running phase`)
const signal = this.phase.abort.signal
const claimed = this.inbox.claim(target, position.turn)
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
signal.throwIfAborted()
const sections = renderContextSections(assembly)
const context = this.runtimeContext.project(joinContextSections(sections), sections)
const decision = await this.dispatch.waterfall(
'agent/pre-step', { messages: claimed, ...position, signal },
(): Promise<PreStepDecision> => Promise.resolve<PreStepDecision>({
kind: 'enter',
messages: context === undefined ? claimed : [...claimed, context],
}),
)
signal.throwIfAborted()
return decision.kind === 'reject' ? decision : { ...decision, assembly }
}Prompt and Messages Are Assembled From the Log
packages/core/agent-loop/src/agent.ts:335step() derives the model history from the session, then streams from ctx.llm
The model history is not a local variable — it is derived from the session log (this.session.deriveMessages()). The system prompt is rendered from the assembled sections. The request is then streamed from the LLM seam (ctx.llm.stream). Every chunk is appended to the log as assistant/chunk. This is the heart of "model-visible means logged": the loop never holds a message that it does not also put in the log.
The loop is stateless. Its state lives in the log, and every model-visible fact is a log entry. ---
const { turn, step, abort: { signal } } = this.phase
signal.throwIfAborted()
const system = renderPrompt(assembly)
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)
signal.throwIfAborted()
for await (const chunk of stream) {
signal.throwIfAborted()
chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq)
assembler.push(chunk)The Request Is Proposed, Prepared, and Frozen
packages/core/agent-loop/src/agent.ts:438agent/request proposes the provider/model; llm/prepareCall resolves an adapter; the resulting config is frozen
The provider and model are not hard-coded. The agent/request waterfall proposes them (defaulting to the agent's declared route), and the LLM seam's prepareCall resolves the adapter and any model defaults. If the adapter is missing, the loop can still proceed if a middleware served an unregistered route — that is the NO_ADAPTER escape hatch. The resulting config is what the invariant below checks.
The provider/model is a request, not a constant. agent/request is the extension point that lets one agent run on a different model than another, on the same session.
---
const proposedConfig = await this.dispatch.waterfall(
'agent/request', { turn, step, signal },
() => Promise.resolve(seedConfig),
)
signal.throwIfAborted()
if (!proposedConfig.provider || !proposedConfig.model) {
throw new Error(`agent "${this.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`)
}
let config: LlmCallConfig
let preparedCall: PreparedLlmCall | undefined
try {
preparedCall = await this.loopCtx.llm.prepareCall(proposedConfig, signal)
config = preparedCall.config
} catch (error: unknown) {
// Middleware may serve an unregistered route; terminal dispatch still requires an adapter.
if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error
config = proposedConfigThe LLM Seam Streams, the Loop Appends
packages/core/agent-loop/src/agent.ts:347llm/stream is the only call to the model; every chunk lands in the log as assistant/chunk
This is the entire bridge to the model. The loop does not know which provider is on the other end — it calls ctx.llm.stream and reads chunks. The adapter's streaming is transparent. Each chunk is appended to the log before it is passed to the assembler. The chunkSeqs array is then used to link the assistant/message to its chunks, so a replay can reconstruct the exact stream.
The loop is provider-agnostic. One call site (ctx.llm.stream) covers every model.
---
for await (const chunk of stream) {
signal.throwIfAborted()
chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq)
assembler.push(chunk)
}
signal.throwIfAborted()Tool Calls Dispatch Through the Pipeline
packages/core/tools/src/index.ts:142The three waterfalls that gate every tool call: tools/pre-execute, tools/execute, and tools/post-execute
Each tool call traverses three waterfalls. pre-execute decides allow/deny/ask _before_ dispatch. execute is the around-dispatch for timeout, retry, and metrics. post-execute decides what to do with the result — accept, replace, or block. A fourth event, tools/result, is a terminal notification. The loop itself never calls a tool directly; it dispatches to the registry, which runs the pipeline.
Tools are not "called". They are dispatched through a three-stage pipeline that plugins can gate at each stage. ---
interface Events {
/**
* Allow, deny, or ask before dispatch. `next()` delegates to allow; missing
* approval support turns `ask` into denial. Async gates must observe
* `exec.signal`; the registry rechecks cancellation after they settle but
* never abandons their promise.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
* @param exec - the pending call (name, parsed arguments, caller agent).
* @mode waterfall
*/
'tools/pre-execute'(this: Scoped<ToolRuntime>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
/**
* Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns
* a normalized result; wrappers may change only `exec.signal`, while call
* identity remains immutable. The registry re-fuses the original caller
* signal before the body, so replacement cannot detach caller cancellation;
* wrappers must still restore their signal and reach quiescence.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
* @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
* @mode waterfall
*/
'tools/execute'(this: Scoped<ToolRuntime>, exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
/**
* Accept, replace, enrich, or block a normalized dispatch result. `next()`
* accepts it unchanged; thrown tools still reach this waterfall as errors. Async
* listeners must observe `exec.signal`; after they settle, caller
* cancellation replaces only a successful accepted outcome with the code
* selected by whether the tool body was invoked.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
* @param exec - the call that just ran (name, parsed arguments, caller agent).
* @param result - the dispatch outcome a listener may accept, replace, or block.
* @mode waterfall
*/
'tools/post-execute'(this: Scoped<ToolRuntime>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
/**
* Allow a listener to replace content in the DURABLE LOG COPY of one
* `run_code` sub-dispatch outcome before the bridge appends its
* `tool/code-dispatch` event. `next()` keeps the
* content unchanged; a listener may return replacement blocks (e.g. the
* spill policy's preview + locator for an oversized text result). Only the
* logged copy is affected — the program already received the complete
* value, and the model sees neither. A throwing listener is contained:
* the bridge falls back to logging the original settled content.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches.
* @param dispatch - the parent execution, sub-call identity, and the settled content to log.
* @mode waterfall
*/
'tools/code-dispatch-log'(this: Scoped<ToolRuntime>, dispatch: CodeDispatchLog, next: () => Promise<ContentBlock[]>): Promise<ContentBlock[]>
/**
* Observe the frozen, lossless-JSON final outcome. Listener failures are contained.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`.
* @param exec - the execution object that traversed the pipeline.
* @param result - a deep-frozen snapshot of the final returned result.
* @mode emit
*/
'tools/result'(this: Scoped<ToolRuntime>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): undefinedModel-Visible Means Logged — Enforced
packages/core/agent-loop/src/invariant.ts:18A runtime invariant asserts that every loop-built request is reconstructable from the log
This is the architectural bet made enforceable. The invariant listens on llm/stream (the very seam the loop uses) and checks that the request is frozen, carries a live session id, and — critically — that options.messages exactly matches session.deriveMessages(). If a plugin adds a model-visible input without a new session event, the invariant fails.
"Model-visible means logged" is not a convention. It is a runtime invariant that fires on every model call. A plugin that bypasses the log is a bug the system will refuse to run. ---
/** Install the request-reconstruction contribution into its child registration fiber. */
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
// Prepend prevents a short-circuiting replay listener from silencing the check.
ctx.on('llm/stream', (options: GenerateOptions, next) => {
if (!isAgentLoopRequest(options)) return next()
if (!Object.isFrozen(options)) fail('a loop-built request must be frozen')
if (options.sessionId === undefined) fail('a loop-built request must carry a session id')
const session = ctx.sessions.get(options.sessionId)
if (!session) fail(`a loop-built request must carry a live session id, got "${String(options.sessionId)}"`)
if (!Object.isFrozen(options.messages)) {
fail('a loop-built request must carry a frozen messages array')
}
const events = session.events
if (!events.some(event => event.type === 'step/start')) {
return fail('a loop-built request with no step/start in its session log')
}
const header = foldRequestHeader(events)
if (header === undefined) {
return fail('a loop-built request with no request/header event in its session log')
}
const expected = session.deriveMessages()
if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {You've walked through 8 key areas of the DeepSeek Harness codebase.
Browse all projects →Create code tours for your project
Intraview lets AI create interactive walkthroughs of any codebase. Install the free VS Code extension and generate your first tour in minutes.
Install Intraview Free