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 responsibility | Source to keep open |
|---|---|
| Turn state and product-level orchestration | nanobot/agent/loop.py::TurnState, TurnContext, AgentLoop._process_message, AgentLoop._state_* |
| Model–Tool execution loop | nanobot/agent/runner.py::AgentRunSpec, AgentRunResult, AgentRunner.run, AgentRunner._run_core |
| Model-context construction | nanobot/agent/context.py::ContextBuilder.build_system_prompt, ContextBuilder.build_messages |
| Inbound and outbound boundaries | nanobot/bus/events.py::InboundMessage, OutboundMessage; nanobot/bus/queue.py::MessageBus |
| Normalized Provider contract | nanobot/providers/base.py::ToolCallRequest, LLMResponse, LLMProvider |
| Tool registration, validation, and execution | nanobot/agent/tools/registry.py::ToolRegistry; nanobot/agent/tools/ |
| Session and persistence | nanobot/session/manager.py::Session, SessionManager |
| In-process runtime-state notifications | nanobot/bus/runtime_events.py::RuntimeEventBus, RuntimeEventPublisher |
Open only four files on the first pass
Read
events.py → loop.py → context.py → runner.py. Addregistry.py,manager.py, andruntime_events.pyonly after you can follow a normal text Turn.
Prerequisites
- Read Python
dataclass,Enum,async def,await, andasyncio.Queuewell enough to identify what an object stores and where a coroutine can pause. See Python and asyncio minimum. - Know the basic
system/user/assistant/toolmessage roles and the Tool Calling loop. See LLM messages, streaming, and Tool Calling. - Be able to search for class and function callers and inspect local variables at a breakpoint. See Source reading and debugging minimum.
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
- Messages, Session Keys, and entry points
- AgentLoop state machine
- ContextBuilder and model context
- AgentRunner’s model-tool loop
- Tool registration, validation, and execution
- Streaming, errors, and stop
- 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:
- With
MessageBus,AgentLoop.run()consumes the inbound queue and creates a processing task. AgentRunnerawaits 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
| Component | Responsibility it owns | Responsibility it deliberately does not own |
|---|---|---|
| Channel, CLI, API, SDK, and WebUI | Accept input, select transport, and present results | Do not implement the model–Tool loop |
MessageBus | Carry common messages between Channels and the Agent core | Does not persist Sessions or interpret message content |
AgentLoop | Session assignment, Turn state machine, commands, context preparation, persistence, response, and runtime events | Does not implement Provider protocol details |
ContextBuilder | Select and organize the system prompt, history, current message, media, and runtime metadata | Does not call the model or save the Session |
AgentRunner | LLM iterations, Tool Calls, Tool Results, context governance, and stop_reason | Does not choose Channel routing, Session files, or WebUI rendering |
LLMProvider | Normalize model backends into LLMResponse and handle Provider-level streaming/retry | Does not decide product-level Turn states |
ToolRegistry and Tool | Expose schemas, perform exact lookup, cast and validate arguments, and execute capabilities | Do not decide whether the model loop should continue or stop |
SessionManager | Load, cache, repair, and atomically save JSONL | Does not construct model prompts |
RuntimeEventBus | Publish observable state to in-process subscribers | Does 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_reasonThe 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 = FalseThis 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"]
| Object | Lifetime | Primary purpose |
|---|---|---|
InboundMessage | Inbound boundary | Normalize input fields across Channels, direct calls, and other entry points |
TurnContext | One product-level Turn | Carry mutable state and trace data between state handlers |
history | Created in BUILD | Replayable history selected from the Session |
initial_messages | Created before Runner invocation | system + history + current user/runtime context |
messages_for_model | One Runner iteration | Temporary repairs, trimming, and budget governance applied for the model |
AgentRunResult.messages | At Runner completion | The complete working message list returned from this execution |
Session.messages | Persisted across Turns | Conversation records after persistence filtering |
OutboundMessage | Outbound boundary | Deliver final content and metadata to the user’s runtime surface |
| Runtime Event | In-process notification | Notify WebUI and other subscribers about Session, run-status, and completion events |
The four message lists are not interchangeable
Session.messages,history,initial_messages, andmessages_for_modelhave 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 → DONEA 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.
...
breakThe 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
| Invariant | Source evidence | Why it matters |
|---|---|---|
| One effective Session Key is serialized; different Sessions may run concurrently | AgentLoop._dispatch() uses a per-session asyncio.Lock, while outer tasks may run concurrently | Prevents 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_respond | Keeps Session, command, persistence, and delivery concerns out of Runner |
| Runner does not own product-layer concerns | The AgentRunner class docstring and AgentRunSpec/Result | Makes the model–Tool loop reusable and independently testable |
| ContextBuilder constructs input only | build_system_prompt() and build_messages() return strings or message lists | Decouples prompt organization from model calls and persistence |
| Provider returns a normalized response | LLMResponse unifies text, tool calls, finish reason, usage, and error metadata | Runner need not branch its main loop for every backend |
| Tool arguments are validated on the host side | ToolRegistry.prepare_call() performs lookup, coercion, and validation before execution | Model-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 change | Inspect first |
|---|---|
| Add or adjust a Turn stage | States, handlers, and transition table in nanobot/agent/loop.py |
| Change model–Tool iteration or stopping | nanobot/agent/runner.py |
| Change the system prompt, history, or runtime context | nanobot/agent/context.py |
| Adapt a new model protocol | The concrete Provider plus the nanobot/providers/base.py contract |
| Add a capability or argument validation | nanobot/agent/tools/ and ToolRegistry |
| Change Session files or replay rules | nanobot/session/manager.py |
| Change user delivery or UI presentation | Channel, API/SDK/WebUI adapters, and runtime events—not Runner |
Core tests
tests/agent/test_loop_runner_integration.pytests/agent/test_runner_core.pytests/agent/test_runner_tool_execution.pytests/agent/test_context_builder.pytests/agent/test_loop_save_turn.pytests/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
AgentRunneralso 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:
- What did this stage add, remove, or transform on its input object?
- Does this function create a disk, network, queue, or runtime-state side effect?
- On failure, does it re-raise, convert the failure to a result, or return it to the model?