Day 08 / 共 20 天 · 第 2 周 核心引擎 · 加深版

内置工具精读

Day 07 讲了工具接口"长什么样",今天打开 packages/builtin-tools 看几个工具"真怎么填"——贴真实代码:Read(只读)、Edit(写)、Bash(最复杂权限)、Agent(子代理)。昨天(Day 07)看的是"工具箱的规格标准",今天真把箱子打开,拆开四把具体工具看内部,Day 09 再专讲它们动手前的"签字"。

📍 你在整门课的位置 · 第 2 周 核心引擎
D06 QueryEngine D07 工具接口 D08 内置工具 D09 权限 D10 上下文/Token
💡 用一个类比先兜住今天(延续 Day 07 的"工具箱",今天拆开四把) 今天拆的四把工具,恰好是工具箱里四种"危险等级":Read = 卷尺(只量不改,可以几把同时量 → 只读、可并发);Edit = 电钻(会在你东西上打洞 → 会写、串行、动手前要签字);Bash = 万用电动工具(能接各种头、最危险,用前先把复合命令拆开逐段检查);Agent = 雇个助手(雇他不用审批,但他真去动生产数据时该审批照样审批 → 权限下放)。四把工具,一条主线:越能改变世界的工具,把关越严。
L01

工具全家福

工具都在 packages/builtin-tools/src/tools/<名字>/,注册入口 getAllBaseTools()src/tools.ts:218)。约 60 个,核心的:

模型可见名用途
Read读文件/图片/PDF/notebook
Write / Edit创建覆盖 / 就地修改文件
Bash执行 shell 命令
Glob / Grep按文件名找 / 按内容正则搜(ripgrep)
Agent(别名 Task)委派工作给子代理
WebFetch / WebSearch抓 URL / 联网搜索
TodoWrite / Task*任务清单管理
Skill / EnterPlanMode / LSP …技能调用 / 规划模式 / 代码智能
名字对不上目录名,别慌 工具的真实 name(模型看到的)常和目录名不同——目录叫 FileReadTool/ 但工具名是 Read,目录 AgentTool/ 但主名是 AgentTask 只是向后兼容的别名)。读代码以工具文件里的 name 字段为准。
L02

Read — 只读工具的范例(真实代码)

FileReadTool(name: Read)

packages/builtin-tools/src/tools/FileReadTool/FileReadTool.ts
isReadOnly → trueisConcurrencySafe → truemaxResultSizeChars: 100000

贴真实代码(FileReadTool.ts:342)——正好印证 Day 07 讲的几个点:

maxResultSizeChars: 100_000,       // 结果超 100KB 就落盘(L08)
strict: true,
async description() {              // ← 给 UI/权限弹窗看的(简短)
  return DESCRIPTION
},
async prompt() {                  // ← ★ 给模型看的(详细,教它怎么用;Day 07 L07)
  const limits = getDefaultFileReadingLimits()
  // ...拼一段告诉模型"大文件用 offset/limit、行号格式"等的说明
  return renderPromptTemplate(...)
},
isConcurrencySafe() { return true },   // ← 可并发(多个 Read 同时跑)
isReadOnly() { return true },          // ← 只读
看到没——Read 同时定义了 description()prompt(),正好是 Day 07 L07 那个反直觉点的实锤:description()return DESCRIPTION(一句话,给人看);prompt() 拼一大段(给模型看,教它大文件怎么分页读)。而它主动声明 isReadOnly:true + isConcurrencySafe:true——所以 Day 07 L08 的并发分区里,多个 Read 会被并发执行。
权限:398)委托给共享函数 checkReadPermissionForToolfilesystem.ts),判定简化版:UNC/可疑路径 → ask;deny 规则 → deny;在工作目录内 → allow(最常见放行路径,所以读项目里的文件通常不弹窗);否则 → ask。Day 09 细讲。输出是"判别联合"(text/image/notebook/pdf/file_unchanged)——Read 不只读文本,还能读图片、PDF、Jupyter notebook。
L03

