One Turn learning path

“One Turn” is the main Agent-architecture section of this library and the shortest path to understanding nanobot:

Start with one inbound message, then follow it until the runtime produces a response, persists state, and publishes runtime events.

A Turn is not one model request

One Turn may contain multiple LLM requests, multiple Tool executions, retries, injections, or continuations. A Turn is one complete product-level operation; a model request is only one step within it.

Corresponding source

Architecture responsibilitySource to keep open
Turn state and product-level orchestrationnanobot/agent/loop.py::TurnState, TurnContext, AgentLoop._process_message, AgentLoop._state_*
Model–Tool execution loopnanobot/agent/runner.py::AgentRunSpec, AgentRunResult, AgentRunner.run, AgentRunner._run_core
Model-context constructionnanobot/agent/context.py::ContextBuilder.build_system_prompt, ContextBuilder.build_messages
Inbound and outbound boundariesnanobot/bus/events.py::InboundMessage, OutboundMessage; nanobot/bus/queue.py::MessageBus
Normalized Provider contractnanobot/providers/base.py::ToolCallRequest, LLMResponse, LLMProvider
Tool registration, validation, and executionnanobot/agent/tools/registry.py::ToolRegistry; nanobot/agent/tools/
Session and persistencenanobot/session/manager.py::Session, SessionManager
In-process runtime-state notificationsnanobot/bus/runtime_events.py::RuntimeEventBus, RuntimeEventPublisher

Open only four files on the first pass

Read events.py → loop.py → context.py → runner.py. Add registry.py, manager.py, and runtime_events.py only after you can follow a normal text Turn.

Prerequisites

Learning objectives

After this stage, you should be able to:

  • explain why nanobot separates a product-level Turn from model execution;
  • explain how a message is assigned to a Session;
  • draw the AgentLoop state machine;
  • describe what ContextBuilder constructs for the model;
  • explain how AgentRunner iterates between the model and Tools;
  • locate streaming output, errors, stop reasons, and persistence;
  • distinguish model messages, Session messages, OutboundMessage, and runtime events.
  • decide whether a change belongs in the Loop, Runner, Context, Provider, Tool, Session, or a runtime surface.

Reading order

  1. Messages, Session Keys, and entry points
  2. AgentLoop state machine
  3. ContextBuilder and model context
  4. AgentRunner’s model-tool loop
  5. Tool registration, validation, and execution
  6. Streaming, errors, and stop
  7. Persistence, response, and runtime events

End-to-end overview

sequenceDiagram
    participant E as Entry point
    participant L as AgentLoop
    participant S as SessionManager
    participant C as ContextBuilder
    participant R as AgentRunner
    participant P as Provider
    participant T as ToolRegistry
    participant B as Bus/callback

    E->>L: InboundMessage / process_direct
    L->>S: Acquire Session
    L->>L: RESTORE → COMPACT → COMMAND
    L->>S: Read replayable history
    L->>C: Build system + history + current
    C-->>L: initial_messages
    L->>R: AgentRunSpec
    loop Model-tool iteration
        R->>P: messages + tool schemas
        P-->>R: text / tool_calls / error
        opt Tool Call
            R->>T: Parse, validate, execute
            T-->>R: tool result
        end
    end
    R-->>L: AgentRunResult
    L->>S: Save new messages and metadata
    L->>B: OutboundMessage + runtime events

This diagram contains two important asynchronous boundaries:

  1. With MessageBus, AgentLoop.run() consumes the inbound queue and creates a processing task.
  2. AgentRunner awaits Provider or Tool work, while the same effective Session Key remains serialized by a Session lock.

process_direct() bypasses the inbound and outbound queues, but it still uses the same Session lock and ultimately enters _process_message(). It is another transport path, not another Agent core.

Architecture ownership and dependency direction

ComponentResponsibility it ownsResponsibility it deliberately does not own
Channel, CLI, API, SDK, and WebUIAccept input, select transport, and present resultsDo not implement the model–Tool loop
MessageBusCarry common messages between Channels and the Agent coreDoes not persist Sessions or interpret message content
AgentLoopSession assignment, Turn state machine, commands, context preparation, persistence, response, and runtime eventsDoes not implement Provider protocol details
ContextBuilderSelect and organize the system prompt, history, current message, media, and runtime metadataDoes not call the model or save the Session
AgentRunnerLLM iterations, Tool Calls, Tool Results, context governance, and stop_reasonDoes not choose Channel routing, Session files, or WebUI rendering
LLMProviderNormalize model backends into LLMResponse and handle Provider-level streaming/retryDoes not decide product-level Turn states
ToolRegistry and ToolExpose schemas, perform exact lookup, cast and validate arguments, and execute capabilitiesDo not decide whether the model loop should continue or stop
SessionManagerLoad, cache, repair, and atomically save JSONLDoes not construct model prompts
RuntimeEventBusPublish observable state to in-process subscribersDoes not deliver user messages
flowchart LR
    Surface["Runtime surface<br/>Channel / CLI / API / SDK / WebUI"] --> Bus["MessageBus or direct call"]
    Bus --> Loop["AgentLoop<br/>product-level Turn"]
    Loop --> Context["ContextBuilder"]
    Loop --> Runner["AgentRunner<br/>model execution"]
    Runner --> Provider["LLMProvider"]
    Runner --> Registry["ToolRegistry / Tool"]
    Loop --> Session["SessionManager"]
    Loop --> Runtime["RuntimeEventBus"]
    Loop --> Surface

    Context -.returns messages only.-> Loop
    Provider -.returns LLMResponse only.-> Runner
    Registry -.returns Tool Result only.-> Runner

