AgentLoop state machine
Corresponding source
| Subject | Source |
|---|---|
| States, work object, and transition table | nanobot/agent/loop.py::TurnState, TurnContext, AgentLoop._TRANSITIONS |
| State-machine driver | nanobot/agent/loop.py::AgentLoop._process_message |
| Stage handlers | nanobot/agent/loop.py::AgentLoop._state_restore through AgentLoop._state_respond |
| Loop–Runner boundary | nanobot/agent/loop.py::AgentLoop._run_agent_loop; nanobot/agent/runner.py::AgentRunSpec |
Learning objectives
After this lesson, you should be able to:
- explain all eight
TurnStatevalues; - locate the transition table and state handlers;
- explain why normal messages and slash commands follow different paths;
- identify the portion of the complete Turn owned by AgentRunner.
Prerequisites
- Understand Session Keys.
- Read enums, dataclasses, and asynchronous functions.
- Read Messages, Session Keys, and entry points.
1. TurnContext is the workbench for one Turn
Definition:
nanobot/agent/loop.py::TurnContext
It keeps the data produced throughout a Turn on one runtime object:
| Stage | Typical fields |
|---|---|
| Entry | msg, session_key, turn_id |
| Session | session, history, pending_summary |
| Context | initial_messages |
| Runner result | final_content, tools_used, all_messages, stop_reason |
| Streaming and progress | on_stream, on_progress, on_retry_wait |
| Injection | pending_queue, had_injections |
| Output | outbound, suppress_response |
| Diagnostics | turn_latency_ms, trace |
This is a runtime object, not a persistence schema written directly to disk.
2. State transition table
AgentLoop._TRANSITIONS explicitly defines:
stateDiagram-v2 [*] --> RESTORE RESTORE --> COMPACT: ok COMPACT --> COMMAND: ok COMMAND --> BUILD: dispatch COMMAND --> DONE: shortcut BUILD --> RUN: ok RUN --> SAVE: ok SAVE --> RESPOND: ok RESPOND --> DONE: ok DONE --> [*]
Each handler returns an event string. The driver looks up the next state by (current state, event).
DONE is a terminal marker; it does not invoke _state_done(). A normal main path therefore visits all eight enum states but records only the seven actual handlers in ctx.trace. A shortcut command produces a shorter trace.
This design provides:
- observable state boundaries;
- explicit shortcuts;
- relatively focused stage responsibilities;
- duration and error recording in the trace.
3. _process_message() drives the state machine
Its main steps are:
- resolve the Session;
- create
TurnContextwith initial stateRESTORE; - select the handler for the current state;
- execute the handler;
- record a state-trace entry;
- use the transition table to select the next state;
- return the outbound message or direct result after reaching
DONE.
The driver skeleton is visible directly in AgentLoop._process_message():
while ctx.state is not TurnState.DONE:
handler_name = f"_state_{ctx.state.name.lower()}"
handler = getattr(self, handler_name, None)
if handler is None:
raise RuntimeError(f"Missing state handler for {ctx.state}")
event = await handler(ctx)
next_state = self._TRANSITIONS.get((ctx.state, event))
if next_state is None:
raise RuntimeError(
f"[turn {ctx.turn_id}] No transition from {ctx.state} "
f"on event {event!r}"
)
ctx.state = next_stateThis source excerpt removes trace timing and logging. It exposes two constraints: a new enum state needs a matching _state_<name>() handler, and a new handler event needs a transition-table entry. Otherwise the Turn fails explicitly instead of silently taking a default path.
When debugging a Turn, begin in the _process_message() state loop and inspect:
ctx.state;- the event returned by the handler;
ctx.trace;- the first state that raises an exception.
4. RESTORE: Recover state and prepare inbound data
Handler:
AgentLoop._state_restore
Primary responsibilities:
- process media or document references;
- ensure that the Session exists;
- publish
SessionTurnStarted; - persist the message’s workspace scope;
- restore a runtime checkpoint;
- restore a pending user turn.
This stage answers “What might the previous attempt have left, and which runtime scope owns this message?” It does not call the model.
5. COMPACT: Handle stale or pending Session summaries
Handler:
AgentLoop._state_compact
Call:
AutoCompact.prepare_session
It can return an updated Session and a pending summary. Token-triggered consolidation can also occur later during BUILD.
Do not conflate the compression layers
AutoCompact, Consolidator, and Runner’s microcompaction/tool-result budget operate at different levels. The State and Long-Running Work section separates them.
6. COMMAND: Handle slash commands first
Handler:
AgentLoop._state_command
It creates a CommandContext and passes it to the command router.
If the command returns an OutboundMessage:
- the event is
shortcut; - BUILD, RUN, SAVE, and RESPOND are skipped;
- the state transitions directly to DONE;
- ordinary commands still save explicit user/assistant command messages so WebUI can see history after the Turn;
/newis an exception because it intentionally clears the Session.
The important distinction is:
Skipping the general SAVE state does not mean that a shortcut never persists anything.
7. BUILD: Construct this Turn’s runtime input
Handler:
AgentLoop._state_build
Primary responsibilities:
- check token-triggered consolidation for a non-ephemeral Turn;
- set the current ToolContext;
- reset MessageTool state for this Turn;
- obtain budget-constrained history from the Session;
- record the current Provider/model runtime;
- let ContextBuilder construct
initial_messages; - persist the current user message early;
- create progress and retry-wait callbacks.
One fact matters for failure analysis:
The current user message may be persisted before Runner finishes.
Combined with the pending user turn and checkpoint, this prevents a long or interrupted Turn from losing the user’s input completely.
8. RUN: Invoke AgentRunner
Handler:
AgentLoop._state_run
Primary responsibilities:
- record the start of visible execution;
- publish
"running"status; - call
_run_agent_loop(); - receive final content, tools used, complete runtime messages, stop reason, and injection state;
- handle a possible continuation.
_run_agent_loop() converts current configuration into an AgentRunSpec containing:
- initial messages;
- ToolRegistry;
- model;
- iteration limit;
- tool-result limits;
- timeout;
- stream/progress callbacks;
- checkpoint/injection callbacks;
- goal predicate;
- finalization policy.
9. SAVE: Persist the run result to Session
Handler:
AgentLoop._state_save
Primary responsibilities:
- handle continuation persistence boundaries;
- provide a standard empty-response message when a response must not be empty;
- calculate latency;
- save messages added during this Turn;
- record runtime latency;
- enforce the Session file-size limit;
- schedule consolidation in the background;
- clear the pending user turn;
- clear the runtime checkpoint;
- save the Session.
10. RESPOND: Construct the external response
Handler:
AgentLoop._state_respond
If the Turn suppresses its response, no OutboundMessage is created. Otherwise, _assemble_outbound():
- chooses the Channel and
chat_id; - uses final content;
- records whether content was already streamed;
- attaches latency;
- avoids a duplicate response when MessageTool already sent the message proactively.
Data changes in a normal Turn
flowchart TD M["InboundMessage"] --> R["RESTORE<br/>Session + checkpoint"] R --> C["COMPACT<br/>pending summary"] C --> CMD["COMMAND<br/>normal message continues"] CMD --> B["BUILD<br/>history + initial_messages<br/>persist user early"] B --> RUN["RUN<br/>AgentRunResult"] RUN --> S["SAVE<br/>append assistant/tool<br/>clear checkpoint"] S --> O["RESPOND<br/>OutboundMessage"]
Observation task
In the _process_message() state loop, record:
| state | Observe |
|---|---|
| RESTORE | ctx.session.metadata |
| COMPACT | ctx.pending_summary |
| COMMAND | whether the event is dispatch or shortcut |
| BUILD | ctx.history, ctx.initial_messages |
| RUN | ctx.stop_reason, ctx.tools_used |
| SAVE | which messages were added to the Session |
| RESPOND | ctx.outbound.metadata |
Send a normal message and /help, then compare their visited states.
Source and tests
nanobot/agent/loop.pyTurnStateTurnContext_TRANSITIONS_process_message_state_*
tests/agent/test_loop_runner_integration.pytests/agent/test_loop_save_turn.pytests/agent/test_stop_preserves_context.pytests/command/test_router_dispatchable.py
Self-check
- Why does COMMAND precede BUILD?
- Why can a shortcut command still save history?
- Why is the user message persisted before Runner completes?
- In which state does AgentRunner’s result enter the Session?
- Why might RESPOND omit a normal reply after MessageTool has already sent one?