Read 的去重巧思:省 token

Read 执行时(:494)有个聪明优化:如果模型重复读同一个文件的同一范围、且文件没改过(mtime 未变),就返回一个 file_unchanged 存根,而不是把整个文件内容再塞一遍。源码注释在 :339 也点了 100KB 落盘的动机("减少长会话的内存压力")。

📝 举个例子:去重省了多少 第 1 次 Read(utils.ts) → 返回全文 8000 字(记下它的 mtime)。
模型改了别处、又想复查,第 3 次 Read(utils.ts),而文件 mtime 没变 → 返回 { type:"file_unchanged" }几个字,不是再塞 8000 字)。
一次省 ~8000 字 token。长会话里反复读同一批文件,省下的是真金白银 + 更晚触发压缩(Day 10)。
为什么这么做?(大白话) Agent 干活时经常反复读同一个文件(读一次 → 改一点 → 再读确认……)。每次都把全文塞进对话,既浪费 token(花钱)又撑大上下文(更容易触发压缩 Day 10)。检测到"你读的这个文件跟上次一模一样",就回一句"没变化"(file_unchanged),模型用之前读到的内容即可。Day 06 讲的 QueryEngine 里那个 readFileState 字段,就是记"读过哪些文件、什么版本",供这个去重用的。这是"省 token = 省钱 + 省上下文"的典型优化。
mtime 是什么? 文件的"最后修改时间"(modification time)。系统给每个文件记着它上次被改是什么时候。比较"上次读时的 mtime"和"现在的 mtime",一样就说明文件没动过——所以能安全地说"没变化"。

另一个细节 backfillObservableInput(Day 07 L08 第 4 关提过):把 file_path 展开成绝对路径,让 hook 白名单无法用 ~/相对路径绕过——安全考量。

L04

Edit — 写工具的 schema(真实代码)

FileEditTool(name: Edit)

packages/builtin-tools/src/tools/FileEditTool/
会写文件默认非并发(fail-closed)

真实 schema(FileEditTool/types.ts:6)——注意每个字段的 .describe(...),这些描述会随 schema 一起发给模型(Day 07 讲的"zod 一处声明两处受益"):

z.strictObject({
  file_path: z.string().describe('The absolute path to the file to modify'),
  old_string: z.string().describe('The text to replace'),
  new_string: z.string().describe('The text to replace it with (must be different from old_string)'),
  replace_all: semanticBoolean(z.boolean().default(false).optional())
    .describe('Replace all occurrences of old_string (default false)'),
})
Edit 的机制是"把文件里的 old_string 精确替换成 new_string"(不是按行号)。z.strictObject 的 "strict" = "只允许这几个字段,多传别的就报错"。.describe(...) 里的英文就是模型看到的"这个参数是干嘛的"。replace_all 默认 false = "只替换第一处"。
为什么 Edit 用"字符串替换"而非"行号"? 因为行号很脆——文件一改,行号全变了。而"把这段旧文本换成这段新文本"更稳、更像人改代码的方式。代价是 old_string 必须在文件里唯一且精确匹配(连空格缩进都要对),否则改不动或改错地方——这也是 Edit 有 validateInput 校验(如 old_string===new_string 就报错)的原因。
L05

safetyCheck:危险文件清单(真实代码)

Edit 的权限里有个"安全检查"——命中危险文件/目录就强制 ask,即使 bypassPermissions 模式也照样问(Day 09 会讲这个"不可绕过")。真实清单在 src/utils/permissions/filesystem.ts:57

