上一篇讲了 react-agent-mini 的主循环:query() 约 150 行把「模型 ↔ 工具」跑通了。
但只有 Echo 和「整文件 Read」时,Agent 其实还是个瞎子——它答得了「用 Echo 回复 hello」,却答不了「这个仓库里 needsFollowUp 在哪定义的?」。

今天这篇讲 v1 新加的一块:只读代码库工具。主循环一个字没改,换的是「手」。


为什么要加「找文件」和「搜内容」?

Agent 探索代码通常是这个节奏:

Glob(按文件名找)
  → Grep(按内容搜)
    → Read(精读关键片段)
      → 回答用户

没有 Grep / Glob,它就只能猜文件名、整本读文件——上下文又贵又慢,也很容易读爆。

上一版扩展路线里写的第 1 步正是:

更多只读工具(Grep、Glob)—— 仍串行、auto-allow

这期把它落地了。query.ts 完全不动:工具往 getTools() 一注册,模型就能在下一轮 callModel 里看到新的 tools schema。

这本身就是 harness 分层的好处——循环稳定,能力往外挂。


改了什么?(一张表说清)

能力 变化
Grep 新增:cwd 内正则搜索;默认 output_mode: files_with_matcheshead_limit 默认 250
Glob 新增:cwd 内按 glob 列文件,最多 100 条
Read 增强:file_path + offset/limit 分段读;输出带行号(N\t…);offset=0 视为第 1 行
CLI stderr 状态行支持 [工具] Grep: … / [工具] Glob: …

刻意没做:写文件、Bash、权限弹窗、.gitignore 过滤、ripgrep 全参数、并发工具分区。
只读 + 结果上限,先保证「找得到、读得了、塞不爆 context」。


新增工具,两步就够

注册表现在长这样:

// src/tools/index.ts
export function getTools(): Tools {
  return [EchoTool, ReadTool, GrepTool, GlobTool]
}

每加一个工具,流程固定:

  1. src/tools/XxxTool.ts 实现 Tool 契约(name / description / inputSchema / call
  2. 挂进 getTools(),写测试,CLI 状态行可选补一行

Zod inputSchema 会自动变成发给模型的 tools JSON schema,所以不用改 adapter 的核心逻辑——出站时遍历工具列表即可。


Grep:在仓库里「搜词」

入参(精简版):

字段 说明
pattern 正则
path 可选,搜索根(必须在 cwd 内)
glob 可选,文件名过滤(如 *.ts
output_mode 可选:content / files_with_matches默认)/ count
head_limit 可选,默认 2500 表示不限

默认返回匹配文件列表(不是每一行):

src/query.ts
src/Tool.ts

需要行内容时显式传 output_mode: "content",才会得到熟悉的 path:line:content

src/query.ts:89:    let needsFollowUp = false
src/query.ts:107:          needsFollowUp = true

实现上为什么不用系统 rg

设计里最初考虑「优先 rg,没有再 JS 回退」。落地时选择了 纯 JS 遍历 + RegExp

  • Windows 开发环境经常没有 ripgrep
  • 单测不依赖外部二进制
  • 对学习向最小 Agent 够用

代价是大仓库会慢一些——用 head_limit、跳过 node_modules / .git、以及 32KB 输出上限 兜住。更重的 vendor ripgrep,留给以后对齐完整版时再考虑。

两个护栏(很重要)

  1. 条数上限:默认 250,避免一次 tool_result 塞爆上下文
  2. 路径护栏:复用 Read 的 resolvePathUnderCwd,拒绝 ../ 穿越

Agent 工具里「能搜」和「搜成灾难」往往就差这两个上限。


Glob:先摸清「有哪些文件」

入参更简单:pattern(如 **/*.ts)+ 可选 path

实现直接用 Bun 内置 API:

import { Glob } from 'bun'

const glob = new Glob(args.pattern)
for await (const match of glob.scan({ cwd: root, onlyFiles: true })) {
  // …最多收 100 条
}

无新依赖,结果硬顶 100 条路径。模型常见用法是:先 Glob 缩小范围,再 Grep / Read。


Read:别整本塞给模型

大文件整读有两个问题:超过 100KB 上限直接失败;就算塞进去也浪费 token。

这里把字段名统一写成 file_path(不是 path),读起来更直观:你一眼就知道这是“文件路径”。分段语义:

  • offset:1-based 起始行;0 视为 1
  • limit:读多少行
  • 无这两个参数时,仍读整文件(≤100KB),但始终带行号

输出行号格式为 N\t内容:前面是行号,中间用一个 tab 分隔,后面是原始内容。这样模型后续如果要引用某一行,定位会更稳定:

42	export async function* query(
43	  params: QueryParams,
44	): AsyncGenerator<QueryYield, Terminal> {

典型组合拳:

Glob  **/*query*
  → Grep  needsFollowUp
    → Read  file_path=src/query.ts  offset=85  limit=40

主循环真的不用改吗?

是的。回顾上一篇的五字诀:看、停、干、限、记

「干」这一步仍然是 runToolsrunToolUse

权限(v1 仍 auto-allow)
  → findToolByName
  → Zod 校验
  → tool.call()
  → 包装成 tool_result

Grep / Glob / Read 都是 isReadOnly: true,继续吃同一套权限策略。你要加的是工具实现,不是循环语义

这也是面试时常考的点:

加一个只读工具,该动哪一层?
→ 工具注册表 + Tool 实现;不动 queryLoop


30 秒试一把

Mock 模式仍可验证 Echo;探索代码库更建议真实模型(需要 API Key):

# 找文件
bun run dev -- "用 Glob 列出 src/tools 下的 *.ts"

# 搜内容 + 读片段
bun run dev -- "在仓库里 Grep needsFollowUp,然后分段 Read 相关代码并解释它做什么"

CLI 上你会看到类似:

[工具] Glob: src/tools/*.ts
[工具] Grep: needsFollowUp
[工具] Read: src/query.ts

文本仍走 stdout,工具状态走 stderr——和上一篇一致。


刻意留下的坑(也是下一步)

还没有 意味着什么
写文件 / Bash Agent 还不能改代码、跑命令
真实权限流水线 现在仍恒 allow,只适合可信 cwd
不尊重 .gitignore 可能扫到构建产物(可手动缩 path)
工具串行 一轮里多个只读工具也会挨个跑

建议的下一刀仍然清晰:

  1. 只读工具
  2. 权限流水线(替换 autoAllowCanUseTool
  3. REPL 多轮对话
  4. compact
  5. 写操作 / MCP

你可以从这里带走什么?

  1. 工具是 harness 最便宜的扩展点:实现 Tool + 注册,循环层零改动。
  2. 只读工具也要有上限:条数、字节、cwd 边界——防止把 context 和磁盘一起炸掉。
  3. 探索链路有固定套路:Glob → Grep → 分段 Read,比「一口吞整个文件」更像真 Agent。

如果你把上一篇看成「心脏搏动」,这篇就是「长出手和眼睛」——还不会写代码、也不会问你权限,但终于能在仓库里走动了。


仓库与相关文档

欢迎 Star、Issue 和 PR。


本文基于 react-agent-mini 变更 v1-codebase-tools;Grep/Read 契约已按 v4-claude-align 校正。

更多推荐