保存、响应与 Runtime Event

对应源码

主题源码
保存边界nanobot/agent/loop.py::AgentLoop._save_turn、AgentLoop._state_save
最终响应nanobot/agent/loop.py::AgentLoop._state_respond、AgentLoop._assemble_outbound
Session JSONLnanobot/session/manager.py::Session、SessionManager.save
出站消息nanobot/bus/events.py::OutboundMessage、nanobot/bus/queue.py::MessageBus
运行事件nanobot/bus/runtime_events.py::RuntimeEventBus、RuntimeEventPublisher
WebUI Turn 协调nanobot/session/webui_turns.py

学习目标

读完后,你应当能够:

  • 说明 Runner 结果如何写入 Session;
  • 区分 OutboundMessage 与 Runtime Event;
  • 解释 MessageTool 抑制重复回复的行为;
  • 找到 Turn latency、checkpoint 清理和完成事件的位置。

前置知识

  • 已理解 AgentLoop 状态机;
  • 已理解 AgentRunResult;
  • 知道 Session 是持久化状态。

1. Runner 返回的 messages 不能全部盲目保存

AgentRunner 返回:

  • 最终文本;
  • 运行中累积 messages;
  • tools used;
  • stop reason;
  • usage 等。

AgentLoop _save_turn() 负责把本轮应持久化的增量加入 Session,并执行:

  • 内容清理;
  • 孤立工具结果处理;
  • tool output 限制;
  • provider-specific block 清理;
  • 本轮保存边界计算。

原因:

  • Runner 为模型请求可能生成临时修复;
  • 某些 provider-specific 字段不适合持久化;
  • 超大 tool result 会污染未来上下文和磁盘;
  • user 消息可能已经在 BUILD 提前保存。

2. SAVE 阶段

AgentLoop._state_save() 顺序:

flowchart TD
    A["Continuation 保存边界"] --> B["处理空 final content"]
    B --> C["计算 latency"]
    C --> D["_save_turn"]
    D --> E["记录 runtime latency"]
    E --> F["Session file cap / archive"]
    F --> G["安排后台 consolidation"]
    G --> H["清 pending user turn"]
    H --> I["清 runtime checkpoint"]
    I --> J["SessionManager.save"]

这里是“模型运行结果”转换成“长期可恢复 Session”的边界。

3. Session 文件上限

非 ephemeral Turn 会调用:

  • Session.enforce_file_cap

被移出的较早内容可以交给 MemoryStore raw archive。这样 Session 文件不会无限增长,同时保留长期整理入口。

文件上限和模型 replay 上限是两个概念:

文件上限:磁盘 Session 保留多少
replay 上限:本轮送入 ContextBuilder 多少
Runner context budget:每次 Provider 请求真正发送多少

4. RESPOND 阶段

_state_respond() 调用 _assemble_outbound(),生成:

OutboundMessage(
    channel=msg.channel,
    chat_id=msg.chat_id,
    content=final_content,
    metadata=...
)

metadata 可能加入:

  • _streamed
  • latency_ms
  • 原入站 metadata 中需要回传的路由信息

ephemeral direct Turn 还可能加入 _stop_reason。

5. MessageTool 为什么可能抑制默认回复

某些 Agent Turn 会通过 MessageTool 主动向 Channel 发送消息。如果本轮已经这样发送,再返回同一份默认 OutboundMessage 会造成重复。

因此 _assemble_outbound() 会检查 MessageTool 本轮是否已发送,并根据 injection 和 stop reason 决定是否返回 None。

调试“模型完成但没有普通 outbound”时,应先检查:

  • ctx.suppress_response
  • MessageTool _sent_in_turn
  • 是否已有主动投递
  • stop reason

6. RuntimeEventBus 不是 MessageBus

MessageBus 传递用户可见入站/出站消息。

RuntimeEventBus 发布进程内状态事件:

  • SessionTurnStarted
  • TurnRunStatusChanged
  • TurnCompleted
  • GoalStateChanged
  • RuntimeModelChanged
flowchart LR
    AL["AgentLoop"] --> OM["OutboundMessage"]
    OM --> MB["MessageBus"]
    MB --> CH["Channel / 用户"]

    AL --> RE["Runtime Event"]
    RE --> RB["RuntimeEventBus"]
    RB --> WC["WebuiTurnCoordinator / 订阅者"]

两条路径可能同时发生,但目的不同:

  • OutboundMessage:告诉用户“回复是什么”;
  • Runtime Event:告诉协调器“Turn 现在处于什么状态”。

7. RuntimeEventPublisher

RuntimeEventPublisher 帮助 AgentLoop:

  • 发布 Session turn started;
  • 发布 run status;
  • 记录当前 Provider/model runtime;
  • 记录 latency;
  • 发布完成事件;
  • 清理本轮临时 runtime 数据。

这避免 AgentLoop 到处手工组装 event。

8. WebUI 协调边界

项目设计约束要求:

  • AgentLoop 只发布通用 runtime event;
  • WebUI/WebSocket wire 细节交给 WebuiTurnCoordinator 或 Channel adapter;
  • _turn_end、_goal_status、标题刷新不进入 AgentLoop 核心。

这条边界对扩展开发非常重要。如果只是增加 UI 状态,不应先改 Runner。

9. 完成时序

简化顺序:

AgentRunner 返回
  → continuation 边界
  → 保存 Session
  → 清 checkpoint
  → 组装 OutboundMessage
  → 发布 outbound
  → 发布 TurnCompleted / run status
  → UI/Channel 各自展示

具体发布先后因 direct、MessageBus、stream 和异常分支而有差异,应通过对应入口测试核对。

观察任务

在一次 Tool Call Turn 结束时分别观察:

  1. AgentRunResult.messages
  2. Session.messages
  3. 返回的 OutboundMessage
  4. RuntimeEventBus 订阅者收到的事件

回答:

  • 哪些 runner 消息最终保存了?
  • tool result 是否被截断或引用?
  • latency 保存在哪里?
  • Outbound metadata 和 Runtime Event 有哪些不同字段?

源码与测试

  • 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

自检

  1. 为什么 Runner messages 不能原样全部写入 Session?
  2. Session 文件上限和模型 replay 上限有什么不同?
  3. 为什么 MessageTool 已发送后默认 outbound 可能被抑制?
  4. Runtime Event 与 OutboundMessage 各自服务谁?
  5. WebUI wire 细节为什么不应进入 AgentLoop?

本阶段复盘

现在尝试不看文档,画出:

InboundMessage
  → Session
  → TurnState
  → ContextBuilder
  → AgentRunner
  → Provider / Tool
  → AgentRunResult
  → Session save
  → OutboundMessage / Runtime Event

如果能解释每条箭头对应的类或函数,就已经掌握 nanobot 核心源码主线。

下一步

进入“状态与长期运行”,深入 Session、Memory、Compaction、Goal、Cron 和 Subagent。