export const DANGEROUS_FILES = [
  '.gitconfig', '.gitmodules', '.bashrc', '.bash_profile',
  '.zshrc', '.zprofile', '.profile', '.ripgreprc',
  '.mcp.json', '.claude.json',
]
export const DANGEROUS_DIRECTORIES = [
  '.git', '.vscode', '.idea', '.claude',
]
源码注释(:55)说明了原因:"These files can be used for code execution or data exfiltration."(这些文件可被用于代码执行或数据泄露)
为什么这几个文件即使"绕过权限"也要问? 因为改它们后果严重:.bashrc/.zshrc 是你的 shell 启动脚本——被写入恶意命令,你下次开终端就执行了;.mcp.json 被注入恶意 MCP server;.git 目录被动可能破坏仓库历史。这些是"改错了可能毁掉环境或泄密"的雷区。所以无论你多信任、开了多宽的放行模式,动这几个文件仍会停下来问你——这就是"最大放行模式也保留最小安全底线",Day 09 的核心设计。
L06

Bash — tree-sitter 拆命令判权限

BashTool(name: Bash)

packages/builtin-tools/src/tools/BashTool/
maxResultSizeChars: 30000只读命令才可并发

Bash 的并发标志很聪明(BashTool.tsx:571,真实代码):

isConcurrencySafe(input) {
  return this.isReadOnly?.(input) ?? false;   // 只有只读命令才可并发
},
isReadOnly(input) {
  // 判断命令是不是只读(find/grep/cat/ls 等白名单)
}

Bash 权限是全仓最复杂的,因为一条命令可能是复合的(a && b | c)。它用 tree-sitter 做语法解析bashSecurity.ts,源码里多处 "Tree-sitter path" 注释),把复合命令拆成一个个子命令,逐个匹配 deny/ask/allow 规则;解析不了(too-complex)就fail-safe 返回 askBashTool.tsx:587 注释:"parse-unavailable / too-complex: fail safe by running the hook")。

为什么要 AST 拆命令、而不是简单字符串匹配?(安全关键) 如果只做字符串匹配,echo hi; rm -rf / 这种"前面无害、后面危险"的复合命令就能骗过规则(因为整串不完全等于某条 deny 规则)。用 tree-sitter 把命令解析成语法树、逐个子命令判权限,才能准确拦住藏在复合命令里的危险操作。解析不了就宁可问(fail-safe)。tree-sitter 是一个把代码/命令解析成语法树的库。"用真正的解析器而非正则来做安全判断"——正则在安全场景常有绕过漏洞,这是很重要的一课。
先拆成语法树,再逐个子命令判权限 echo hi && cat a.txt && rm -rf / tree-sitter 解析成语法树,拆出 3 个子命令 ↓ echo hi只读白名单 → allow cat a.txt只读 → allow rm -rf /危险 → ✋ 拦下! 若只做整串字符串匹配,这条"前善后恶"的命令就能蒙混过关;逐段判才拦得住
图注:复合命令被解析成语法树、拆成子命令逐个过闸;只要有一段危险就整体拦下。解析不了则宁可问(fail-safe)。
L07

Agent — 权限下放(真实代码)

AgentTool(name: Agent,别名 Task)

packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx
isReadOnly → trueisConcurrencySafe → true

最特别的工具——它的"执行"是递归跑一个完整子代理(Day 15)。看它的真实标志和权限(AgentTool.tsx:1458):

isReadOnly() {
  return true; // delegates permission checks to its underlying tools
},
isConcurrencySafe() {
  return true;
},
async checkPermissions(input, context) {
  const appState = context.getAppState();
  // 只有 ant 构建 + auto 模式才走分类器;其它模式一律放行子代理生成
  if (process.env.USER_TYPE === 'ant' && appState.toolPermissionContext.mode === 'auto') {
    return { behavior: 'passthrough', message: '...' };
  }
  return { behavior: 'allow', updatedInput: input };   // ← 默认直接放行
},
最关键的是那行注释:isReadOnly() return true; // delegates permission checks to its underlying tools——"标只读,因为它把权限检查下放给它内部调用的工具"
"权限下放"是什么意思?(重要) Agent 明明能让子代理改文件,为什么标 isReadOnly:true、还默认放行?因为——父代理调 Agent 工具本身不弹权限(此刻子代理还没干活,只是"派了个活");真正的权限门控发生在子代理内部——当子代理去调 Read/Edit/Bash 时,那些工具各自的权限检查照常生效(Day 09)。好比你雇了个助手:雇他不用审批,但他去动生产数据库时该审批还得审批。子代理跑完,取它最后一条 assistant 文本作为 Agent 工具的结果回给父代理(Day 15 细讲)。

