AgentLoop 状态机

对应源码

主题源码
状态、工作对象和转换表nanobot/agent/loop.py::TurnState、TurnContext、AgentLoop._TRANSITIONS
状态机驱动器nanobot/agent/loop.py::AgentLoop._process_message
各阶段处理器nanobot/agent/loop.py::AgentLoop._state_restore 到 AgentLoop._state_respond
Loop 与 Runner 交界nanobot/agent/loop.py::AgentLoop._run_agent_loop、nanobot/agent/runner.py::AgentRunSpec

学习目标

读完后,你应当能够:

  • 解释八个 TurnState;
  • 找到状态转换表和状态 handler;
  • 说明普通消息与 slash command 为什么走不同路径;
  • 说明 AgentRunner 在整个 Turn 中只负责哪一段。

前置知识

1. TurnContext 是一次 Turn 的工作台

定义:

  • nanobot/agent/loop.py::TurnContext

它将一次 Turn 中逐步产生的数据放在同一个对象上:

阶段典型字段
入口msg、session_key、turn_id
Sessionsession、history、pending_summary
Contextinitial_messages
Runner 结果final_content、tools_used、all_messages、stop_reason
流式和进度on_stream、on_progress、on_retry_wait
注入pending_queue、had_injections
输出outbound、suppress_response
诊断turn_latency_ms、trace

它是运行时对象,不是直接写入磁盘的持久化 schema。

2. 状态转换表

AgentLoop._TRANSITIONS 显式定义:

stateDiagram-v2
    [*] --> RESTORE
    RESTORE --> COMPACT: ok
    COMPACT --> COMMAND: ok
    COMMAND --> BUILD: dispatch
    COMMAND --> DONE: shortcut
    BUILD --> RUN: ok
    RUN --> SAVE: ok
    SAVE --> RESPOND: ok
    RESPOND --> DONE: ok
    DONE --> [*]

handler 返回事件字符串,driver 根据 (当前状态, 事件) 查找下一状态。

DONE 是终止标记,不会执行 _state_done()。因此普通主路径会经过八个枚举状态,但 ctx.trace 只记录前七个实际 handler;shortcut command 的 trace 更短。

这种设计的好处是:

  • 状态边界可观察;
  • shortcut 清晰;
  • 每个阶段的职责相对集中;
  • trace 可以记录耗时和错误。

3. _process_message() 驱动状态机

主要步骤:

  1. 计算 Session;
  2. 创建 TurnContext,初始状态为 RESTORE;
  3. 根据当前状态找到 handler;
  4. 执行 handler;
  5. 记录 state trace;
  6. 使用转换表进入下一状态;
  7. 到 DONE 后返回 outbound 或结果。

对应的驱动骨架可以直接从 AgentLoop._process_message() 读出:

while ctx.state is not TurnState.DONE:
    handler_name = f"_state_{ctx.state.name.lower()}"
    handler = getattr(self, handler_name, None)
    if handler is None:
        raise RuntimeError(f"Missing state handler for {ctx.state}")
 
    event = await handler(ctx)
    next_state = self._TRANSITIONS.get((ctx.state, event))
    if next_state is None:
        raise RuntimeError(
            f"[turn {ctx.turn_id}] No transition from {ctx.state} "
            f"on event {event!r}"
        )
    ctx.state = next_state

这是删去 trace 计时和日志后的源码节选。它揭示了两个约束:新增枚举状态时必须有同名 _state_<name>() handler;handler 新增事件时必须同步增加转换表项,否则会明确失败,而不是悄悄走默认分支。

调试一次 Turn 时,优先在 _process_message() 的状态循环观察:

  • ctx.state
  • handler 返回的 event
  • ctx.trace
  • 哪个状态首次出现异常

4. RESTORE:恢复和准备入站

handler:

  • AgentLoop._state_restore

主要职责:

  • 处理媒体或文档引用;
  • 确保 Session 存在;
  • 发布 SessionTurnStarted;
  • 持久化消息的 workspace scope;
  • 恢复 runtime checkpoint;
  • 恢复 pending user turn。

这里解决的是“上一轮可能留下什么,以及当前消息属于哪个运行范围”,还没有调用模型。

5. COMPACT:处理过期或待摘要 Session

handler:

  • AgentLoop._state_compact

调用:

  • AutoCompact.prepare_session

它可能返回更新后的 Session 和 pending summary。真正按 token 触发的 consolidation 还会在 BUILD 发生。

两种压缩不要混为一谈

AutoCompact、Consolidator、Runner 的 microcompact/tool-result budget 作用层级不同。后续“状态与长期运行”会分别解释。

