Architecture · 06

Agent Loop and Session Events: How One Turn Runs

Trace queued input through turns, steps, model streaming, ordered tool results, durable replay, SDK observation, cancellation, and recovery.

Reading time
18 minutes
Sources verified

Define turn, step, and activity before reading events

A turn is the outer unit of owed work. It opens before queued input is claimed and can contain zero or more steps. A step is one model request plus every tool execution requested by that response. A text-only answer commonly uses one step. A model that calls tools produces a step for the first request and its tools, then another step after tool results become model-visible. A rejected first input can close a durable turn without opening any step.

This distinction matters operationally. ‘The agent is running’ is live status, not proof that a model request began. ‘A turn exists’ is not proof that a step spent tokens. ‘A step ended’ is not necessarily the end of the turn because tools or next-step input can require another request. Diagnose with identified turn and step numbers rather than UI spinner duration.

text
Turn 1
  turn/start
  Step 1: step/start → request → tool calls/results → step/end
  Step 2: step/start → request → final response → step/end
  agent/turn-stopping
  turn/end
  • Turn: zero or more model-request steps until nothing is owed.
  • Step: one provider call plus its requested tool executions.
  • Activity interval: an SDK collection window from accepted input through the next whole-agent idle.
  • Session: the append-only lifetime that can contain many turns.

Follow input through the live inbox before it becomes durable history

followup(content) inserts work into one Agent inbox. Live agent/inbox/spliced and agent/inbox/inserted notifications describe queue changes. Queued work wakes the driver, which publishes agent/status running and appends durable turn/start. The driver claims pending next-step input plus one queued prompt. Claim notifications are live coordination facts; the message becomes model-visible only after pre-step admits it and the loop appends user/message.

Injected context and steering share the inbox. Some messages wake the driver immediately; injected context can wait until another message does. When the driver claims a batch, agent/pre-step is the authoritative waterfall. Listeners may reject it or return enter(messages), potentially rewriting the input. A rejected or rewritten-empty first claim closes the open durable turn without a step, preserving evidence that work was attempted without pretending a provider request occurred.

text
followup(prompt)
→ live agent/inbox/inserted
→ durable turn/start
→ live agent/inbox/claimed
→ agent/pre-step waterfall
   reject → durable turn/end, no step
   enter  → durable step/start + user/message

Trace request construction as logged, reconstructable state

After step/start, each admitted message is appended as user/message. The loop derives model history from the session log, assembles registered prompt sections and tool schemas through system-prompt/assemble, then enters agent/request and llm/stream waterfalls. The adapter streams StreamChunk values; every raw chunk becomes assistant/chunk. A successful provider call concludes with assistant/message, including content-less and max-token finishes.

Request facts outside derived chat history are durable too. request/header stores the full EpochHeader: provider/model call configuration, adapter-materialized defaults, rendered system prompt, and assembled tool schemas. Initial and resume boundaries write a snapshot; later changes write another. request/context separately records route and capacity changes. This supports the architecture invariant that anything model-visible must be reconstructable from the log.

text
step/start
user/message*
request/header (initial | resume | change)
request/context when route/capacity changed
agent/request → llm/stream
assistant/chunk*
assistant/message
typescript
const latest = foldRequestHeader(session.events)
// latest.config: provider, model, reasoning and sampling facts
// latest.system: exact rendered system text, when present
// latest.tools: exact assembled schemas, when present

Place tool execution in exact model and result order

After assistant/message, the driver classifies pending tool calls by executionMode. Calls can start under barriers and a bounded rolling pool, with classification checked again before start. Before a call executes, the driver appends durable tool/call containing callId, name, and the raw argument JSON exactly as the model produced it. The tools service runs ordered pre-execute policy, concurrent body execution where permitted, and ordered post-execute processing.

Results are appended in model order as tool/result even when bodies finish in a different physical order. Each result carries its model-facing message, optional stable internal error identity, and optional JSON-serializable presentation metadata owned by the tool. The next model request derives these results from the log. step/end follows tool processing; if tools owe another request, the next step begins.

text
assistant/message requests calls A, B, C
record tool/call A/B/C in model order
pre-execute in order
execute concurrently when modes permit
body completion may be B, C, A
post-execute and tool/result commit A, B, C
next request sees deterministic model order
json
{
  "type": "tool/result",
  "turn": 1,
  "step": 1,
  "callId": "call-1",
  "message": { "role": "tool", "content": "..." },
  "error": { "name": "ToolError", "code": "DENIED" }
}
  1. Queued input receives a durable inbox receipt before the activity interval is collected.
  2. A turn contains one or more steps; each step contains one model request and its tool batch.
  3. Tool calls may execute concurrently, but results are committed in model order.
  4. The turn closes before the Agent reports idle.
Simplified successful Agent turn. Rejection, request-error recovery, and continuation branches remain described in the adjacent text. Official source ↗

Separate durable session facts from live control events

