Persistence, response, and Runtime Events
Corresponding source
| Subject | Source |
|---|---|
| Persistence boundary | nanobot/agent/loop.py::AgentLoop._save_turn, AgentLoop._state_save |
| Final response | nanobot/agent/loop.py::AgentLoop._state_respond, AgentLoop._assemble_outbound |
| Session JSONL | nanobot/session/manager.py::Session, SessionManager.save |
| Outbound message | nanobot/bus/events.py::OutboundMessage, nanobot/bus/queue.py::MessageBus |
| Runtime events | nanobot/bus/runtime_events.py::RuntimeEventBus, RuntimeEventPublisher |
| WebUI Turn coordination | nanobot/session/webui_turns.py |
Learning objectives
After this lesson, you should be able to:
- explain how Runner output is written into a Session;
- distinguish OutboundMessage from a runtime event;
- explain how MessageTool suppresses duplicate responses;
- locate Turn latency, checkpoint cleanup, and completion events.
Prerequisites
- Understand the AgentLoop state machine.
- Understand
AgentRunResult. - Know that Session is persistent state.
1. Runner messages cannot all be saved blindly
AgentRunner returns:
- final text;
- messages accumulated during execution;
- tools used;
- stop reason;
- usage and related data.
AgentLoop _save_turn() adds the persistable delta from this Turn to the Session and performs:
- content cleanup;
- orphaned tool-result handling;
- tool-output limits;
- Provider-specific block cleanup;
- calculation of this Turn’s persistence boundary.
This is necessary because:
- Runner may create temporary repairs for a model request;
- selected Provider-specific fields should not persist;
- an oversized tool result can contaminate future context and disk;
- the user message may already have been saved during BUILD.
2. SAVE stage
AgentLoop._state_save() follows:
flowchart TD A["Continuation persistence boundary"] --> B["Handle empty final content"] B --> C["Calculate latency"] C --> D["_save_turn"] D --> E["Record runtime latency"] E --> F["Session file cap / archive"] F --> G["Schedule background consolidation"] G --> H["Clear pending user turn"] H --> I["Clear runtime checkpoint"] I --> J["SessionManager.save"]
This is the boundary where a model-run result becomes a durable, recoverable Session.
3. Session file cap
A non-ephemeral Turn calls:
Session.enforce_file_cap
Older content removed from the Session can enter the MemoryStore raw archive. The Session file therefore remains bounded while preserving an input for later long-term organization.
The file cap and model replay limit are different:
File cap: how much the on-disk Session retains
Replay limit: how much this Turn hands to ContextBuilder
Runner context budget: how much each Provider request actually sends4. RESPOND stage
_state_respond() calls _assemble_outbound() to create:
OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=final_content,
metadata=...
)Metadata may include:
_streamed;latency_ms;- routing data copied from inbound metadata.
An ephemeral direct Turn may also include _stop_reason.
5. Why MessageTool can suppress the default response
Some Agent Turns proactively send a message to a Channel through MessageTool. Returning the same default OutboundMessage afterward would duplicate the response.
_assemble_outbound() therefore checks whether MessageTool sent during this Turn and uses injection and stop-reason state to decide whether to return None.
When “the model finished but no ordinary outbound appeared,” inspect:
ctx.suppress_response;- MessageTool
_sent_in_turn; - whether proactive delivery already occurred;
- stop reason.
6. RuntimeEventBus is not MessageBus
MessageBus carries user-visible inbound and outbound messages.
RuntimeEventBus publishes in-process state events:
SessionTurnStartedTurnRunStatusChangedTurnCompletedGoalStateChangedRuntimeModelChanged
flowchart LR AL["AgentLoop"] --> OM["OutboundMessage"] OM --> MB["MessageBus"] MB --> CH["Channel / user"] AL --> RE["Runtime event"] RE --> RB["RuntimeEventBus"] RB --> WC["WebuiTurnCoordinator / subscribers"]
Both paths may occur during one Turn, but they have different purposes:
- OutboundMessage tells the user what the response is.
- A runtime event tells a coordinator what state the Turn is in.
7. RuntimeEventPublisher
RuntimeEventPublisher helps AgentLoop:
- publish Session turn start;
- publish run status;
- record the current Provider/model runtime;
- record latency;
- publish completion;
- clear temporary runtime data for the Turn.
This avoids hand-building events throughout AgentLoop.
8. WebUI coordination boundary
Project design constraints require:
- AgentLoop publishes only general runtime events;
WebuiTurnCoordinatoror a Channel adapter owns WebUI/WebSocket wire details;_turn_end,_goal_status, and title refresh stay out of core AgentLoop.
This boundary matters during extension development. If a change only adds UI state, Runner should not be the first place you modify.
9. Completion sequence
A simplified order is:
AgentRunner returns
→ apply Continuation boundary
→ save Session
→ clear checkpoint
→ assemble OutboundMessage
→ publish outbound
→ publish TurnCompleted / run status
→ UI and Channel present their own viewsThe exact ordering differs among direct, MessageBus, streaming, and error branches. Verify the entry point with its tests.
Observation task
At the end of one Tool Call Turn, inspect:
AgentRunResult.messages;Session.messages;- the returned
OutboundMessage; - events received by a RuntimeEventBus subscriber.
Answer:
- Which Runner messages were persisted?
- Was the tool result truncated or replaced by a reference?
- Where was latency saved?
- How do Outbound metadata and runtime-event fields differ?
Source and tests
nanobot/agent/loop.py_save_turn_state_save_state_respond_assemble_outbound
nanobot/session/manager.pynanobot/bus/events.pynanobot/bus/runtime_events.pynanobot/session/webui_turns.pytests/agent/test_loop_save_turn.pytests/agent/test_runner_persistence.pytests/bus/test_runtime_events.pytests/agent/test_loop_direct_websocket_status.py
Self-check
- Why can Runner messages not all be copied unchanged into Session?
- How does the Session file cap differ from the model replay limit?
- Why can the default outbound be suppressed after MessageTool sends?
- Who does a runtime event serve, and who does OutboundMessage serve?
- Why should WebUI wire details stay out of AgentLoop?
Stage review
Without looking at the lesson, draw:
InboundMessage
→ Session
→ TurnState
→ ContextBuilder
→ AgentRunner
→ Provider / Tool
→ AgentRunResult
→ Session save
→ OutboundMessage / runtime eventIf you can name the class or function behind each arrow, you understand nanobot’s core source path.
Next step
Continue to State and Long-Running Work for Session, Memory, Compaction, Goal, Cron, and Subagent.