6. COMMAND:先处理 slash command

handler:

  • AgentLoop._state_command

它构造 CommandContext,交给命令路由。

如果命令返回 OutboundMessage:

  • event 为 shortcut;
  • 跳过 BUILD、RUN、SAVE、RESPOND;
  • 直接进入 DONE;
  • 但为了 WebUI 能在 Turn 结束后看到历史,普通命令仍会显式保存 user/assistant command 消息;
  • /new 是特例,因为它有意清空 Session。

这说明:

shortcut 跳过通用保存状态,不等于完全不持久化。

7. BUILD:构造本轮运行输入

handler:

  • AgentLoop._state_build

主要职责:

  1. 非 ephemeral Turn 按 token 检查 consolidation;
  2. 设置当前 ToolContext;
  3. 重置 MessageTool 的本轮状态;
  4. 从 Session 获取受预算约束的 history;
  5. 记录当前 Provider/model runtime;
  6. 由 ContextBuilder 构造 initial_messages;
  7. 提前持久化当前 user 消息;
  8. 创建 progress 和 retry-wait callback。

这里有一个重要事实:

当前 user 消息在 Runner 完成前就可能被提前持久化。

这样做配合 pending user turn 和 checkpoint,避免长 Turn 或中断时完全丢失用户输入。

8. RUN:调用 AgentRunner

handler:

  • AgentLoop._state_run

主要职责:

  • 记录可见运行开始时间;
  • 发布 "running" 状态;
  • 调用 _run_agent_loop();
  • 接收 final content、tools used、完整运行消息、stop reason 和 injection 标记;
  • 处理可能的 continuation。

_run_agent_loop() 将当前配置转换成 AgentRunSpec,包括:

  • initial messages;
  • ToolRegistry;
  • model;
  • iteration 限制;
  • tool result 限制;
  • timeout;
  • stream/progress callback;
  • checkpoint/injection callback;
  • goal predicate;
  • finalization policy。

9. SAVE:把运行结果落到 Session

handler:

  • AgentLoop._state_save

主要职责:

  • 处理 continuation 保存边界;
  • 为不应为空的响应提供统一空响应提示;
  • 计算 latency;
  • 保存本轮新增消息;
  • 记录 runtime latency;
  • 执行 Session 文件上限处理;
  • 后台安排 consolidation;
  • 清理 pending user turn;
  • 清理 runtime checkpoint;
  • 保存 Session。

10. RESPOND:构造对外回复

handler:

  • AgentLoop._state_respond

如果本轮被设置为 suppress response,则不创建 OutboundMessage。否则调用 _assemble_outbound():

  • 选择 channel 和 chat_id;
  • 使用 final content;
  • 标记是否已 stream;
  • 添加 latency;
  • 考虑 MessageTool 已经主动发送消息时是否抑制重复回复。

一次普通 Turn 的数据变化

flowchart TD
    M["InboundMessage"] --> R["RESTORE<br/>Session + checkpoint"]
    R --> C["COMPACT<br/>pending summary"]
    C --> CMD["COMMAND<br/>普通消息继续"]
    CMD --> B["BUILD<br/>history + initial_messages<br/>提前保存 user"]
    B --> RUN["RUN<br/>AgentRunResult"]
    RUN --> S["SAVE<br/>追加 assistant/tool<br/>清 checkpoint"]
    S --> O["RESPOND<br/>OutboundMessage"]

观察任务

在 _process_message() 的状态循环记录:

state观察变量
RESTOREctx.session.metadata
COMPACTctx.pending_summary
COMMANDevent 是 dispatch 还是 shortcut
BUILDctx.history、ctx.initial_messages
RUNctx.stop_reason、ctx.tools_used
SAVESession 新增了哪些消息
RESPONDctx.outbound.metadata

分别发送普通消息和 /help,比较经过的状态。

源码与测试

  • nanobot/agent/loop.py
    • TurnState
    • TurnContext
    • _TRANSITIONS
    • _process_message
    • _state_*
  • tests/agent/test_loop_runner_integration.py
  • tests/agent/test_loop_save_turn.py
  • tests/agent/test_stop_preserves_context.py
  • tests/command/test_router_dispatchable.py

自检

  1. 为什么 COMMAND 在 BUILD 之前?
  2. 为什么 shortcut command 仍可能保存历史?
  3. 为什么 user 消息会在 Runner 完成前提前持久化?
  4. AgentRunner 的结果在哪个状态进入 Session?
  5. MessageTool 已发送消息时,RESPOND 为什么可能不再返回普通回复?

下一步

阅读 ContextBuilder 与模型上下文。