The Turn / Step / Loop State Machine
From input claims through model calls and tools to turn stop points
Conclusion: the loop is small, but it guards event order and reconstructability
The default loop owns one semantic spine: claim input → open Turn/Step → reconstruct a request from the log → call the model → persist stream and completion → execute tools → continue or stop. Compaction, permission, retry policy, planning, and multi-Agent behavior participate through plugin seams instead of rewriting this spine.
“Small” does not mean simple. Its value is concentrated in exact commit order: every model request actually dispatched can be independently reconstructed from the Session log immediately before dispatch.
1. Do not conflate four levels
| Level | Begins when | What it owns | Ends when |
|---|---|---|---|
| Driver activity | Waking input starts from idle | One AbortController, initiator scope, and the drain of consecutive queued turns | No continuable work remains, or failure/cancellation converges |
| Turn | The driver calls turn() | Durable accounting for a unit of work, from turn/start to one turn/end | completed / max-tokens / blocked / aborted / error |
| Step | Pre-step accepts a non-empty first batch, or tool continuation requires another request | One model call and its tool batch, from step/start to step/end | Model completion, tool continuation, tool conclusion, or exception |
| Request attempt | buildRequest() runs inside a Step | Frozen route/system/tools/messages; provider failures may retry inside the same Step | Successful finish or an unrecovered failure |
The official sequences separate turns, steps, prompt assembly, LLM streaming, tool settlement, and stop points. Turn/step boundaries are durable SessionEvents rather than a mirrored agent/* stream. ARCH-TURNTURN-SEQUENCE
2. The outer driver only loops, contains, and converges
wakeDriver() synchronously changes the phase to running and starts kick() inside ctx.agents.withInitiator(agent, ...). kick() only performs while (await turn()); reported failures and cancellation are contained there, then phase returns to idle and replays a wake latched during cancellation convergence. LOOP-DRIVER-PRESTEP
One running interval may execute several queued turns. If the Inbox still has pending work after a turn ends, the loop installs a fresh AbortController, resets the step counter, and continues. It exits the driver only when no work remains.
An exception does not destroy the Agent object. Failure belongs to the current turn, the driver converges to idle, and a later wake may start a new turn. Loop survival and work success are deliberately separate facts.
3. A Turn opens before the system knows whether it will have a Step
Append turn/start
The turn number is the previous turn plus one and becomes durable before any input is claimed.
Propose the first pre-step
The target is next-turn: claim every next-step item plus one next-turn prompt, assemble prompt/context, and enter the waterfall.
Possibly open a Step
Only an accepted branch requiring a model call appends step/start, commits accepted user messages, and calls step().
Close every opened Step
step/end is appended in finally, so an opened Step has a terminal boundary even if the model, a tool, or a plugin fails.
Offer turn-stopping
When a Step has a terminal result and no next-step input is pending, serial listeners get one last chance to steer another Step.
Append turn/end
Except for failures earlier than a successful turn/start append, an opened Turn records exactly one ending in finally.
If the first pre-step is rejected, the Turn ends blocked with no step/start, user/message, or model request. If the first decision enters with no messages, the Turn ends completed without opening a Step. LOOP-TURN-STATE
Tests prove that a blocked prompt leaves only turn boundaries, an empty post-completion pre-step opens no phantom Step, and inject/steer arriving after the claim cannot enter the current request but remain for the next Step or wake. LOOP-INTERCEPTION-TEST
4. Pre-step order determines context ownership
preStep() atomically claims the Inbox, assembles the Agent's system prompt and tool schemas, renders dynamic context sections, asks RuntimeContextProjection whether a new snapshot message is needed, and finally invokes the agent/pre-step waterfall. The default decision combines claimed messages with a changed runtime-context message. LOOP-DRIVER-PRESTEPLOOP-RUNTIME-CONTEXT
This produces three important semantics:
- Claim is the exclusive boundary. Messages arriving while the waterfall runs remain in the Inbox and are not part of the frozen proposed batch.
- Prompt assembly precedes
agent/pre-step. A listener may change messages entering history, but the proposed position and cancellation signal are already established. - Dynamic runtime context is not a hidden string spliced into a request. When it changes, it becomes a candidate UserMessage with a plugin source and is logged with the accepted batch.
A plugin can reject, wrap, or extend a Step, but it must accept that claiming already occurred. Rejection does not automatically put the prompt back. The durable blocked Turn accounts for work consumed without reaching the model.
5. A Step is one reconstruct-request, stream, and tool-settlement cycle
A Step renders system text from that assembly and calls session.deriveMessages() at each request attempt. Every streamed chunk is immediately appended as assistant/chunk and fed to BlockAssembler. Every successful finish writes an assistant/message completion anchor—even with empty content—with usage, provenance, and exact sourceEventSeqs. LOOP-STEP-STATE
| Model finish | Step returns | Tool behavior | Turn tendency |
|---|---|---|---|
| Normal, no tool call | completed | None | End after turn-stopping |
max-tokens | max-tokens | Do not dispatch even if content contains tool calls | Reason is sticky within this Turn |
| Normal, tool calls, not concluded | null | Execute and persist results | Open another Step so the model reads results |
| Normal, tool calls, concluded | completed | Execute and persist results | May end immediately unless steering is pending |
A simple-turn test proves strict boundary order—turn/start → step/start → step/end → turn/end—with Inbox receipt earlier. A tool test proves call/result enter the Session log and the next derived request in the same Turn. LOOP-ORDER-TEST
6. A Request is a foldable log snapshot, not an opaque memory object
Seed route proposal
The first Step starts from AgentOptions. Later Steps remove fields marked adapter-derived from the latest header so a new route can resolve its own defaults.
agent/request waterfall
Plugins may return provider/model/temperature/maxTokens/stop and related config; both route fields must exist before dispatch.
Prepare the exact adapter call
llm.prepareCall() validates adapter-owned fields, materializes exact-model defaults, and freezes the adapter registration and retry policy.
Commit request/header
The canonical header contains effective config, adapter-default markers, system, and tools. A loop instance writes initial/resume first, then change snapshots only when needed.
Commit request/context
A capacity record is added only when provider, model, or contextWindow changes.
Freeze the dispatch object
Construct and mark the loop request from durable-derived messages, header system/tools, sessionId, and the turn signal.
The prepared call retains the exact adapter registration that resolved defaults, preventing HMR from combining one adapter's capability decision with another adapter's dispatch. An unregistered route temporarily keeps the proposal so llm/stream middleware may take full ownership; unhandled terminal dispatch still fails with NO_ADAPTER. LOOP-REQUEST-BUILD
The invariant companion checks only loop-marked requests: request and messages must be frozen, the Session must be live, and step/start plus request/header must exist. It then independently compares session.deriveMessages() and the folded header, reporting any mismatch as reconstruction desynchronization. LOOP-REQUEST-INVARIANT
Across tool Steps, later Turns, system-prompt changes, and request-config changes, the test replays the pre-dispatch event prefix into a new Session and proves field-by-field equality with the real messages and header. LOOP-RECONSTRUCTION-TEST
7. End reasons are durable accounting, not merely UI state
| TurnEndReason | Trigger | May have a Step? | Meaning for claimed work |
|---|---|---|---|
completed | Normal finish, a concluding tool, or an empty first batch | Yes or no | Work settled through a normal boundary |
max-tokens | Any Step reaches the output cap | Yes | The truncation fact remains even if a forced later Step succeeds |
blocked | Pre-step rejection | No | Claimed input was consumed without reaching the model |
aborted | The turn signal is aborted by user/parent/disposal cause | Possibly | Records cancellation source; started effects still drain |
error | Unrecovered model, plugin, or tool failure | Possibly | LlmError retains structured failure; other errors become UNKNOWN + errorChain |
Mid-stream cancel records aborted with its cause. Max-tokens is sticky inside one Turn even if turn-stopping forces a successful next Step, but does not contaminate the next Turn. Tool calls from a max-token-truncated finish do not execute. LOOP-OUTCOME-TEST
8. Recovery owns model-request failures, not every exception
When BlockAssembler produces an error/aborted finish, the loop invokes the agent/request-error waterfall with turn/step, provider, structured failure, the prepared registration's immutable retryPolicy, and signal. Only {kind:'retry'} rebuilds and resends within the same Step; otherwise the loop throws LlmError. LOOP-STEP-STATE
An agent/request middleware throw bypasses request recovery. Provider failures may be repeatedly recovered by one listener. If a listener cancels and also returns retry, cancellation wins. A recovery listener that fails ends the Turn in error. LOOP-REQUEST-ERROR-TEST
Other failures—prompt rendering, pre-step plugins, tools/result processing, and request middleware—go directly to the Turn error boundary. throwError() first emits live agent/error; Turn finally records the durable error reason; the driver contains the reported error and returns to idle. LOOP-ERROR-CONTAINMENT-TEST
9. Extension seams decide which layer owns a capability
| Extension need | Owning seam | Why it is outside the loop |
|---|---|---|
| Compaction trigger / overflow repair | agent/pre-step / agent/request-error | Context policy, not mechanical Turn structure |
| Model route and request config | agent/request | Provider selection is replaceable and must enter the logged header |
| Retry wait and policy | agent/request-error | The loop understands a retry action, not backoff policy |
| Tool permission, approval, and sandboxing | tools/pre-execute / guards / post / result | Per-call governance must compose across plugins |
| Force continuation or termination | agent/turn-stopping | The loop exposes its natural stop point; policy may steer or cancel |
| Persistence, UI, and telemetry | session/event | The durable fact is already shared; the loop need not invoke each consumer |
The default loop has no built-in turn budget. Tools or steering can keep producing Steps. A deployment that needs runaway-turn limits must count and cancel at an existing seam. This is deliberate “mechanism in core, policy in plugins,” and a risk control operators must actively provide.
10. Benefits, costs, and semantic boundaries
| Choice | Benefit | Cost |
|---|---|---|
| Durable Turn/Step boundaries | Failure, cancellation, and tool continuation are replayable and attributable | Even no-step and blocked work adds lifecycle events |
| deriveMessages on every Step | No hidden conversation buffer; resume and replay are isomorphic | Event-vocabulary and projection compatibility become core burdens |
| Request-header snapshots | Prompt, tools, route defaults, and model capacity are explainable | Canonicalization and default markers must remain exact for cache and replay |
| Raw chunks plus completion anchor | UI fidelity and canonical history coexist | Larger logs and explicit chunk-to-message provenance |
| Narrow request-error recovery | Retry only repeats provider requests that are safe to reconstruct | Plugin authors must distinguish request failure from Step/Turn failure |
| Plugin-owned policy | The loop stays stable while compaction, permission, and planning compose independently | Behavior spreads across waterfall ordering, making composition tests essential |
The default loop is not designed as the “Agent brain.” It is an event-commit machine. Its strongest principle is that what the model saw and how work ended become reconstructable log facts. Its largest risk is extension dispersion: correctness no longer belongs to agent.ts alone, but to every waterfall, tool pipeline stage, and Session projection jointly honoring the order contract.
Chapter verification checklist
- Separated driver, Turn, Step, and request attempt.
- Traced turn/start, claim, pre-step, Step boundaries, and turn/end across principal branches.
- Verified reject, empty initial Step, empty continuation, and input arriving after a claim.
- Traced streamed chunks, assistant completion anchors, tool continuation, and concluded tools.
- Verified request proposals, adapter defaults, header/context logging, and frozen dispatch.
- Used the invariant and reconstruction test to verify independent request reconstruction from the log.
- Verified completed, max-tokens, blocked, aborted, and error outcomes.
- Separated request-error recovery from other Step/Turn exceptions.
My Learning Notes
Autosaved only in this browser. Nothing is uploaded or committed. Export Markdown whenever you want to keep a copy.