The key dependency direction is: runtime surfaces depend on the Agent core, and the Agent core depends on stable contracts. A concrete Provider, Tool, or UI should not push its protocol-specific details back into AgentRunner.

Two nested loops

Beginners often mistake AgentLoop and AgentRunner for duplicate implementations. They operate at different levels:

AgentLoop: one product-level Turn
  ├─ Session
  ├─ Context
  ├─ Command
  ├─ Runtime events
  ├─ Persistence
  └─ Outbound delivery
 
AgentRunner: one model execution
  ├─ Call Provider
  ├─ Parse Tool Call
  ├─ Execute Tool
  ├─ Reinsert Tool Result
  ├─ Govern context
  └─ Decide stop_reason

The AgentLoop “loop” is a state-machine loop; the AgentRunner “loop” is at most max_iterations model iterations. Two data classes form their boundary:

@dataclass(slots=True)
class AgentRunSpec:
    initial_messages: list[dict[str, Any]]
    tools: ToolRegistry
    model: str
    max_iterations: int
    max_tool_result_chars: int
    # Remaining fields provide hook, stream, checkpoint, injection, timeout,
    # and Goal policies.
 
 
@dataclass(slots=True)
class AgentRunResult:
    final_content: str | None
    messages: list[dict[str, Any]]
    tools_used: list[str] = field(default_factory=list)
    usage: dict[str, int] = field(default_factory=dict)
    stop_reason: str = "completed"
    error: str | None = None
    tool_events: list[dict[str, str]] = field(default_factory=list)
    had_injections: bool = False

This excerpt comes from nanobot/agent/runner.py. AgentRunSpec carries the input prepared by the Loop into the Runner; AgentRunResult returns the execution outcome to the Loop. This boundary lets the Runner reuse model–Tool behavior without knowing whether the message came from Telegram, CLI, or WebUI.

How the core objects change

flowchart TD
    IM["InboundMessage<br/>channel / chat_id / content / metadata"] --> TC["TurnContext<br/>mutable workbench for this Turn"]
    TC --> H["Session.get_history()<br/>history"]
    H --> CB["ContextBuilder.build_messages()"]
    IM --> CB
    CB --> INIT["initial_messages"]
    INIT --> SPEC["AgentRunSpec"]
    SPEC --> RUNNER["AgentRunner.run()"]
    RUNNER --> RESULT["AgentRunResult"]
    RESULT --> TC2["write back into TurnContext<br/>final_content / all_messages / stop_reason"]
    TC2 --> SAVE["Session.messages + metadata<br/>JSONL"]
    TC2 --> OUT["OutboundMessage"]
    TC2 --> EVT["Runtime Event"]
ObjectLifetimePrimary purpose
InboundMessageInbound boundaryNormalize input fields across Channels, direct calls, and other entry points
TurnContextOne product-level TurnCarry mutable state and trace data between state handlers
historyCreated in BUILDReplayable history selected from the Session
initial_messagesCreated before Runner invocationsystem + history + current user/runtime context
messages_for_modelOne Runner iterationTemporary repairs, trimming, and budget governance applied for the model
AgentRunResult.messagesAt Runner completionThe complete working message list returned from this execution
Session.messagesPersisted across TurnsConversation records after persistence filtering
OutboundMessageOutbound boundaryDeliver final content and metadata to the user’s runtime surface
Runtime EventIn-process notificationNotify WebUI and other subscribers about Session, run-status, and completion events

The four message lists are not interchangeable

Session.messages, history, initial_messages, and messages_for_model have different origins, lifetimes, and filtering rules. When debugging “why the model saw this,” first name the list you are inspecting.

AgentLoop state-machine skeleton

nanobot/agent/loop.py models the product-level Turn as explicit states instead of advancing implicitly inside one long function:

class TurnState(Enum):
    RESTORE = auto()
    COMPACT = auto()
    COMMAND = auto()
    BUILD = auto()
    RUN = auto()
    SAVE = auto()
    RESPOND = auto()
    DONE = auto()
 
 
