内置工具精读
Day 07 讲了工具接口"长什么样",今天打开 packages/builtin-tools 看几个工具"真怎么填"——贴真实代码:Read(只读)、Edit(写)、Bash(最复杂权限)、Agent(子代理)。昨天(Day 07)看的是"工具箱的规格标准",今天真把箱子打开,拆开四把具体工具看内部,Day 09 再专讲它们动手前的"签字"。
工具全家福
工具都在 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/ 但主名是 Agent(Task 只是向后兼容的别名)。读代码以工具文件里的 name 字段为准。Read — 只读工具的范例(真实代码)
FileReadTool(name: Read)
贴真实代码(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 }, // ← 只读
description() 和 prompt(),正好是 Day 07 L07 那个反直觉点的实锤:description() 就 return DESCRIPTION(一句话,给人看);prompt() 拼一大段(给模型看,教它大文件怎么分页读)。而它主动声明 isReadOnly:true + isConcurrencySafe:true——所以 Day 07 L08 的并发分区里,多个 Read 会被并发执行。:398)委托给共享函数 checkReadPermissionForTool(filesystem.ts),判定简化版:UNC/可疑路径 → ask;deny 规则 → deny;在工作目录内 → allow(最常见放行路径,所以读项目里的文件通常不弹窗);否则 → ask。Day 09 细讲。输出是"判别联合"(text/image/notebook/pdf/file_unchanged)——Read 不只读文本,还能读图片、PDF、Jupyter notebook。Read 的去重巧思:省 token
Read 执行时(:494)有个聪明优化:如果模型重复读同一个文件的同一范围、且文件没改过(mtime 未变),就返回一个 file_unchanged 存根,而不是把整个文件内容再塞一遍。源码注释在 :339 也点了 100KB 落盘的动机("减少长会话的内存压力")。
Read(utils.ts) → 返回全文 8000 字(记下它的 mtime)。模型改了别处、又想复查,第 3 次
Read(utils.ts),而文件 mtime 没变 → 返回 { type:"file_unchanged" }(几个字,不是再塞 8000 字)。一次省 ~8000 字 token。长会话里反复读同一批文件,省下的是真金白银 + 更晚触发压缩(Day 10)。
file_unchanged),模型用之前读到的内容即可。Day 06 讲的 QueryEngine 里那个 readFileState 字段,就是记"读过哪些文件、什么版本",供这个去重用的。这是"省 token = 省钱 + 省上下文"的典型优化。另一个细节 backfillObservableInput(Day 07 L08 第 4 关提过):把 file_path 展开成绝对路径,让 hook 白名单无法用 ~/相对路径绕过——安全考量。
Edit — 写工具的 schema(真实代码)
FileEditTool(name: Edit)
真实 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)'),
})
old_string 精确替换成 new_string"(不是按行号)。z.strictObject 的 "strict" = "只允许这几个字段,多传别的就报错"。.describe(...) 里的英文就是模型看到的"这个参数是干嘛的"。replace_all 默认 false = "只替换第一处"。old_string 必须在文件里唯一且精确匹配(连空格缩进都要对),否则改不动或改错地方——这也是 Edit 有 validateInput 校验(如 old_string===new_string 就报错)的原因。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 的核心设计。Bash — tree-sitter 拆命令判权限
BashTool(name: Bash)
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 返回 ask(BashTool.tsx:587 注释:"parse-unavailable / too-complex: fail safe by running the hook")。
echo hi; rm -rf / 这种"前面无害、后面危险"的复合命令就能骗过规则(因为整串不完全等于某条 deny 规则)。用 tree-sitter 把命令解析成语法树、逐个子命令判权限,才能准确拦住藏在复合命令里的危险操作。解析不了就宁可问(fail-safe)。tree-sitter 是一个把代码/命令解析成语法树的库。"用真正的解析器而非正则来做安全判断"——正则在安全场景常有绕过漏洞,这是很重要的一课。Agent — 权限下放(真实代码)
AgentTool(name: Agent,别名 Task)
最特别的工具——它的"执行"是递归跑一个完整子代理(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——"标只读,因为它把权限检查下放给它内部调用的工具"。isReadOnly:true、还默认放行?因为——父代理调 Agent 工具本身不弹权限(此刻子代理还没干活,只是"派了个活");真正的权限门控发生在子代理内部——当子代理去调 Read/Edit/Bash 时,那些工具各自的权限检查照常生效(Day 09)。好比你雇了个助手:雇他不用审批,但他去动生产数据库时该审批还得审批。子代理跑完,取它最后一条 assistant 文本作为 Agent 工具的结果回给父代理(Day 15 细讲)。👶 小白:Agent 能让子代理改文件,标 isReadOnly:true 不就骗过权限了吗?这不危险?
👨🏫 老师:不危险,因为门没被拆掉,只是挪了位置。"派活"这个动作(调 Agent 工具)确实不改任何东西、所以标只读;但子代理真去干活时,它调的每一把 Read/Edit/Bash 各自的权限检查照常触发。就像雇助手:签雇佣合同不用批,但他去动生产库、刷你的卡,那一刻该弹的审批一个都不少。权限守在"真正动手"那一层,不在"派活"这一层。
结果落盘:maxResultSizeChars
注意各工具的 maxResultSizeChars 不同:Read 是 100_000、Bash 是 30_000(都是真实代码里的值)。这个字段控制"结果多大就落盘"(toolExecution.ts:1478,Day 07 管线第 8 关):
- 结果 ≤ 上限:完整塞进对话给模型。
- 结果 > 上限:存到磁盘,只给模型"预览 + 文件路径",模型想看全部再去 Read 那个路径。
npm install 的日志)。全塞进对话会瞬间撑爆上下文、花一大笔 token。落盘 + 只给预览,让模型看到"大概是什么 + 完整在哪",需要细看再按需读。Bash 的上限(30K)比 Read(100K)小,因为命令输出更容易失控膨胀——读文件你知道大概多大,跑命令的输出可能爆炸。这是"防止单个工具结果撑爆上下文"的又一道闸,和 Day 10 的上下文管理配套。今日小结 + 动手
🧠 今天你应该能回答
- 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
permissions.ts 的真实判定顺序代码——5 种模式、step 1a→3 的过闸顺序(哪些连 bypass 都拦不住)、审批弹窗怎么用"await 一个 Promise"实现。