AgentRunner 的模型—工具循环

对应源码

主题源码
输入和输出契约nanobot/agent/runner.py::AgentRunSpec、AgentRunResult
生命周期 hook 外壳nanobot/agent/runner.py::AgentRunner.run、nanobot/agent/hook.py
模型—工具主循环nanobot/agent/runner.py::AgentRunner._run_core
Provider 请求nanobot/agent/runner.py::AgentRunner._request_model、nanobot/providers/base.py::LLMResponse
Tool 执行nanobot/agent/runner.py::AgentRunner._execute_tools、AgentRunner._run_tool

学习目标

读完后,你应当能够:

  • 解释 AgentRunSpec 和 AgentRunResult;
  • 画出 AgentRunner 每个 iteration 的主要分支;
  • 说明 Tool Call 如何回到下一轮模型请求;
  • 找到上下文治理、checkpoint 和 injection 的位置。

前置知识

  • 理解 Tool Calling 完整闭环;
  • 已阅读 AgentLoop 状态机和 ContextBuilder;
  • 知道 callback 和 async Task 的基本概念。

1. AgentRunner 不负责产品层问题

类定义说明:

Run a tool-capable LLM loop without product-layer concerns.

它不决定:

  • 消息来自哪个 Channel;
  • Session key 如何产生;
  • OutboundMessage 发给谁;
  • WebUI 显示什么协议;
  • Session 文件放在哪里。

这些由 AgentLoop 和外围模块负责。

2. AgentRunSpec 是本次执行契约

核心字段可分为六组:

分组字段示例
输入initial_messages、tools、model
生成参数temperature、max_tokens、reasoning_effort
边界max_iterations、max_tool_result_chars、context budget
策略concurrent_tools、fail_on_tool_error、retry mode
上下文workspace、session_key
回调progress、stream、retry wait、checkpoint、injection
长任务goal predicate、continue message、max-iteration finalization

它将 AgentLoop 的配置和当前 Turn 状态压缩成 Runner 可以使用的最小输入。

3. AgentRunResult 是统一结果

包括:

  • final_content
  • messages
  • tools_used
  • usage
  • stop_reason
  • error
  • tool_events
  • had_injections

AgentLoop 不需要理解 Runner 内部每个 Provider 分支,只消费这个统一结果。

4. 主循环

AgentRunner._run_core() 核心形式:

for iteration in range(spec.max_iterations):
    ...

每轮大致如下:

flowchart TD
    A["准备 messages_for_model"] --> B["Hook: before_iteration"]
    B --> C["请求 Provider"]
    C --> D["解析 reasoning / usage"]
    D --> E{"should_execute_tools?"}
    E -->|是| F["追加 assistant tool_calls"]
    F --> G["checkpoint: awaiting_tools"]
    G --> H["执行 Tools"]
    H --> I["追加 role=tool 结果"]
    I --> J["checkpoint: tools_completed"]
    J --> K["排空 pending injections"]
    K --> A
    E -->|否| L{"空响应 / length / error?"}
    L -->|需要恢复| M["retry / finalization / continue"]
    M --> A
    L -->|最终文本| N["生成 AgentRunResult"]

5. 每轮先做上下文治理

Runner 不直接把累积的 messages 原样发送。它创建 messages_for_model:

  1. 删除孤立 tool results;
  2. 为缺失 tool results 补回放占位;
  3. microcompact 较早内容;
  4. 应用 tool result budget;
  5. snip history;
  6. 再次修复可能产生的工具消息边界。

重要设计:

治理作用于发送给模型的临时视图,不应破坏用于保存本轮增量的原始 messages 边界。

如果治理失败,Runner 记录异常并尝试最小修复,而不是立即丢弃整个 Turn。

6. Provider 响应的三条主路

最终文本

没有可执行 Tool Call,content 非空,进入最终化。

Tool Call

response.should_execute_tools 为真:

  1. 构造 assistant tool-call 消息;
  2. 保存 awaiting_tools checkpoint;
  3. 执行工具;
  4. 为每个调用追加 role=tool 消息;
  5. 保存 tools_completed checkpoint;
  6. 继续下一 iteration。

异常或不完整响应

可能进入:

  • 空响应重试;
  • finalization retry;
  • finish_reason=length 恢复;
  • Provider error;
  • fallback;
  • max iterations finalization。

7. should_execute_tools 比 has_tool_calls 更严格

Provider 可能返回 tool calls,但 finish reason 是 refusal、content filter 或 error。Runner 只在以下 finish reason 下执行:

  • tool_calls
  • function_call
  • stop

否则记录警告并忽略这些调用。

这是安全和协议一致性边界,不能简化成:

if response.tool_calls:
    execute()

8. Checkpoint 的位置

Runner 在两个关键阶段回调 AgentLoop:

awaiting_tools
  assistant tool call 已产生
  工具结果尚未完成
 
tools_completed
  本批工具结果已完成
  下一次模型请求尚未开始

这些 checkpoint 用于中断恢复。Runner 只发布结构化 checkpoint,持久化到 Session metadata 的工作由 AgentLoop 回调完成。

9. Injection 的位置

同一 Session 在当前 Turn 期间收到的新消息,可能进入 pending queue。Runner 在安全边界排空:

  • 工具执行后;
  • 某些错误后;
  • 最终回复前。

注入消息会追加为新的 user 消息;如果最后一条也是 user,则合并内容,保持 role 序列合法。

这种设计允许用户在长 Turn 中补充信息,而不启动第二个竞争 Turn。

10. Hook 的作用

Runner 通过组合 Hook 处理:

  • before/after run;
  • before/after iteration;
  • before execute tools;
  • progress;
  • reasoning;
  • stream;
  • usage;
  • file edit events。

核心循环发布通用生命周期,具体入口或产品层选择如何显示。

11. 停止条件

常见 stop_reason 包括:

  • completed
  • tool_error
  • error
  • empty_final_response
  • max-iteration 相关结果
  • continuation 或 goal 相关结果

具体取值应以当前 _run_core() 分支和测试断言为准。不要在外部扩展中假设未知 stop reason 永远不会出现。

观察任务

在 _run_core() 中记录每轮:

iteration
len(messages)
len(messages_for_model)
response.finish_reason
len(response.tool_calls)
tools_used
stop_reason

分别发送:

  1. 一个无需工具的问题;
  2. 一个必须读取 workspace 文件的问题;
  3. 一个工具参数故意不完整的问题。

比较三条路径。

源码与测试

  • nanobot/agent/runner.py
    • AgentRunSpec
    • AgentRunResult
    • AgentRunner.run
    • AgentRunner._run_core
  • nanobot/providers/base.py::LLMResponse
  • tests/agent/test_runner_core.py
  • tests/agent/test_runner_governance.py
  • tests/agent/test_runner_injections.py
  • tests/agent/test_runner_persistence.py
  • tests/agent/test_runner_goal_continue.py

自检

  1. 为什么 Runner 使用临时 messages_for_model,而不是直接修改原列表?
  2. Checkpoint 为什么由 Runner 产生、由 AgentLoop 持久化?
  3. 为什么 Tool Call 完成后要继续下一 iteration?
  4. 新消息为什么不能在任意时刻插入 messages?
  5. has_tool_calls 与 should_execute_tools 有什么区别?

下一步

阅读 Tool 注册、校验与执行。