_TRANSITIONS = {
    (TurnState.RESTORE, "ok"): TurnState.COMPACT,
    (TurnState.COMPACT, "ok"): TurnState.COMMAND,
    (TurnState.COMMAND, "dispatch"): TurnState.BUILD,
    (TurnState.COMMAND, "shortcut"): TurnState.DONE,
    (TurnState.BUILD, "ok"): TurnState.RUN,
    (TurnState.RUN, "ok"): TurnState.SAVE,
    (TurnState.SAVE, "ok"): TurnState.RESPOND,
    (TurnState.RESPOND, "ok"): TurnState.DONE,
}

_process_message() derives handlers such as _state_restore() and _state_build() from the state name. A handler returns an event string, and _TRANSITIONS selects the next state. This makes each stage’s timing, input, and side effects observable in isolation.

The normal path is:

RESTORE → COMPACT → COMMAND --dispatch→ BUILD → RUN → SAVE → RESPOND → DONE

A slash command can take COMMAND --shortcut→ DONE. Because it skips BUILD/RUN/SAVE, _state_command() must itself persist any command messages that should remain.

AgentRunner loop skeleton

In every Runner iteration, the code first constructs a temporary model-facing message list, then requests the Provider:

for iteration in range(spec.max_iterations):
    messages_for_model = self._drop_orphan_tool_results(messages)
    messages_for_model = self._backfill_missing_tool_results(messages_for_model)
    messages_for_model = self._microcompact(messages_for_model)
    messages_for_model = self._apply_tool_result_budget(spec, messages_for_model)
    messages_for_model = self._snip_history(spec, messages_for_model)
 
    response = await self._request_model(spec, messages_for_model, hook, context)
 
    if response.should_execute_tools:
        # Append assistant tool_calls, execute Tools, then append role="tool"
        # results.
        ...
        continue
 
    # Handle final text, errors, blank output, length recovery, or injection.
    ...
    break

The excerpt comes from nanobot/agent/runner.py::AgentRunner._run_core; comments and ellipses only compress branches. The crucial design point is that context governance operates on a new messages_for_model list. Its temporary repairs do not rewrite messages, whose append boundary is later used for persistence.

Seven architecture invariants

InvariantSource evidenceWhy it matters
One effective Session Key is serialized; different Sessions may run concurrentlyAgentLoop._dispatch() uses a per-session asyncio.Lock, while outer tasks may run concurrentlyPrevents interleaved state writes within one conversation without sacrificing cross-session throughput
Bus and direct entry paths converge on one core_dispatch() and process_direct() both call _process_message()Avoids duplicating Agent logic per runtime surface
Loop owns the product-level lifecycle_state_restore through _state_respondKeeps Session, command, persistence, and delivery concerns out of Runner
Runner does not own product-layer concernsThe AgentRunner class docstring and AgentRunSpec/ResultMakes the model–Tool loop reusable and independently testable
ContextBuilder constructs input onlybuild_system_prompt() and build_messages() return strings or message listsDecouples prompt organization from model calls and persistence
Provider returns a normalized responseLLMResponse unifies text, tool calls, finish reason, usage, and error metadataRunner need not branch its main loop for every backend
Tool arguments are validated on the host sideToolRegistry.prepare_call() performs lookup, coercion, and validation before executionModel-generated arguments are not executed as trusted input

These “invariants” are design interpretations derived from the current source, not a formally named specification published by the maintainers. Before changing one, inspect the corresponding tests and every caller.

Where a change should land

Behavior you want to changeInspect first
Add or adjust a Turn stageStates, handlers, and transition table in nanobot/agent/loop.py
Change model–Tool iteration or stoppingnanobot/agent/runner.py
Change the system prompt, history, or runtime contextnanobot/agent/context.py
Adapt a new model protocolThe concrete Provider plus the nanobot/providers/base.py contract
Add a capability or argument validationnanobot/agent/tools/ and ToolRegistry
Change Session files or replay rulesnanobot/session/manager.py
Change user delivery or UI presentationChannel, API/SDK/WebUI adapters, and runtime events—not Runner

Core tests

  • tests/agent/test_loop_runner_integration.py
  • tests/agent/test_runner_core.py
  • tests/agent/test_runner_tool_execution.py
  • tests/agent/test_context_builder.py
  • tests/agent/test_loop_save_turn.py
  • tests/bus/test_runtime_events.py

Learning method

On the first pass, follow only a normal text response. On the second, follow one Tool Call. Add streaming, cancellation, injection, and Goal only on the third pass.

Do not open every advanced branch at once

AgentRunner also handles retries, fallback, reasoning, tool-result budgets, injection, and maximum iterations. Establish the main loop first, then add one branch at a time.

After each lesson, answer at least three questions:

  1. What did this stage add, remove, or transform on its input object?
  2. Does this function create a disk, network, queue, or runtime-state side effect?
  3. On failure, does it re-raise, convert the failure to a result, or return it to the model?

Next step

Read Messages, Session Keys, and entry points.