Session events are appended facts broadcast through session/event. turn/*, step/*, user/message, assistant/*, tool/*, request headers, and plugin-extended durable records survive reload. Agent events carry a live Agent: inbox movement, status, pre-step interception, request construction, continuation, steering, and errors. Capability events attach policy around seams such as tools. They may affect execution without themselves becoming transcript facts.

Use durable events when a fact must support replay, resume, transcript, telemetry, or a later projection. Use agent/* for current coordination and interception. SDK users who need a replayable transcript should consume session/event. A status notification can tell a UI to animate now, but cannot reconstruct history tomorrow. Conversely, replaying turn/start does not mean an agent process is currently running.

text
Durable / session/event
  turn/start, step/start, user/message, assistant/chunk,
  assistant/message, tool/call, tool/result, step/end, turn/end

Live / agent/*
  inbox insertion/claim, status, pre-step, request,
  request-error, steering, continuation

Derive views from events instead of storing a second truth

A Session is an in-memory append-only log of lossless JSON events with contiguous sequence numbers. Message history is derived, never maintained as a separate mutable array. Raw assistant/chunk events preserve streaming and UI fidelity; assistant/message provides the assembled message used by history. Projection units fold specialized state such as permissions or todos. Persistence backends store the same event vocabulary.

Replay means running the same derivations over persisted events. Resume seeds a new in-memory Session with history and appends session/end-seed as the boundary between seed and new lifecycle work. Forking selects a source boundary and constructs a child history. Consumers must not confuse an old unmatched bracket before end-seed with currently live work.

bash
cp /path/to/session.jsonl /tmp/session.inspect.jsonl
wc -l /tmp/session.inspect.jsonl
head -n 8 /tmp/session.inspect.jsonl
# Inspect a copy; never delete or reorder canonical rows.
text
persisted events → load seed → session/end-seed
                 → fold messages/header/projections
                 → append new live turn events
                 → persist contiguous new sequence

Interpret SDK run results without inventing prompt causality

The high-level SDK DeepSeekHarness owns a runtime subprocess and run() collects one activity interval. It queues the prompt, waits until the MessageId appears in a durable inbox receipt, then collects through the next whole-agent idle. RunResult contains sessionId, finalResponse, root-session events, and notifications. Notifications can include descendant sessions discovered from subagent.started, while events contains root-session events.

finalResponse is the last committed root-session assistant text within the interval, not a response causally assigned only to the submitted prompt. Steering, injected context, or already queued work can contribute before idle. The result has no prompt-level success status or turn reason. Transport loss, timeout, and protocol violations reject the SDK call; model outcomes remain in events and must be interpreted there.

typescript
const result = await harness.run('inspect the repository', {
  sessionId: 'review-001',
  onNotification(notification) {
    // live and descendant notifications, in wire order
  },
})
console.log(result.sessionId, result.finalResponse)
for (const event of result.events) inspectDurableEvent(event)
text
Do not assert: finalResponse belongs exclusively to my prompt.
Do assert: my MessageId received a durable inbox receipt,
           the collection reached whole-agent idle,
           and the event interval contains the expected turn/tool facts.

Locate errors and cancellation at their actual lifecycle boundary

A final adapter or terminal in-band request failure closes the step, then enters the agent/request-error waterfall. Listeners can return a retry action or preserve the original error. Basic compaction uses pre-step for proactive pressure and request-error only for canonical context overflow. Recovery opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.

Cancellation can close a turn or step without normal output. Low-level SDK prompt() only returns an enqueue receipt and does not wait. The current SDK protocol has no mid-turn prompt cancel; abandoning work means closing the runtime. UI state should therefore render durable end reasons and live transport status separately rather than converting every closed stream into a successful assistant response.

text
provider failure
→ step/end
→ agent/request-error waterfall
   retry action after real recovery → fresh retry turn
   no advancing recovery           → preserve original error
→ turn/end with authoritative reason
text
transport timeout/loss → SDK rejects
model/tool outcome     → inspect durable event stream
user closes runtime    → transport shutdown, not prompt-level cancel
pre-step rejection     → turn closes with zero steps

Use a deterministic runbook for stuck or surprising sessions

Begin with the durable bracket: find the latest turn/start, step/start, assistant/message, tool/call/result, step/end, and turn/end. Missing events narrow the phase. Then correlate live notifications captured at the time. A running status without step/start means work is waiting around claim or pre-step. Chunks without assistant/message indicate an incomplete or failed provider call. tool/call without result indicates execution or post-policy interruption.

text
Symptom → boundary
turn/start, no step/start → claim/pre-step rejection or failure
step/start, no chunks     → request assembly/provider transport
chunks, no message        → incomplete stream/finalization
tool/call, no result      → policy/body/post-execute path
step/end, no turn/end     → owed work/turn-stopping/driver interruption

After whenIdle(), consumers that read storage should flush explicitly because the loop does not await persistence flush at every turn boundary; checkpoint policy owns per-request durability. Compare in-memory events with persisted rows only after the proper flush. Preserve sequence numbers and redact message or tool content before sharing diagnostics.

bash
# Work on an exported, redacted copy.
rg '"type":"(turn|step|assistant|tool)/' /tmp/session.inspect.jsonl
tail -n 20 /tmp/session.inspect.jsonl
  • Record session id, runtime revision, profile, provider/model, and permission preset.
  • Preserve the first stable error code and request id.
  • Do not edit canonical JSONL to close an unmatched bracket.
  • Back up representative sessions before upgrading preview event schemas.

Extend the loop without breaking replay or ordering

Choose the event domain first. Durable model-visible or replayable state belongs in SessionEventMap and must remain JSON-serializable. Live interception belongs in agent events with the documented dispatch mode. Capability policy belongs around its seam. Preserve waterfall delegation, turn and step identities, tool model order, and the distinction between raw chunks and assembled messages.

text
Extension review
[ ] Is the fact durable, live, or capability policy?
[ ] If model-visible, can request state be rebuilt from the log?
[ ] Does the event use the declared dispatch mode?
[ ] Are turn/step/call identities preserved?
[ ] Is payload lossless JSON?
[ ] Does replay produce the same projection?
[ ] Do rejection, cancellation, and failure close valid brackets?

Test a normal text response, tool round trip, concurrent tool completion, rejected pre-step, provider error, cancellation, persistence reload, resume, and SDK observation. Developer preview allows incompatible event changes, so pin the revision that produced a log and validate migration or rollback with real session fixtures.

Official sources