KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
03 · 工具运行时 — keel 龙骨
工具是 Agent 与外部世界的唯一接口。Claude Code 2.1.88 把「工具」定义得比「一个模型能调用的函数」宽得多:它同时携带 zod schema、权限检查、执行逻辑、并发语义、乃至 UI 渲染方法。
工具是 Agent 与外部世界的唯一接口。Claude Code 2.1.88 把「工具」定义得比「一个模型能调用的函数」宽得多:它同时携带 zod schema、权限检查、执行逻辑、并发语义、乃至 UI 渲染方法。
这一章讲清楚三件事:
Tool契约上到底有哪些字段,以及默认值为什么是「最保守」的那一组;- 一批工具请求进来后,怎么被切成「连续可并行批」与「单个非安全」,以及为什么写工具必须严格串行;
- 工具结果为什么需要三级预算,超限之后又去了哪里。
一、Tool 契约
Tool.ts: Tool(第 372 行)的注释把职责列全了:面向模型的 schema 与 prompt 文案、输入校验与权限检查钩子、执行逻辑、UI 渲染辅助、以及 query / 编排层要用的并发与只读语义。
按类别拆开:
面向模型的部分
readonly name: string、aliases?: string[](重命名后的向后兼容名)、readonly inputSchema: Input(zod)readonly inputJSONSchema?: ToolInputJSONSchema——给能直接声明 JSON Schema 的 MCP 工具用;outputSchema?: z.ZodType<unknown>可选(注释提到 TungstenTool 没定义)searchHint?: string——3–10 词的能力短语,供 ToolSearch 做关键词匹配readonly shouldDefer?: boolean/readonly alwaysLoad?: boolean——延迟加载与「必须在第 1 轮就出现」的例外(MCP 侧由_meta['anthropic/alwaysLoad']设置)readonly strict?: boolean——开启后 API 会更严格地遵守工具说明与参数 schema
执行与校验
call(args, context, canUseTool, parentMessage, onProgress?)description(input, { isNonInteractiveSession, toolPermissionContext, tools })prompt(options)——生成给模型的工具说明validateInput?(input, context): Promise<ValidationResult>checkPermissions(input, context): Promise<PermissionResult>——注释明确「Only called after validateInput() passes」preparePermissionMatcher?(input)——为 hook 的if条件(如Bash(git *))预编译匹配器,避免每次重复解析inputsEquivalent?(a, b)
语义与安全检查
isConcurrencySafe(input)/isReadOnly(input)/isDestructive?(input)isEnabled()、interruptBehavior?(): 'cancel' | 'block'(工具运行中用户提交新消息时的策略)isOpenWorld?(input)、requiresUserInteraction?()isMcp?: boolean/isLsp?: boolean/mcpInfo?: { serverName, toolName }toAutoClassifierInput(input)——给 auto 模式的安全分类器看的压缩表示
结果与展示
maxResultSizeChars: number、mapToolResultToToolResultBlockParam(content, toolUseID)renderToolResultMessage?/getToolUseSummary?/getActivityDescription?/userFacingName(input)/userFacingNameBackgroundColor?isTransparentWrapper?()、isSearchOrReadCommand?(input)
默认值集中在 Tool.ts: TOOL_DEFAULTS(第 777 行),全部按「最保守」原则写死:isEnabled → true、isConcurrencySafe → false(assume not safe)、isReadOnly → false(assume writes)、isDestructive → false、checkPermissions → { behavior: 'allow', updatedInput: input }、toAutoClassifierInput → ''(跳过分类器,安全相关工具必须自己覆盖)、userFacingName → ''。
buildTool(def) 负责把这份默认值和工具定义合并,类型层面用 BuiltTool<D> 镜像运行时行为。也就是说「忘记声明」的结果永远是更安全、更慢,而不是更快更危险。
maxResultSizeChars 有一个刻意的例外:Tool.ts 的注释说明,对「输出绝不能落盘」的工具应设为 Infinity,并点名了 Read——因为让 Read 落盘会制造 Read → 文件 → Read 的循环,而 Read 本身已经自带边界。
二、工具池装配
tools.ts: getAllBaseTools()(第 193 行)返回内置工具数组。其中无条件注册的有:AgentTool、TaskOutputTool、BashTool、ExitPlanModeV2Tool、FileReadTool、FileEditTool、FileWriteTool、NotebookEditTool、WebFetchTool、TodoWriteTool、WebSearchTool、TaskStopTool、AskUserQuestionTool、SkillTool、EnterPlanModeTool、getSendMessageTool()、BriefTool、ListMcpResourcesTool、ReadMcpResourceTool。
条件注册的包括(这是「约 40 个工具」这个说法的来源):
- GlobTool / GrepTool——仅在
!hasEmbeddedSearchTools()时加入(ant 原生构建把 bfs/ugrep 嵌进 bun 二进制,并把 shell 里的 find/grep 别名过去); - TaskCreate/Get/Update/List——
isTodoV2Enabled(); - LSPTool——
ENABLE_LSP_TOOL;EnterWorktree/ExitWorktree——worktree 模式; - ConfigTool / TungstenTool / REPLTool——
process.env.USER_TYPE === 'ant'; - 团队/群组类(TeamCreate/TeamDelete/ListPeers)、TimeCron 系列、PowerShellTool、ToolSearchTool 等各按自己的开关。
真正决定「模型看见什么」的是 tools.ts: getTools(permissionContext):先应用全局模式(如 CLAUDE_CODE_SIMPLE 只留 Bash/Read/Edit),再用 filterToolsByDenyRules 剔掉被 blanket deny 的工具。后者的注释特意说明:它用的是与运行时权限检查同一个匹配器,所以 mcp__server 这样的前缀规则会在模型看到工具之前就把整个 server 的工具摘掉,而不是等到调用时才拒绝。
最终合并发生在 tools.ts: assembleToolPool(permissionContext, mcpTools):
const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name)
return uniqBy(
[...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
'name',
)
关键不是排序,而是保序去重:内置工具在前、MCP 在后,各自按名字排序,uniqBy 保留插入顺序,于是同名冲突时内置胜出。理由写在注释里,值得完整引用其要点:服务端的 claude_code_system_cache_policy 会在最后一个前缀匹配的内置工具之后放一个全局 cache breakpoint;如果做扁平排序,MCP 工具会插进内置工具之间,一旦有 MCP 工具排到现有内置工具中间,下游所有 cache key 全部失效。
三、并发分批
services/tools/toolOrchestration.ts: partitionToolCalls 用一次 reduce 把工具调用切成批次:
const isConcurrencySafe = parsedInput?.success
? (() => { try { return Boolean(tool?.isConcurrencySafe(parsedInput.data)) } catch { return false } })()
: false // safeParse 失败或 isConcurrencySafe 抛异常 → 一律视为不安全
两个「保守默认」值得注意:inputSchema.safeParse 失败直接判为不安全;isConcurrencySafe 抛异常(注释举例:shell-quote 解析失败)也判为不安全。
分组的规则很短:如果本个工具安全且上一个批次也安全,就并进上一批;否则开一个新批次。于是结果必然是「若干个连续可并行批」与「若干个只含单个非安全工具」交替。
runTools 遍历批次分派:
- 并发批 →
runToolsConcurrently,内部用utils/generators.ts: all(generators, concurrencyCap)做「至多 N 个同时在跑」的调度; - 非并发批 →
runToolsSerially,逐个runToolUse。
并发上限来自 toolOrchestration.ts: getMaxToolUseConcurrency():
parseInt(process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY || '', 10) || 10
即默认 10,且非数字 / 0 都会退回 10。
写工具严格串行是安全语义,不是性能取舍。注释写得很清楚:「只读 / 可并发的工具可以一起跑来提速;会修改状态或文件的工具则必须串行,这样后一个工具才能看到前一个工具刚刚产生的上下文变化。」
并行批里最大的坑是 contextModifier。工具可以通过它修改 ToolUseContext,但在并行批里「谁先改」是不确定的。解决方案是入队延后:
if (update.contextModifier) {
const { toolUseID, modifyContext } = update.contextModifier
;(queuedContextModifiers[toolUseID] ??= []).push(modifyContext)
}
// 批次结束后,按 blocks 的原始顺序依次 apply
for (const block of blocks) {
for (const modifier of queuedContextModifiers[block.id] ?? []) {
currentContext = modifier(currentContext)
}
}
顺序由模型发出 tool_use 的顺序决定,因此可复现。串行批里则相反——currentContext = update.contextModifier.modifyContext(currentContext) 立即生效。
四、三级结果预算
constants/toolLimits.ts 定义了三级上限,注释解释了每一级在防什么:
| 常量 | 值 | 作用域 |
|---|---|---|
DEFAULT_MAX_RESULT_SIZE_CHARS |
50_000 | 单个工具结果;系统级上限,工具自报的 maxResultSizeChars 只能更低 |
MAX_TOOL_RESULT_TOKENS |
100_000 | 以 token 计的硬上限 |
MAX_TOOL_RESULTS_PER_MESSAGE_CHARS |
200_000 | 单条 user message 内 tool_result 块的聚合上限 |
第三级的动机注释写得很具体:「This prevents N parallel tools from each hitting the per-tool max and collectively producing e.g. 10 × 40K = 400K in one turn's user message.」并且明确「Messages are evaluated independently」——上一轮的 150K 与这一轮的 150K 互不影响。
派生常量和辅助量:
BYTES_PER_TOKEN = 4(保守估计),于是MAX_TOOL_RESULT_BYTES = MAX_TOOL_RESULT_TOKENS * 4 = 400_000;TOOL_SUMMARY_MAX_LENGTH = 50——紧凑视图里工具摘要串的截断长度。
超限之后去哪:utils/toolResultStorage.ts: persistToolResult。它把完整内容写入文件,然后 generatePreview(contentStr, PREVIEW_SIZE_BYTES) 生成回喂给模型的预览,PREVIEW_SIZE_BYTES = 2000。消息里用 <persisted-output> / </persisted-output> 标签包裹(PERSISTED_OUTPUT_TAG / PERSISTED_OUTPUT_CLOSING_TAG),目录是 tool-results(TOOL_RESULTS_SUBDIR)。
预算执行有两个入口:
enforceToolResultBudget(...)——读getPerMessageBudgetLimit(),超过就把该消息里最大的若干块落盘替换,直到落进预算;applyToolResultBudget(...)——在 query 循环的每轮开头调用(见第 02 章阶段 1)。
getPerMessageBudgetLimit() 可被 GrowthBook flag tengu_hawthorn_window 覆盖(有限正数才生效),单工具的落盘阈值也可被 tengu_satin_quoll 覆盖。另一个重要细节在 query.ts:传给 applyToolResultBudget 的排除集合是
new Set(toolUseContext.options.tools
.filter(t => !Number.isFinite(t.maxResultSizeChars))
.map(t => t.name))
即 maxResultSizeChars = Infinity 的工具(Read)永远不会被替换。持久化还有配套的 ContentReplacementState(createContentReplacementState / cloneContentReplacementState / reconstructContentReplacementState),用于在恢复会话或子 Agent 续跑时重建「哪些块已被替换」的账本。
五、工具摘要与进度消息
工具摘要不是写死的,而是用模型生成的:services/toolUseSummary/toolUseSummaryGenerator.ts: generateToolUseSummary(第 45 行)内部走 queryHaiku,并把 querySource 标成 'tool_use_summary_generation'。
系统提示(TOOL_USE_SUMMARY_SYSTEM_PROMPT)明确要求「think git-commit-subject, not sentence」,并说明它会出现在移动端单行、约 30 字符处被截断。
调度方式同样有讲究——query.ts: 1476 不 await:
nextPendingToolUseSummary = generateToolUseSummary({
tools: toolInfoForSummary, signal: toolUseContext.abortController.signal, ...,
}).then(summary => summary ? createToolUseSummaryMessage(summary, toolUseIds) : null)
.catch(() => null)
注释是「Fire off summary generation without blocking the next API call」——这个 promise 被塞进 State.pendingToolUseSummary,跟着下一轮循环走;失败被吞成 null,因为摘要只是展示层。
进度消息走另一条路。StreamingToolExecutor 的 TrackedTool 把 results 与 pendingProgress 分开存放,注释说明「Progress messages are stored separately and yielded immediately」——进度是实时的,结果是按到达顺序 buffer 的。
代码地图
| 机制 | 位置 | 要点 |
|---|---|---|
| 工具契约 | Tool.ts: Tool |
第 372 行;schema + 校验 + 权限 + 执行 + 渲染 + 并发语义 |
| 工具默认值 | Tool.ts: TOOL_DEFAULTS |
第 777 行;三类语义方法默认 false;由 buildTool(第 803 行)合并 |
| 结果大小例外 | Tool.ts: maxResultSizeChars |
第 486 行;Read 用 Infinity 避免 Read→文件→Read 循环 |
| 内置工具清单 | tools.ts: getAllBaseTools |
第 193 行;19 个无条件 + 若干条件启用 |
| 工具池装配 | tools.ts: assembleToolPool |
第 351 行;内置在前 + 保序 uniqBy(内置胜出);先经 filterToolsByDenyRules 按同一匹配器预剔除 |
| 并发分批 | services/tools/toolOrchestration.ts: partitionToolCalls |
第 97 行;safeParse 失败或抛异常一律视为不安全;上限见 getMaxToolUseConcurrency(默认 10) |
| 并行批上下文延后 | services/tools/toolOrchestration.ts: runTools |
第 37 行;queuedContextModifiers 按 toolUseID 入队,批次末按序 apply |
| 结果上限常量 | constants/toolLimits.ts: DEFAULT_MAX_RESULT_SIZE_CHARS |
50_000 / 100_000 / 200_000 三级;BYTES_PER_TOKEN = 4 |
| 超限落盘 | utils/toolResultStorage.ts: persistToolResult |
第 137 行;PREVIEW_SIZE_BYTES = 2000 预览回喂 |
| 单消息预算执行 | utils/toolResultStorage.ts: enforceToolResultBudget |
第 769 行;把最大块落盘直到落进预算;上限可被 tengu_hawthorn_window 覆盖 |
| 工具摘要生成 | services/toolUseSummary/toolUseSummaryGenerator.ts: generateToolUseSummary |
第 45 行;走 queryHaiku;在 query.ts 第 1476 行不 await,随 State 带入下一轮 |
| 流式执行器 | services/tools/StreamingToolExecutor.ts: addTool |
第 76 行;每收到一个 tool_use 立即入队并按并发规则启动 |
关键取舍
Tool 契约把执行与展示混在一起,代价是接口很宽。
好处是核心层零 React 依赖(见第 01 章);代价是任何新工具都要面对二十余个可选方法,且 buildTool 默认值一旦设错,影响面覆盖所有工具。这也是为什么默认值刻意全部选「保守」而不是「方便」。
isConcurrencySafe 默认 false,代价是顺手写的新工具天然慢。
工具作者必须显式声明并发安全,否则它每次都独占执行。配合「safeParse 失败/抛异常也算不安全」,并发风险被压到最小;代价是并发收益只存在于被认真标注过的只读工具上。
并行批的 contextModifier 延后 apply,代价是「工具已返回但上下文还没变」。
批次结束前 currentContext 看到的是批次开始时的快照。这要求工具不能依赖「我改的上下文在同一批的下一个工具里立刻可见」——这类工具必须声明为非并发安全。
三级预算而不是单级,代价是行为更难预测。
单工具 50K、token 100K、单消息聚合 200K,外加两个 GrowthBook 覆盖开关与 Read 的 Infinity 例外,模型看到的结果有时是全文、有时是 2000 字节预览加一个路径。换来的是任何一轮的工具输出总量有上界,不会因为模型一口气发 10 个并行 grep 而把上下文打爆。
工具摘要交给 haiku 生成,代价是额外的模型调用与非确定性。
它给紧凑视图提供了可读标签,但每次都要发一次 API 请求(与主流程并行),失败被静默吞掉。TOOL_SUMMARY_MAX_LENGTH = 50 与提示词里的「~30 字符」并不一致——提示词约束生成,常量兜底截断。
自测题
TOOL_DEFAULTS里checkPermissions默认返回{ behavior: 'allow' },而isConcurrencySafe默认false。这两个默认值的「保守方向」为什么不一致?它们各自把风险推给了谁?assembleToolPool用「内置在前 + 保序去重」而不是简单uniqBy([...mcp, ...builtin], 'name')。请构造一个具体场景,说明后者为什么会破坏 prompt cache。- 并行批里
contextModifier被延后到批次结束才 apply。如果一个工具在call()内部就依赖「自己刚写进上下文的字段」,会发生什么?请给出一个应该把它标成非并发安全的例子。 MAX_TOOL_RESULTS_PER_MESSAGE_CHARS是「单消息」而不是「单轮」。这两者在什么情况下会不同?请结合 query 循环里applyToolResultBudget的调用位置说明它为什么选择「消息」这个粒度。Read用maxResultSizeChars = Infinity把落盘的皮球踢回给自己。请说明 Read 必须自己实现哪些边界,才能保证这个例外不会变成新的上下文炸弹。