跳到主要内容

数据截至 (上游 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 的预算段单条截断 + 一批 offloadpackages/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_KINDSEdit Delete Move Execute标记「有副作用」
CONCURRENCY_SAFE_KINDSRead 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:443returnDisplay 是个联合类型(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 上还挂着三个不显眼但很关键的可覆写属性:

钩子默认谁覆写了它
maxOutputCharsundefined(用全局阈值)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 里有两次禁用检查,不是冗余:

  1. 进门先查 disabledTools(tool-registry.ts:236)。
  2. 若名字与已有工具/工厂撞车且来源是 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);

三个开关的组合决定了一个工具在不在初始列表里:

shouldDeferalwaysLoad已 reveal在初始声明列表?
false
truetrue在(如 tool_search 自己)
truefalse不在
truefalse在(本会话内)

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 后缀1012
名字子串56
searchHint 按词命中44
description 子串22
动作近义词(cancel/delete/stop…)66

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:232stat 与 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 === nulloldString !== ''返回空串(防御性)
oldString === '' 且非新建不改(拒绝「空串替换」这种爆炸操作)
正常safeLiteralReplace——避免 $& $1 被当成正则替换模式

calculateEdit(edit.ts:147)负责产出所有可能的失败码,一次调用只可能命中一个:

错误码触发条件
FILE_NOT_FOUND文件不存在且 old_string 非空
ATTEMPT_TO_CREATE_EXISTING_FILEold_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)是静态类,维护两张全局表:activePtysactiveChildProcesses,并在 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 里调 greprg」(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. 任务与协作类工具

这一组工具的共性是:它们的输出不是给人看的数据,而是给别的执行体的指令。所以它们的权限默认值被刻意抬高了。

工具KindgetDefaultPermission()为什么
todo_writeThink基类默认 allow只写会话内的待办文件
task_createOther'ask'description 会被空闲队友自动领取并当指令执行
task_updateOther覆写同上
task_listRead基类默认 allow纯查询
task_stopOthershouldDefer: true,低频
send_messageOther'ask'自由文本注入到运行中的任务/队友,等于新指令
ask_user_questionThink'ask'(交互模式)必须弹窗才能拿到用户答案

task_createsend_message 的注释用了同一个词——privileged sink(特权汇聚点):自由文本在这里变成另一个 agent 的指令,而基类默认的 'allow' 会让 AUTO 模式直接短路掉分类器。所以强制改成 'ask',让分类器或人至少看一眼(tools/task-create.ts:70tools/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)
kindannotations.readOnlyHint === true ? Read : Other声明只读的 MCP 工具能进并发安全集
shouldDefertrue永远按需披露
maxOutputChars500_000MCP 常返回大结构化载荷,给全局阈值的 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: truemaxOutputChars: 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_THRESHOLD25_000单条结果字符阈值(config.ts:588)
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES1000单条结果行阈值(config.ts:589)
DEFAULT_TOOL_OUTPUT_BATCH_BUDGET200_000一批的总字符预算(config.ts:595)

GATE_HEADROOM = 3000(coreToolScheduler.ts:171)是给「自己已经截断到 25K、再加上头部说明变成 25.4K」的工具留的余量,避免级联重复落盘。

11.2 谁豁免、谁跳过

GATE_EXEMPT_TOOLS 只有两个成员:read_fileread_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.tsDeclarativeToolBaseDeclarativeToolBaseToolInvocation
结果与确认类型packages/core/src/tools/tools.tsToolResultToolCallConfirmationDetailsKindCONCURRENCY_SAFE_KINDS
注册表packages/core/src/tools/tool-registry.tsToolRegistryregisterFactoryensureToolgetFunctionDeclarationsgetDeferredToolSummary
工具名常量packages/core/src/tools/tool-names.tsToolNamesToolDisplayNamesToolNamesMigration
懒注册入口packages/core/src/config/config.tscreateToolRegistryregisterLazy
按需披露packages/core/src/tools/tool-search.tsToolSearchToolcollectCandidatesloadAndReturnSchemasscoreTool
披露提示词packages/core/src/utils/environmentContext.tsbuildDeferredToolsReminderbuildAddedMcpToolsReminder
编辑工具packages/core/src/tools/edit.tsEditToolapplyReplacementcalculateEdit
编辑归一化packages/core/src/utils/editHelper.tsnormalizeEditStringsmaybeAugmentOldStringForDeletionextractEditSnippet
先读后写packages/core/src/tools/priorReadEnforcement.tscheckPriorReadStructuredToolError
写文件packages/core/src/tools/write-file.tsWriteFileTool
读文件packages/core/src/tools/read-file.tsReadFileTool
Notebook 编辑packages/core/src/tools/notebook-edit.tsNotebookEditTool
Diff 选项与统计packages/core/src/tools/diffOptions.tsDEFAULT_DIFF_OPTIONSgetDiffStat
全仓 diffpackages/core/src/utils/gitDiff.tsfetchGitDifffetchGitDiffHunksparseGitNumstat
Shell 工具packages/core/src/tools/shell.tsShellToolShellToolInvocationgetSedEditInfoprepareSedEdit
sed 解析packages/core/src/utils/sedEditParser.tsparseSedEditCommandapplySedSubstitution
Shell 执行服务packages/core/src/services/shellExecutionService.tsShellExecutionServiceexecuteWithPtychildProcessFallback
后台 shellpackages/core/src/services/backgroundShellRegistry.tsBackgroundShellRegistryregistercomplete
进程树packages/core/src/tools/pid-descendants.tslistDescendantPidssigtermPids
Grep(降级版)packages/core/src/tools/grep.tsGrepTool
Grep(ripgrep 版)packages/core/src/tools/ripGrep.tsRipGrepTool
grep 读记账packages/core/src/tools/grepReadTracking.tsrecordGrepResultFileReads
Glob / LSpackages/core/src/tools/glob.tspackages/core/src/tools/ls.tsGlobToolsortFileEntriesLSTool
待办packages/core/src/tools/todoWrite.tsTodoWriteTool
任务与协作packages/core/src/tools/task-create.tsTaskCreateToolTaskUpdateToolTaskListToolTaskStopToolSendMessageTool
结构化提问packages/core/src/tools/askUserQuestion.tsAskUserQuestionTool
MCP 工具封装packages/core/src/tools/mcp-tool.tsDiscoveredMCPToolgenerateValidNameasFullyQualifiedTool
MCP 客户端packages/core/src/tools/mcp-client.tsMcpClientMCPDiscoveryState
MCP 管理器packages/core/src/tools/mcp-client-manager.tsMcpClientManagerBudgetExhaustedErrordiscoverAllMcpTools
MCP 传输池packages/core/src/tools/mcp-transport-pool.tsMcpTransportPoolacquiredrainAll
MCP 工作区预算packages/core/src/tools/mcp-workspace-budget.tsWorkspaceMcpBudget
MCP 资源读取packages/core/src/tools/read-mcp-resource.tsReadMcpResourceTool
MCP 提示packages/core/src/prompts/mcp-prompts.tsgetMCPServerPrompts
输出预算packages/core/src/core/coreToolScheduler.tsapplyBatchOutputBudgetoffloadCallOutputmaybePersistLargeToolResult
截断与落盘packages/core/src/utils/truncation.tstruncateToolOutputpersistAndTruncateToolResultTOOL_OUTPUT_TRUNCATED_PREFIX