AgentLoop state machine

Corresponding source

SubjectSource
States, work object, and transition tablenanobot/agent/loop.py::TurnState, TurnContext, AgentLoop._TRANSITIONS
State-machine drivernanobot/agent/loop.py::AgentLoop._process_message
Stage handlersnanobot/agent/loop.py::AgentLoop._state_restore through AgentLoop._state_respond
Loop–Runner boundarynanobot/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 TurnState values;
  • 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

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:

StageTypical fields
Entrymsg, session_key, turn_id
Sessionsession, history, pending_summary
Contextinitial_messages
Runner resultfinal_content, tools_used, all_messages, stop_reason
Streaming and progresson_stream, on_progress, on_retry_wait
Injectionpending_queue, had_injections
Outputoutbound, suppress_response
Diagnosticsturn_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:

  1. resolve the Session;
  2. create TurnContext with initial state RESTORE;
  3. select the handler for the current state;
  4. execute the handler;
  5. record a state-trace entry;
  6. use the transition table to select the next state;
  7. 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_state

This 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;
  • /new is 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:

  1. check token-triggered consolidation for a non-ephemeral Turn;
  2. set the current ToolContext;
  3. reset MessageTool state for this Turn;
  4. obtain budget-constrained history from the Session;
  5. record the current Provider/model runtime;
  6. let ContextBuilder construct initial_messages;
  7. persist the current user message early;
  8. 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:

stateObserve
RESTOREctx.session.metadata
COMPACTctx.pending_summary
COMMANDwhether the event is dispatch or shortcut
BUILDctx.history, ctx.initial_messages
RUNctx.stop_reason, ctx.tools_used
SAVEwhich messages were added to the Session
RESPONDctx.outbound.metadata

Send a normal message and /help, then compare their visited states.

Source and tests

  • nanobot/agent/loop.py
    • TurnState
    • TurnContext
    • _TRANSITIONS
    • _process_message
    • _state_*
  • tests/agent/test_loop_runner_integration.py
  • tests/agent/test_loop_save_turn.py
  • tests/agent/test_stop_preserves_context.py
  • tests/command/test_router_dispatchable.py

Self-check

  1. Why does COMMAND precede BUILD?
  2. Why can a shortcut command still save history?
  3. Why is the user message persisted before Runner completes?
  4. In which state does AgentRunner’s result enter the Session?
  5. Why might RESPOND omit a normal reply after MessageTool has already sent one?

Next step

Read ContextBuilder and model context.