数据截至 (上游 commit d87b272aec54)
工具层:声明式工具、按需披露,以及「把话落到文件上」
30 秒导读: 模型只会输出文本。工具层负责把「模型说的那段 JSON」变成真实动作——改一个文件、跑一条命令、调一个外部服务——并且保证这个动作是可校验的、可预览的、可追溯的、体积可控的。本章讲这层的抽象基座、注册与发现机制,以及几个关键工具的落地细节。
本章只讲工具本身。谁批准这次调用见 04 安全护栏;工具调用在主循环里的位置见 01 主循环;agent / workflow 这类工具见 06 多智能体。完整的分工在 §14。
1. 这一层在干嘛(零基础也能懂)
一句话定义: 工具层 = 一组「模型可以点名调用的函数」,外加管理它们的注册表、发现机制和结果预算。
它要解决的问题。 你在终端里对 AI 说「把 config.ts 里的超时改成 30 秒」。模型不能直接碰你的磁盘,它只能回一段结构化的话:
{ "name": "edit",
"args": { "file_path": "/proj/config.ts",
"old_string": "timeout: 5000", "new_string": "timeout: 30000" } }
工具层要接住这段话,然后回答四个很不浪漫的问题:
| 问题 | 工具层的回答机制 |
|---|---|
| 这个工具存在吗、参数合法吗? | 注册表 + JSON Schema 校验(build()) |
| 它会碰哪些文件、要不要问人? | toolLocations() / getDefaultPermission() / getConfirmationDetails() |
| 真去执行会发生什么? | execute(),返回 ToolResult |
| 结果太大怎么办? | 落盘换成一个指针(truncateToolOutput / 批预算) |
它提供的工具大致分五类:
| 类别 | 代表工具 | 干什么 |
|---|---|---|
| 文件读写 | read_file edit write_file notebook_edit | 把改动精确落到字节上 |
| 命令执行 | run_shell_command | 跑 shell,前台/后台 |
| 检索 | grep_search glob list_directory | 找文件、找内容 |
| 任务与协作 | todo_write task_create send_message ask_user_question | 记待办、派活、问人 |
| 外部接入 | MCP 工具、read_mcp_resource | 接第三方服务 |
一句话直觉: 把工具层想成餐厅的传菜口。模型是坐在包间里的客人,只能写菜单条子;传菜口负责看懂条子、确认厨房真有这道菜、必要时先端出来给你看一眼再上桌,最后端上来的盘子还不能大到把桌子压塌。
2. 顶层全景(它大概怎么转)
怎么读这张图: 从上到下是一次工具调用的生命周期;启动期在最上面,结果预算在最下面。
┌──────────────── 启动期 ────────────────┐
│ config.createToolRegistry() │
│ registerLazy(名字, 工厂函数) │ ← 只登记名字,不 import 模块
│ MCP 发现 / 命令发现 → registerTool() │
└───────────────┬────────────────────────┘
▼
┌──────────────┐
│ ToolRegistry │ tools / factories / revealedDeferred
└───┬──────┬───┘
①声明列表 │ │ ②按需披露
getFunctionDeclarations │ ToolSearch(select: 或 关键词)
(藏起 shouldDefer) │ → revealDeferredTool()
▼
┌──────────────┐
模型说话 ────▶ │ build(参数) │ ── 校验失败 → 直接回错误
└──────┬───────┘
▼ ToolInvocation
┌───────────────────────┐
│ getDefaultPermission │ ── ask → 确认弹窗(见 04 章)
│ getConfirmationDetails│
└──────────┬────────────┘
▼
execute() ──▶ ToolResult { llmContent, returnDisplay }
│
▼
③预算:单工具截断 → 批预算 offload → 落盘留指针
部件一句话职责:
| 部件 | 干什么 | 文件 |
|---|---|---|
DeclarativeTool | 工具的抽象基类:名字、schema、kind、预算钩子 | packages/core/src/tools/tools.ts:194 |
BaseDeclarativeTool | 加上「先 schema 校验再造 invocation」的 build() | packages/core/src/tools/tools.ts:389 |
BaseToolInvocation | 一次已校验调用的载体,带默认权限与确认框 | packages/core/src/tools/tools.ts:82 |
ToolRegistry | 注册、懒加载、禁用、MCP 增删、声明列表生成 | packages/core/src/tools/tool-registry.ts:184 |
ToolNames | 工具名常量表,防循环依赖 | packages/core/src/tools/tool-names.ts:20 |
ToolSearchTool | 把被藏起来的工具 schema 按需喂给模型 | packages/core/src/tools/tool-search.ts:449 |
coreToolScheduler 的预算段 | 单条截断 + 一批 offload | packages/core/src/core/coreToolScheduler.ts:4169 |
3. 抽象基座:为什么要拆成「工具」和「一次调用」
3.1 它要解决的小问题
一个工具类天然有两种状态:静态的自我描述(我叫什么、参数长什么样)和动态的一次具体调用(这次要改哪个文件)。把两者塞进同一个类,「校验」和「执行」就会纠缠:validate() 通过之后 execute() 还得把参数再解析一遍。
Qwen Code 的做法:拆成两个对象。
DeclarativeTool(单例,注册在 registry 里)
│ build(params) ← 这一步只做校验
▼
ToolInvocation(一次性,参数已可信)
├─ getDescription() 给人看的一句话
├─ toolLocations() 我会碰哪些路径
├─ getDefaultPermission() 我天然安全吗
├─ getConfirmationDetails() 要问人的话,弹窗长什么样
└─ execute() 真干活
build() 的实现只有五行,语义却很硬:校验不过就抛,抛出去的就绝不可能被执行(packages/core/src/tools/tools.ts:393,BaseDeclarativeTool.build)。schema 校验走 SchemaValidator.validate,子类再用 validateToolParamValues 加自己的业务校验(packages/core/src/tools/tools.ts:401)。
3.2 原理演示
这段演示「先校验后执行」这个分层带来的好处:invocation 拿到手时,参数已经不需要再防御性判断了。
// 示意,非源码
class GreetTool extends BaseDeclarativeTool {
// 静态描述:名字 + schema 只写一次
constructor() { super('greet', 'Greet', '打招呼', Kind.Other,
{ type: 'object', properties: { who: { type: 'string' } }, required: ['who'] }); }
validateToolParamValues(p) { // schema 之外的业务规则
return p.who.trim() ? null : 'who 不能是空白';
}
createInvocation(p) { return new GreetInvocation(p); } // 此时 p 已可信
}
// 重点看:GreetInvocation.execute 里不需要再判空
3.3 Kind 不只是标签
Kind 是工具的动作类型(packages/core/src/tools/tools.ts:861),有两个派生集合直接影响调度行为:
| 常量 | 内容 | 用途 |
|---|---|---|
MUTATOR_KINDS | Edit Delete Move Execute | 标记「有副作用」 |
CONCURRENCY_SAFE_KINDS | Read Search Fetch | 只有这三种能并发跑 |
CONCURRENCY_SAFE_KINDS 刻意把 Think 排除在外——因为 save_memory / todo_write 这类「思考类」工具其实会写盘(packages/core/src/tools/tools.ts:887 的注释写明了这点)。这是个容易踩的坑:分类名叫 Think 不代表它是纯函数。
3.4 ToolResult:两套内容,各说各话
ToolResult
├─ llmContent → 进模型历史的事实(可以是 Part[],含图片)
├─ returnDisplay → 给人看的渲染物(字符串 / FileDiff / TodoList / …)
├─ resultFilePaths → 这次真正碰到/发现的路径,喂给调度器做路径激活
└─ error? → 存在即失败
定义在 packages/core/src/tools/tools.ts:443。returnDisplay 是个联合类型(ToolResultDisplay,tools.ts:625),所以 Edit 能返回结构化的 FileDiff 让终端画彩色 diff,而不是塞一坨文本。
resultFilePaths 的典型用法在 grep:搜到的文件路径不仅回给模型,还被调度器消费(packages/core/src/tools/grep.ts:284;调度器侧 packages/core/src/core/coreToolScheduler.ts:3552)。
3.5 三个「预算与安全」钩子
DeclarativeTool 上还挂着三个不显眼但很关键的可覆写属性:
| 钩子 | 默认 | 谁覆写了它 |
|---|---|---|
maxOutputChars | undefined(用全 局阈值) | Shell 30_000、Grep 20_000、MCP 500_000、ReadFile Infinity |
truncateKeep | 'both'——留首尾、掐中间(head 1/5 + tail 4/5,utils/truncation.ts:68) | 全仓只有后台 agent 覆写成 'tail'(tools/agent/agent.ts:421) |
toAutoClassifierInput | ''(默认不泄露参数) | Edit / Shell / WebFetch 等按需返回脱敏投影 |
truncateKeep 这一行有个反直觉的地方要点破:'both' 保留的是首尾、掐掉的是中间,'tail' 才是「掐头留尾」。另外基类那段 doc 注释里举的例子「'head'(beginning, e.g. shell)」已经和现实脱节(tools.ts:249-251)——ShellTool 既不覆写 truncateKeep,也不走 'head':它在工具内部显式传 keep: 'both',注释还写了理由——命令开头和尾部的 exit/error 摘要都得保住(tools/shell.ts:2721)。
toAutoClassifierInput 的默认值是空字符串而非原始参数——这是故意 fail-closed:第三方 MCP 工具的参数里可能有 API key,不能默认灌进分类器的 prompt(packages/core/src/tools/tools.ts:279)。Edit 的覆写只截 300 字预览(packages/core/src/tools/edit.ts:842,toAutoClassifierInput)。
4. 注册表:懒加载、禁用、改名
4.1 启动时几乎什么都不 import
Config.createToolRegistry()(packages/core/src/config/config.ts:5780)注册的不是工具实例,而是工厂函数:
await registerLazy(ToolNames.READ_FILE, async () => {
const { ReadFileTool } = await import('../tools/read-file.js');
return new ReadFileTool(this);
});
registerFactory 只把名字和闭包放进 factories map(packages/core/src/tools/tool-registry.ts:287)。真正的 import() 要等到 ensureTool(name) 第一次被调用(tool-registry.ts:303)。
ensureTool 里有个容易被忽视的并发处理:in-flight promise 去重。同名工具的并发 ensureTool 共享同一个 promise,工厂绝不会跑第二次。
ensureTool(name)
├─ tools 里有? → 直接返回(顺手删掉残留 factory)
├─ inflight 里有? → 返回同一个 promise(关键:不重复执行工厂)
├─ factories 里没有? → undefined
└─ 执行工厂 → 存进 tools → 删 factory → 删 inflight
需要一次性看全部工具(如生成完整声明列表)时才调 warmAll()(tool-registry.ts:344)。getAllTools() / getFunctionDeclarationsFiltered() 在还有未加载工厂时会打 warning,提醒调用者先 warm。
4.2 禁用与改名
registerTool 里有两次禁用检查,不是冗余:
- 进门先查
disabledTools(tool-registry.ts:236)。 - 若名字与已有工具/工厂撞车且来源是 MCP,改名成
mcp__<server>__<tool>(tool-registry.ts:258,asFullyQualifiedTool),改完再查一次禁用集(tool-registry.ts:274)。
第二次检查堵的是这个洞:运维禁用的是改名后的那个名字,但改名发生在第一次检查之后。
4.3 声明列表怎么生成
// tool-registry.ts:678 getFunctionDeclarations
if (!includeDeferred && tool.shouldDefer && !tool.alwaysLoad
&& !this.revealedDeferred.has(tool.name)) return; // 跳过
declarations.push(tool.schema);
三个开关的组合决定了一个工具在不在初始列表里:
| shouldDefer | alwaysLoad | 已 reveal | 在初始声明列表? |
|---|---|---|---|
| false | — | — | 在 |
| true | true | — | 在(如 tool_search 自己) |
| true | false | 否 | 不在 |
| true | false | 是 | 在(本会话内) |
5. 按需披露:ToolSearch
5.1 它要解决的小问题
一个工具的 schema 动辄几百 token。接上五个 MCP server 就是几十个工具,光声明列表就能吃掉上万 token——而其中绝大多数这一轮根本用不上。
思路: 低频工具在启动时只报名字,不报 schema;模型想用了再来取。取的动作本身就是一个工具,叫 tool_search。
启动 ─▶ system-reminder 列出名字清单
"cron_create, cron_list, monitor, mcp__slack__send, …
reachable via tool_search"
│
模型:"我需要定时任务"
▼
tool_search { query: "select:cron_create" }
│
├─ ensureTool 加载实例
├─ revealDeferredTool 打标记
├─ geminiClient.setTools() 重发声明列表 ← 真正生效的一步
└─ 返回 <functions>{schema}</functions> ← 只是给模型看一眼
│
下一轮 ─▶ 模型可以正常 function-call cron_create
名字清单由 buildDeferredToolsReminder 生成(packages/core/src/utils/environmentContext.ts:192),数据源是 ToolRegistry.getDeferredToolSummary()(tool-registry.ts:739,排序保证提示词稳定)。
哪些工具默认被藏起来?看构造函数第 8 个位置参数即可,例如 cron_create(tools/cron-create.ts:153)、monitor(tools/monitor.ts:719)、send_message(tools/send-message.ts:291)、全部 MCP 工具(tools/mcp-tool.ts:524) 。反例:ask_user_question 明确不延迟,注释写着「保持常驻,好让模型优先用结构化澄清 UX 而不是干说」(tools/askUserQuestion.ts:368)。
5.2 两种查询模式
select: 模式是精确取用(packages/core/src/tools/tool-search.ts:160)。它处理了三件琐碎但真实的事:
- 去重(模型重复写同一个名字);
- 剥一层引号——模型常把提示里的
"cron_list"原样粘回来(stripMatchingQuotes,tool-search.ts:537); - 超出
max_results的名字不是静默丢弃,而是列进结果里告诉模型「这些没加载,再发一次」(tool-search.ts:429)。
关键词模式先分词、去停用词、支持 +必须词 前缀,再打分。打分表(tool-search.ts:47-53):
| 命中位置 | 内置工具 | MCP 工具 |
|---|---|---|
名字完全相等 / _x .x 后缀 | 10 | 12 |
| 名字子串 | 5 | 6 |
searchHint 按词命中 | 4 | 4 |
| description 子串 | 2 | 2 |
| 动作近义词(cancel/delete/stop…) | 6 | 6 |
MCP 之所以加权,是因为它们永远是 deferred 的,搜索是模型唯一能够到它们的路(tool-search.ts:44 的注释)。MCP 工具的 searchHint 被设成 mcp <serverName>,所以用户说「发个 slack 消息」时 server 名能加分(tools/mcp-tool.ts:529)。
scoreTool 是导出的纯函数(tool-search.ts:562),方便测试。
5.3 三个不显眼但要命的细节
候选集只含「未 reveal 的 deferred 工具」(collectCandidates,tool-search.ts:241)。已在声明列表里的工具再被搜出来纯属浪费 token,还可能诱导模型重复取用。但 select: 模式不受此限——模型可能只是想复查 schema。
reveal 与 setTools() 必须原子。 如果 setTools() 失败,代码会把这次新 reveal 的名字全部回滚(tool-search.ts:395,unrevealDeferredTool),并把错误如实返回给模型。理由很实在:registry 说「已披露」而 API 的声明列表里没有,关键词搜索会因为 isDeferredToolRevealed 把它排除掉,这工具就再也够不着了,只能 /clear。
schema 里的 < 被转义。 返回的伪 XML 外壳是 <functions><function>{json}</function></functions>;某个工具的 description 里若含 </function> 就会提前闭合外层标签。做法是把 JSON 字符串里所有 < 替换成它的 JSON unicode 转义形式(反斜杠 + u003c,见 tool-search.ts:419)——模型按 JSON 解码时它还原成 <,但作为外壳里的原始文本时不再是标签起始。
6. 把话落到文件上:编辑与读写
这是整个工具层工程含量最高的一段。难点不是「写文件」,而是模型给的 old_string 几乎从不和磁盘上的字节一字不差,而且模型可能压根没看过这个文件。
6.1 先读后写:checkPriorRead
要解决的小问题: 模型凭想象写 old_string,或者根据十轮之前读过的旧内容去改一个已经被别人改动的文件。
约束: 想改一个已存在的文件,必须在本会话内读过它,而且读之后磁盘没变过。
判定逻辑集中在 packages/core/src/tools/priorReadEnforcement.ts:155(checkPriorRead),返回一个结构化 PriorReadDecision 而不是抛异常——因为三个调用点想要的形状不同(Edit 要塞进 CalculatedEdit.error,确认路径要抛,execute 要返回 ToolResult)。
stat(path)
├─ ENOENT + expectExisting=false → ok(这是新建 文件)
├─ ENOENT + expectExisting=true → 拒:文件读完后消失了
├─ 其它 stat 错误(EACCES…) → 拒(fail-closed,不能因为一次瞬时错误放行)
├─ 是目录 → 拒 TARGET_IS_DIRECTORY
├─ 不是常规文件(FIFO/设备) → 拒
└─ cache.check(stats)
├─ fresh + 读过 + 内容可当文本 → ok
├─ stale → 拒:改过了,重读
├─ fresh 但 lastReadCacheable=false → 拒:二进制/图片/PDF/notebook,重读也没用
└─ unknown → 拒:这个会话没读过
这里有一条被踩出来的边界: 曾经有过 requireFullRead 选项,要求 WriteFile 必须整份读完。它被删掉了,因为输出截断阈值让「读完」在大文件上根本不可能达成,直接死锁(源码注释点名 issue #3945,priorReadEnforcement.ts:88-104)。现在的契约是:任何一次读都算数,mtime/size 漂移检查才是真正的安全网。而 lastReadCacheable 只关心「内容是不是文本」,绝不掺「读没读全」——两者混在一起曾导致部分读 一个 .kt 文件后再编辑,被误报成「二进制载荷」(注释点名 issue #3964,tools/read-file.ts:234)。
同一次编辑里,这个检查跑三遍。 不是偷懒复制粘贴,是三个不同的 TOCTOU 窗口:
| 时机 | 位置 | 堵什么 |
|---|---|---|
| 读内容之前 | edit.ts:173 | 不让 NO_OCCURRENCE_FOUND 这类报错变成「无需 Read 的内容探测器」 |
| 读内容之后 | edit.ts:232 | stat 与 read 之间文件被改,读到的是模型没见过的字节 |
| 真写之前 | edit.ts:565 | 用户确认可能耗时很久,这段时间里文件可能已经变了 |
第三次检查还带 expectExisting: !isNewFile——原地编辑时文件消失应当拒绝,而不是掉进「新建文件」分支用旧字节重造一个(edit.ts:577)。源码诚实地承认残留竞态仍在:最后那次 stat 和 writeTextFile 之间仍有窗口,要彻底关掉得上原子写或写后哈希校验(edit.ts:546-557)。
6.2 模糊归一:让 old_string 落到真实字节上
要解决的小问题: 模型输出的引号可能是弯引号、连字符可能是长破折号、行尾可能多个空格。字面 indexOf 一律找不到。
做法是一条降级链,命中即停(packages/core/src/utils/editHelper.ts:241,findMatchedSlice):
① 字面 indexOf ← 最快,最常见
↓ 没中
② 字符归一后再 indexOf ← 弯引号/破折号/各种空格 → ASCII
↓ 没中
③ 按行匹配
├─ 原样逐行比
├─ 每行 trimEnd 后比 ← 容忍行尾空白
└─ 归一 + trimEnd 后比
↓ 还没中
④ 若 pattern 末行是空行,砍掉再试一遍
↓ 还没中
⑤ 放弃:原样返回,交给上层报 0 occurrences
最关键的一步在命中之后: 返回的不是模型给的字符串,而是从文件里切出来的那一段真实字节(normalizeEditStrings,editHelper.ts:313)。这样后续替换操作的是磁盘上真实存在的文本,而不是一个「差不多」的近似串。
归一化只作用于 old_string,不碰 new_string——new_string 代表模型的意图,行尾空白可能是故意的(heredoc、多行字符串),注释点名 issue #1618(editHelper.ts:305-311)。
另外一个小体贴:删除操作(new_string === '')时,如果磁盘上这段文本后面跟着换行,就把换行一起吃掉,免得留下一个空行(maybeAugmentOldStringForDeletion,editHelper.ts:347)。
6.3 真正的替换与写回
applyReplacement(packages/core/src/tools/edit.ts:60)本身只有二十行,把四种情况分清楚:
| 情况 | 行为 |
|---|---|
isNewFile | 直接返回 newString |
currentContent === null 且 oldString !== '' | 返回空串(防御性) |
oldString === '' 且非新建 | 不改(拒绝「空串替换」这种爆炸操作) |
| 正常 | safeLiteralReplace——避免 $& $1 被当成正则替换模式 |
calculateEdit(edit.ts:147)负责产出所有可能的失败码,一次调用只可能命中一个:
| 错误码 | 触发条件 |
|---|---|
FILE_NOT_FOUND | 文件不存在且 old_string 非空 |
ATTEMPT_TO_CREATE_EXISTING_FILE | old_string === '' 但文件已存在 |
EDIT_NO_OCCURRENCE_FOUND | 归一化之后仍然 0 处匹配 |
EDIT_EXPECTED_OCCURRENCE_MISMATCH | 多处匹配但没开 replace_all |
EDIT_NO_CHANGE | 新旧内容相同 |
写回时保留原文件的编码、BOM 和行尾风格(edit.ts:620);只有新建文件才用配置里的默认编码,并在 Windows 上按扩展名自动判断要不要 BOM(needsUtf8Bom,edit.ts:611)。
写之前先备份,而且备份必须在最后一次新鲜度检查之前做。 这一点源码专门写了长注释:trackEdit 要 stat + copyFile,大文件上可能几百毫秒;如果放在检查之后,就把「检查 → 写」这个窗口从两个相邻 syscall 撑成了「检查 → 长时间备份 → 写」(edit.ts:509-537)。
写完之后 recordWrite 把写后 stat 记进缓存(edit.ts:648),这样后续 Read 会看到 lastReadAt < lastWriteAt,走完整读取流程而不是返回「文件没变」的占位符。
返回给模型的不只是「已更新」,还附带改动区域前后各 4 行的片段(extractEditSnippet,editHelper.ts:410),让模型不用再 Read 一遍就能确认落点。片段用双向逐行比对定位改动区间,改动超过 1000 行就不给了。
6.4 WriteFile 与 NotebookEdit 的差别
WriteFileTool(packages/core/src/tools/write-file.ts:642)走同一套先读后写,但提示词不同:Edit 允许部分读(只需看过要改的那段),WriteFile 是整体覆盖,所以提示是「读全文——覆盖会丢掉你没看过的字节」(priorReadEnforcement.ts:307-311)。
NotebookEditTool(packages/core/src/tools/notebook-edit.ts:725)反而是最严格的:它要求 lastReadWasFull === true。理由直白——渲染被截断的 notebook 意味着模型没看过后面的 cell,让它按 cell 编辑等于盲写(notebook-edit.ts:376-395)。它支持三种模式:
edit_mode | 需要 cell_id | 行为 |
|---|---|---|
replace(默认) | 是 | 替换该 cell 的源码,可顺带改 cell_type |
insert | 否(不给则插到开头) | 新建 cell |
delete | 是 | 删除该 cell |
6.5 Diff:两处,别混
| 用途 | 实现 | 粒度 |
|---|---|---|
| 单次编辑的确认预览 / 结果展示 | DEFAULT_DIFF_OPTIONS + getDiffStat(tools/diffOptions.ts:10) | 一个文件、一次改动 |
整个工作区的 /diff 视图 | fetchGitDiff / fetchGitDiffHunks(utils/gitDiff.ts:109、:285) | 全仓 git 差异 |
getDiffStat(diffOptions.ts:15)做了件有意思的事:它算两组统计。一组是「模型 提议的改动」(old → ai),一组是「用户在确认框里手改的部分」(ai → user)。这样最终能分开统计出 AI 写了多少行、人类改了多少行——ToolEditConfirmationDetails 的 inline modify 流程就靠这个(tools/tools.ts:668,DiffStat)。
gitDiff.ts 侧有一套硬上限防止 /diff 撑爆 UI:最多 50 个文件、1MB、单文件 400 行(utils/gitDiff.ts:56-62)。
7. 命令执行:Shell
7.1 一条命令的三条岔路
run_shell_command(command, is_background?, timeout?, directory?)
│
├─ 是 `sed -i` 且能模拟? ──▶ 转成「编辑」:解析 → 预览 diff → 确认 → 直接写文件
│
├─ is_background: true ──▶ 注册进 BackgroundShellRegistry,立刻返回
│
└─ 前台 ──▶ ShellExecutionService.execute
├─ node-pty 可用 → executeWithPty(有终端语义、可交互)
└─ 否则 → childProcessFallback
7.2 sed -i 为什么要被拦截
要解决的小问题: 模型很喜欢用 sed -i 's/a/b/' file 来改文件。这条命令绕过了 Edit 工具的一切保护:没有 diff 预览、没有先读后写、没有备份。
做法: 把它认出来,用 JS 模拟一遍替换,然后当成一次编辑来审批。
parseSedEditCommand(packages/core/src/utils/sedEditParser.ts:21)用shell-quote解析参数,只认「安全形状」的 sed:必须有-i,取出 pattern / replacement / flags / 是否扩展正则。认不出就返回null,老老实实走 shell。getSedEditInfo(tools/shell.ts:1547)先排除后台执行和前导环境变量赋值(FOO=1 sed …这种)。prepareSedEdit(shell.ts:1586)拒绝符号链接目标,跑一次checkPriorRead,然后applySedSubstitution(sedEditParser.ts:441)在内存里算出新内容。getConfirmationDetails返回的是type: 'edit'的确认框,标题写着Confirm Sed Edit,并且hideModify: true——不允许在确认框里手改内容(shell.ts:1930-1947)。- 模拟失败(
sedEditPreviewFailed)就降级回真 shell 执行。
7.3 执行服务
ShellExecutionService.execute(packages/core/src/services/shellExecutionService.ts:639)是静态类,维护两张全局表:activePtys 和 activeChildProcesses,并在 process.on('exit') 里统一清理(shellExecutionService.ts:623)。
PTY 路径(executeWithPty,:1362)给的是真终端:ANSI 序列被 headless terminal 解析成结构化的 AnsiOutput,所以 UI 能渲染带颜色的实时输出,还支持 writeToPty / resizePty / scrollPty 这些交互操作(:2312 起)。非 Windows 下子进程用 detached: true 起(:699),这样杀的是整个进程组。
流式输出通过 updateOutput 回调节流推送,间隔 OUTPUT_UPDATE_INTERVAL_MS = 1000(tools/shell.ts:927)。
7.4 后台 shell 与进程树
BackgroundShellRegistry(packages/core/src/services/backgroundShellRegistry.ts:249)是个小状态机,注释写得很清楚:
register ──▶ running ──┬──▶ completed
├──▶ failed
└──▶ cancelled
(一次性转移:settle 之后的回调全是 no-op)
一次性的设计是为了防「进程在取消过程中自然退出」这种晚到回调把终态覆盖掉(backgroundShellRegistry.ts:14-19)。它还负责在进程结束时给模型发通知,通知里带命令和输出尾部——尾部上限 8KB(MAX_NOTIFICATION_OUTPUT_TAIL_BYTES,:30),并且做了 UTF-8 边界对齐和控制字符剥离,避免把 ANSI 转义序列注进通知文本。终止的 shell 最多保留 32 条(MAX_RETAINED_TERMINAL_SHELLS,:167)。
杀进程时只杀主 pid 是不够的——npx some-server 这种包装器会留下孤儿子进程。listDescendantPids(packages/core/src/tools/pid-descendants.ts:76)跨平台地枚举整棵后代进程树,然后 sigtermPids(:370)逐个发信号。它对失控进程树有硬防护:最多 256 个后代、8 层深度、单次查询 2 秒超时、快照 8MB 缓冲(pid-descendants.ts:18-38)。MCP 传 输池关闭时也用它。
8. 检索:grep / glob / ls
8.1 Grep 有两套实现
| 实现 | 何时启用 | 特点 |
|---|---|---|
RipGrepTool(tools/ripGrep.ts:597) | useRipgrep 开启 | 走 ripgrep 的 JSON 输出流,解析 type: "match" 事件 |
GrepTool(tools/grep.ts:656) | 否则 | 三级降级 |
两者对模型是同一个名字 grep_search(都取 ToolNames.GREP),输出预算也一样是 20K 字符。
GrepTool 的三级降级(grep.ts:433、:488、:590):
① git grep --untracked ← 在 git 仓库里且有 git,天然尊重 .gitignore
↓ 不可用 / 失败
② 系统 grep -r(带 --exclude-dir) ← 从排除规则里提取目录名
↓ 不可用 / 失败
③ 纯 JS:globStream + 逐文件正则 ← 保底,一定能跑
工具描述里明确写了「永远用 Grep 工具,不要在 Bash 里调 grep 或 rg」(grep.ts:668)——因为工具版本已经处理好了权限与排除规则。
8.2 搜到的文件算「读过」吗?
算,但只算一半。recordGrepResultFileReads(packages/core/src/tools/grepReadTracking.ts:16)把命中的文件按 50 个一批 stat,然后写进 FileReadCache,参数是 { full: false, cacheable: true }。
这意味着:grep 命中的文件通过了 Edit 的先读后写检查(模型确实看到了那几行),但拿不到 ReadFile 的「文件未变」快路径(full: false)。.ipynb 被明确排除在 cacheable 之外(grepReadTracking.ts:13),因为 notebook 需要结构化读取。
8.3 Glob 的排序有讲究
sortFileEntries(packages/core/src/tools/glob.ts:46)不是按字母排,而是近期修改的文件排前面:
最近 N 毫秒内改过的文件 ──▶ 按 mtime 倒序(最新的在最前)
其余文件 ──▶ 按路径字母序
对编码 agent 来说这个默认很对——你正在改的那批文件就是最相关的。上限 MAX_FILE_COUNT = 100(glob.ts:33)。
9. 任务与协作类工具
这一组工具的共性是:它们的输出不是给人看的数据,而是给别的执行体的指令。所以它们的权限默认值被刻意抬高了。
| 工具 | Kind | getDefaultPermission() | 为什么 |
|---|---|---|---|
todo_write | Think | 基类默认 allow | 只写会话内的待办文件 |
task_create | Other | 'ask' | description 会被空闲队友自动领取并当指令执行 |
task_update | Other | 覆写 | 同上 |
task_list | Read | 基类默认 allow | 纯查询 |
task_stop | Other | — | shouldDefer: true,低频 |
send_message | Other | 'ask' | 自由文本注入到运行中的任务/队友,等于新指令 |
ask_user_question | Think | 'ask'(交互模式) | 必须弹窗才能拿到用户答案 |
task_create 和 send_message 的注释用了同一个词——privileged sink(特权汇聚点):自由文本在这里变成另一个 agent 的指令,而基类默认的 'allow' 会让 AUTO 模式直接短路掉分类器。所以强制改成 'ask',让分类器或人至少看一眼(tools/task-create.ts:70、tools/send-message.ts:75)。
task_create 还覆写了确认框内容:一行摘要不够,必须把 description 正文显示出来——那才是人真正在批准的东西(task-create.ts:74-93)。
ask_user_question 是个特例:它借用确认流程来收集输入。getConfirmationDetails 返回 type: 'ask_user_question',UI 渲染成选项列表,用户的选择经 ToolConfirmationPayload.answers 回流到 invocation 上(tools/askUserQuestion.ts:191-220)。非交互且非 ACP 模式下它直接返回 'allow',随后跳过执行(askUserQuestion.ts:180)。
todo_write 的执行流程里嵌了 hook 的校验阶段:先读旧 todo、算出 created/updated 差异,再让 TodoCreated 之类的 hook 有机会 block(tools/todoWrite.ts:353-395)。hook 系统本身见 04 安全护栏。
10. MCP 接入
MCP(Model Context Protocol,一套让外部进程以标准方式向 agent 暴露工具/资源/提示的协议)在这里被包成了普通的 DeclarativeTool。
10.1 分层
McpTransportPool(每 workspace 一个)
│ 按「服务器配置指纹」复用传输连接,多 session 共享
├─ PoolEntry ─── 子进程 / WebSocket
└─ WorkspaceMcpBudget ── 工作区级并发上限
▲
│ pool.acquire / release
McpClientManager(每 session 一个)
│ 发现流程、状态机、per-session 预算(standalone / SDK MCP 走这条)
└─ McpClient ── connect / discover / readResource
│
▼
DiscoveredMCPTool ×N ──▶ ToolRegistry.registerTool()
10.2 DiscoveredMCPTool 的四个特别之处
定义在 packages/core/src/tools/mcp-tool.ts:491:
| 特别之处 | 值 | 原因 |
|---|---|---|
| 名字 | generateValidName('mcp__<server>__<tool>') | Gemini API 只接受 [a-zA-Z0-9_.-] 且 ≤63 字符,超长时中间挖空插 ___(mcp-tool.ts:739) |
kind | annotations.readOnlyHint === true ? Read : Other | 声明只读的 MCP 工具能进并发安全集 |
shouldDefer | true | 永远按需披露 |
maxOutputChars | 500_000 | MCP 常返回大结构化载荷,给全局阈值的 20 倍 |
它的确认框是专门的 type: 'mcp',权限规则字符串形如 mcp__<server>__<tool>(mcp-tool.ts:170)。执行有两条路:直连 client(executeWithDirectClient,:316)和 CallableTool(:384),前者带最多 3 次 重连重试(MAX_RECONNECT_RETRIES,:127)。canUpdateOutput 为 true,因为 MCP 支持进度通知(McpToolProgressData,tools/tools.ts:615)。
10.3 预算:一个上限,两套实现
同一套「预留槽位 → 75% 迟滞告警 → 拒绝批次合并」的状态机存在两份:
McpClientManager内联版(tools/mcp-client-manager.ts:380起),管 standalone qwen 和 SDK MCP;WorkspaceMcpBudget(tools/mcp-workspace-budget.ts:55),管走池子的连接。
不会重复计数,因为池模式下的 discoverAllMcpToolsViaPool 根本不调 manager 的 tryReserveSlot(mcp-workspace-budget.ts:48-53)。超限抛 BudgetExhaustedError(mcp-client-manager.ts:208),两条路共用同一个错误类,SDK 消费者看到的形状一致。
预留的 key 是服务器名而不是子进程:同名不同指纹的两个池条目共占一个槽位。运维视角是「配置的 server 槽位数」,子进程数另有 pool.getSnapshot().subprocessCount。
10.4 资源与提示
read_mcp_resource(tools/read-mcp-resource.ts:143):Kind.Fetch(因此并发安全)、shouldDefer: true、maxOutputChars: Infinity。最后一条是因为它自己已经在格式化时封顶了文本和 blob,让调度器再截一刀反而可能把带 nonce 边框的输出切断(read-mcp-resource.ts:190-203)。- MCP 提示(prompt)不是工具:
getMCPServerPrompts(packages/core/src/prompts/mcp-prompts.ts:10)从PromptRegistry里按 server 取,走的是@server:uri内联引用那条路,属于上下文工程范畴(见 05 上下文工程)。 - 服务器断开时,工具、提示、资源三者一起清,并且要清 reveal 标记(
tool-registry.ts:390,removeMcpToolsByServer)。不清的话,断开重连后同名工具会继承上一轮的「已披露」状态,在模型还不知道它存在时就出现在声明列表里。
11. 大结果落盘与预算
11.1 三道闸门
模型上下文是最贵的资源,工具结果是最容易失控的一项。Qwen Code 设了三道闸:
单条结果
│
① 持久化闸门 maybePersistLargeToolResult
text.length > 全局阈值(25K) + 3K 余量 ?
└─ 是 → 写盘,llmContent 换成「已截断 + 文件路径」的存根
│
② 单工具截断(按 maxOutputChars / truncateKeep)
└─ 留首尾 / 只留头 / 只留尾,全文照样落盘
│
┌────┴─────┐
│ 一批结果 │
③ 批预算 applyBatchOutputBudget
所有结果字符数之和 > 200K ?
└─ 是 → 按大小从大到小,逐个 offload 成 2K 预览 + 指针,直到回到预算内
三个默认值(packages/core/src/config/config.ts):
| 常量 | 值 | 含义 |
|---|---|---|
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD | 25_000 | 单条结果字符阈值(config.ts:588) |
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES | 1000 | 单条结果行阈值(config.ts:589) |
DEFAULT_TOOL_OUTPUT_BATCH_BUDGET | 200_000 | 一批的总字符预算(config.ts:595) |
GATE_HEADROOM = 3000(coreToolScheduler.ts:171)是给「自己已经截断到 25K、再加上头部说明变成 25.4K」的工具留的余量,避免级联重复落盘。
11.2 谁豁免、谁跳过
GATE_EXEMPT_TOOLS 只有两个成员:read_file 和 read_mcp_resource(coreToolScheduler.ts:177)。理由写在注释里:这两个工具自己分页/自己封顶,而持久化闸门跑在它们的自封顶之前——不豁免的话,一个 28K 的 MCP 资源会先被换成存根,模型永远看不到正文。
offloadCallOutput(coreToolScheduler.ts:4217)对以下情况直接跳过,返回 null:
| 跳过条件 | 原因 |
|---|---|
| 状态不是 success | 错误信息本来就短,且要完整 |
responseParts.length !== 1 | 多部件不好整体替换 |
fr.parts 非空 | 带媒体,不能只留文本预览 |
| 已带截断/持久化前缀 | 避免嵌套 |
truncateToolOutput 抛异常 | offload 失败绝不能拖垮整批 |
11.3 落盘存根长什么样
truncateAndSaveToFile(packages/core/src/utils/truncation.ts)把全文写到 <projectTempDir>/<tool>_<随机hex>.output,权限 0o600(工具输出可能含密钥,truncation.ts:168),然后把 llmContent 换成一段带路径的说明,告诉模型「用 read_file 读这个绝对路径拿全文」。
有个很克制的判断:如果包装后的存根比原文还长,就干脆不截断——截了既费事又丢可恢复性,没有任何收益(truncation.ts:154-159)。
批预算还有一条诚实的日志:如果 offload 之后总量仍然超预算(比如单个 MCP 结果的 500K 上限本身就超过 200K 批预算),它 warn 出来而不是假装没事(coreToolScheduler.ts:4200-4207)。
12. 巧妙之处(可以带走的技术)
① 校验与执行分成两个对象。 build() 是唯一入口,过不去就抛。执行期代码因此不必再做防御性判空——参数在类型和运行时两个层面都已可信(tools/tools.ts:393)。
② 「工具太多」被当成上下文问题而不是 UI 问题来解。 不是折叠列表,而是根本不发 schema,只发名字,让模型自己去取(tools/tool-search.ts 全文 + tool-registry.ts:678)。搜索工具自己用 alwaysLoad: true 打破递归。
③ reveal 与 setTools() 做成事务。 失败即回滚(tool-search.ts:395)。这类「本地状态」与「远端声明」的一致性问题在 agent 框架里非常常见,绝大多数实现选择静默失败。
④ 用文件读缓存做「先读后写」的凭证。 不需要锁、不需要版本号,只用 mtime+size 指纹加上「这个会话读过没」两个位,就挡住了绝大部分盲写和陈旧写(tools/priorReadEnforcement.ts:155)。
⑤ 归一化的产物是「磁盘上的真实切片」而不是「模型给的近似串」。 这一步让容错匹配和精确替换可以共存(utils/editHelper.ts:313)。
⑥ 把 sed -i 提升成一次编辑。 与其禁止模型用 sed,不如把它翻译成受管控的编辑动作,保留 diff 预览和先读后写(tools/shell.ts:1586 + utils/sedEditParser.ts:21)。
⑦ 分类器输入默认为空。 toAutoClassifierInput 默认返回 '' 而不是原始参数,第三方工具必须主动声明哪些字段可以给分类器看(tools/tools.ts:279)。
⑧ 大结果不丢,只是换成指针。 落盘 + 可恢复路径,比单纯截断保留了更多信息,也比全量入上下文便宜(utils/truncation.ts + coreToolScheduler.ts:4169)。
13. 边界与局限(源码自己承认的)
写文件不是原子的。 edit.ts:546-557 明说:最后一次 stat 与 writeTextFile 之间的并发写仍可能被覆盖。要根治需要「写临时文件再 rename」或写后哈希校验,都被推迟了。在意的运维应当设 fileReadCacheDisabled: true 并自己做应用层加锁。
先读后写没有「更严格」模式。 注释明确写着:fileReadCacheDisabled 是反方向的开关(完全绕过),不存在比现状更严的内建选项;想要更严的请提 feature request(priorReadEnforcement.ts:96-104)。issue #2499(模型对未读字节产生幻觉后覆写)是当前接受的残余风险。
批预算可能压不下去。 单个 MCP 结果的 500K 上限大于 200K 的批预算,此时只能 warn(coreToolScheduler.ts:4200)。
FS_PATH_TOOL_NAMES 是手工维护的白名单。 新增带文件路径参数的工具时,必须手动加进 coreToolScheduler.ts 的那张表,没有编译期检查,漏了会静默跳过路径激活流程——源码里挂着 TODO 说要换成声明式的 pathFields 注解(tools/tool-names.ts:12-18)。
Grep 的三级降级结果不完全一致。 git grep、系统 grep 和 JS 兜底对排除规则、正则方言的处理不可能字节级一致;ripgrep 路径又是另一套正则方言(工具描述里专门提醒 Go 的 interface{} 要转义花括号,ripGrep.ts:611)。
pid-descendants 有硬上限。 256 个后代、8 层深度封顶(pid-descendants.ts:37-38)。极端进程树下会有孤儿残留,这是刻意用「不卡住关闭流程」换来的。
14. 横向对比与本组其他章
- 谁批准这次调用 ——
getDefaultPermission()只是第一道闸(L3),后面还有规则引擎、审批模式、钩子与沙箱,见 04 安全护栏。 - 工具调用怎么串进主循环 —— 调度器的状态机、并发分批、结果回灌见 01 主循环。
- 声明列表最后变成什么 ——
tools数组怎么被翻译成各家协议的工具格式,见 02 多协议模型层。 - 按需披露省下来的 token 花在哪 —— 系统提示、QWEN.md、技能清单与压缩见 05 上下文工程。
agent/workflow这类工具 —— 子代理、工作流编排、团队与竞技场见 06 多智能体。
15. 代码地图(导航索引)
| 主题 | 文件路径 | 关键符号 |
|---|---|---|
| 工具抽象基类 | packages/core/src/tools/tools.ts | DeclarativeTool、BaseDeclarativeTool、BaseToolInvocation |
| 结果与确认类型 | packages/core/src/tools/tools.ts | ToolResult、ToolCallConfirmationDetails、Kind、CONCURRENCY_SAFE_KINDS |
| 注册表 | packages/core/src/tools/tool-registry.ts | ToolRegistry、registerFactory、ensureTool、getFunctionDeclarations、getDeferredToolSummary |
| 工具名常量 | packages/core/src/tools/tool-names.ts | ToolNames、ToolDisplayNames、ToolNamesMigration |
| 懒注册入口 | packages/core/src/config/config.ts | createToolRegistry、registerLazy |
| 按需披露 | packages/core/src/tools/tool-search.ts | ToolSearchTool、collectCandidates、loadAndReturnSchemas、scoreTool |
| 披露提示词 | packages/core/src/utils/environmentContext.ts | buildDeferredToolsReminder、buildAddedMcpToolsReminder |
| 编辑工具 | packages/core/src/tools/edit.ts | EditTool、applyReplacement、calculateEdit |
| 编辑归一化 | packages/core/src/utils/editHelper.ts | normalizeEditStrings、maybeAugmentOldStringForDeletion、extractEditSnippet |
| 先读后写 | packages/core/src/tools/priorReadEnforcement.ts | checkPriorRead、StructuredToolError |
| 写文件 | packages/core/src/tools/write-file.ts | WriteFileTool |
| 读文件 | packages/core/src/tools/read-file.ts | ReadFileTool |
| Notebook 编辑 | packages/core/src/tools/notebook-edit.ts | NotebookEditTool |
| Diff 选项与统计 | packages/core/src/tools/diffOptions.ts | DEFAULT_DIFF_OPTIONS、getDiffStat |
| 全仓 diff | packages/core/src/utils/gitDiff.ts | fetchGitDiff、fetchGitDiffHunks、parseGitNumstat |
| Shell 工具 | packages/core/src/tools/shell.ts | ShellTool、ShellToolInvocation、getSedEditInfo、prepareSedEdit |
| sed 解析 | packages/core/src/utils/sedEditParser.ts | parseSedEditCommand、applySedSubstitution |
| Shell 执行服务 | packages/core/src/services/shellExecutionService.ts | ShellExecutionService、executeWithPty、childProcessFallback |
| 后台 shell | packages/core/src/services/backgroundShellRegistry.ts | BackgroundShellRegistry、register、complete |
| 进程树 | packages/core/src/tools/pid-descendants.ts | listDescendantPids、sigtermPids |
| Grep(降级版) | packages/core/src/tools/grep.ts | GrepTool |
| Grep(ripgrep 版) | packages/core/src/tools/ripGrep.ts | RipGrepTool |
| grep 读记账 | packages/core/src/tools/grepReadTracking.ts | recordGrepResultFileReads |
| Glob / LS | packages/core/src/tools/glob.ts、packages/core/src/tools/ls.ts | GlobTool、sortFileEntries、LSTool |
| 待办 | packages/core/src/tools/todoWrite.ts | TodoWriteTool |
| 任务与协作 | packages/core/src/tools/task-create.ts 等 | TaskCreateTool、TaskUpdateTool、TaskListTool、TaskStopTool、SendMessageTool |
| 结构化提问 | packages/core/src/tools/askUserQuestion.ts | AskUserQuestionTool |
| MCP 工具封装 | packages/core/src/tools/mcp-tool.ts | DiscoveredMCPTool、generateValidName、asFullyQualifiedTool |
| MCP 客户端 | packages/core/src/tools/mcp-client.ts | McpClient、MCPDiscoveryState |
| MCP 管理器 | packages/core/src/tools/mcp-client-manager.ts | McpClientManager、BudgetExhaustedError、discoverAllMcpTools |
| MCP 传输池 | packages/core/src/tools/mcp-transport-pool.ts | McpTransportPool、acquire、drainAll |
| MCP 工作区预算 | packages/core/src/tools/mcp-workspace-budget.ts | WorkspaceMcpBudget |
| MCP 资源读取 | packages/core/src/tools/read-mcp-resource.ts | ReadMcpResourceTool |
| MCP 提示 | packages/core/src/prompts/mcp-prompts.ts | getMCPServerPrompts |
| 输出预算 | packages/core/src/core/coreToolScheduler.ts | applyBatchOutputBudget、offloadCallOutput、maybePersistLargeToolResult |
| 截断与落盘 | packages/core/src/utils/truncation.ts | truncateToolOutput、persistAndTruncateToolResult、TOOL_OUTPUT_TRUNCATED_PREFIX |