Persistence, response, and Runtime Events

Corresponding source

SubjectSource
Persistence boundarynanobot/agent/loop.py::AgentLoop._save_turn, AgentLoop._state_save
Final responsenanobot/agent/loop.py::AgentLoop._state_respond, AgentLoop._assemble_outbound
Session JSONLnanobot/session/manager.py::Session, SessionManager.save
Outbound messagenanobot/bus/events.py::OutboundMessage, nanobot/bus/queue.py::MessageBus
Runtime eventsnanobot/bus/runtime_events.py::RuntimeEventBus, RuntimeEventPublisher
WebUI Turn coordinationnanobot/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 sends

4. 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:

  • SessionTurnStarted
  • TurnRunStatusChanged
  • TurnCompleted
  • GoalStateChanged
  • RuntimeModelChanged
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;
  • WebuiTurnCoordinator or 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 views

The 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:

  1. AgentRunResult.messages;
  2. Session.messages;
  3. the returned OutboundMessage;
  4. 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.py
  • nanobot/bus/events.py
  • nanobot/bus/runtime_events.py
  • nanobot/session/webui_turns.py
  • tests/agent/test_loop_save_turn.py
  • tests/agent/test_runner_persistence.py
  • tests/bus/test_runtime_events.py
  • tests/agent/test_loop_direct_websocket_status.py

Self-check

  1. Why can Runner messages not all be copied unchanged into Session?
  2. How does the Session file cap differ from the model replay limit?
  3. Why can the default outbound be suppressed after MessageTool sends?
  4. Who does a runtime event serve, and who does OutboundMessage serve?
  5. 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 event

If 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.