👶 小白:Agent 能让子代理改文件,标 isReadOnly:true 不就骗过权限了吗?这不危险?

👨‍🏫 老师:不危险,因为门没被拆掉,只是挪了位置。"派活"这个动作(调 Agent 工具)确实不改任何东西、所以标只读;但子代理真去干活时,它调的每一把 Read/Edit/Bash 各自的权限检查照常触发。就像雇助手:签雇佣合同不用批,但他去动生产库、刷你的卡,那一刻该弹的审批一个都不少。权限守在"真正动手"那一层,不在"派活"这一层。

L08

结果落盘:maxResultSizeChars

注意各工具的 maxResultSizeChars 不同:Read 是 100_000、Bash 是 30_000(都是真实代码里的值)。这个字段控制"结果多大就落盘"(toolExecution.ts:1478,Day 07 管线第 8 关):

  • 结果 ≤ 上限:完整塞进对话给模型。
  • 结果 > 上限:存到磁盘,只给模型"预览 + 文件路径",模型想看全部再去 Read 那个路径。
为什么要落盘?为什么 Bash 上限比 Read 小? 一个 Bash 命令可能吐几十万字(比如 npm install 的日志)。全塞进对话会瞬间撑爆上下文、花一大笔 token。落盘 + 只给预览,让模型看到"大概是什么 + 完整在哪",需要细看再按需读。Bash 的上限(30K)比 Read(100K)小,因为命令输出更容易失控膨胀——读文件你知道大概多大,跑命令的输出可能爆炸。这是"防止单个工具结果撑爆上下文"的又一道闸,和 Day 10 的上下文管理配套。
L09

今日小结 + 动手

🧠 今天你应该能回答

  • Read 为什么能并发、Edit 为什么串行?(isReadOnly/isConcurrencySafe,fail-closed)
  • Read 的 file_unchanged 去重靠什么、省什么?(mtime + readFileState,省 token/上下文)
  • Edit 为什么用字符串替换而非行号?
  • 为什么改 .git/.bashrc/.mcp.json 即使 bypass 也要问?(safetyCheck 危险清单)
  • Bash 为什么用 tree-sitter AST 拆命令、而非字符串匹配?
  • Agent 为什么标 isReadOnly?权限在哪真正生效?(下放到子代理内部)
  • maxResultSizeChars 干什么?为什么 Bash 比 Read 小?

✋ 动手:对着真实代码读一遍

# 1. Read 的标志 + prompt/description(L02)
sed -n '338,382p' packages/builtin-tools/src/tools/FileReadTool/FileReadTool.ts

# 2. Edit 的 schema(L04)
sed -n '1,20p' packages/builtin-tools/src/tools/FileEditTool/types.ts

# 3. safetyCheck 危险清单(L05)
sed -n '55,80p' src/utils/permissions/filesystem.ts

# 4. Bash 的 tree-sitter 权限(L06)
grep -n "Tree-sitter path\|too-complex\|isReadOnly" packages/builtin-tools/src/tools/BashTool/*.ts* | head

# 5. Agent 的权限下放(L07)
sed -n '1458,1490p' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx
明天预告 · Day 09:工具执行前那道"权限"关卡是安全核心。Day 09 加深版会贴 permissions.ts 的真实判定顺序代码——5 种模式、step 1a→3 的过闸顺序(哪些连 bypass 都拦不住)、审批弹窗怎么用"await 一个 Promise"实现。

← Day 07 Tool 接口 Day 09 · 权限系统 →