2026-11-03
AI
0

目录

开篇
八种基础工具与条件装配
路径边界
读取上限
搜索预算
Glob 文件发现
新建文件
精确编辑
工具层的第二道授权
命令工具仍然受硬限制
工具结果协议
只读并行
扩展工具
工具调用如何进入可追踪链路
代码阅读索引
小结

系列文章:从零实现一个通用智能体
本篇主题:工具协议、工作区路径、读写操作与扩展工具
代码版本:clawsmith 0.1.7

开篇

工具是模型接触真实环境的接口。一个工具定义需要同时回答四个问题:

  • 模型如何描述和调用它;
  • 输入参数是否合法;
  • 它产生什么风险;
  • 它能否与其他工具并发执行。

clawsmith 的 ToolDefinition 把这些信息放在同一个协议对象中。模型只看到 JSON Schema, 运行时则使用同一份 Zod schema 做严格校验:

ts
export interface ToolDefinition { name: string description: string risk: ToolRisk capabilities: readonly ToolCapability[] concurrencySafe: boolean inputSchema: z.ZodType summarize(input: unknown): string execute(input: unknown, context: ToolExecutionContext): Promise<unknown> }

risk 决定权限模式下的默认行为,capabilities 描述工具可以触及的文件、进程、网络、 凭据、会话和外部副作用,concurrencySafe 决定能否参与只读并行批次。

能力集合不是展示字段,而是 Core 计算风险下限的依据:

text
filesystem.read filesystem.write process.execute network credentials external.side-effect session.read session.write agent.delegate

filesystem.write 至少提升为 workspace-write;执行、网络、凭据和外部副作用至少提升为 execute。工具声明的 risk 只能高于这个下限。运行时没有能力字段、类型错误或出现未知 能力时,Core 按全部能力处理并提升到 execute,避免旧工具或扩展悄悄绕过审批。

Hook 收到归一化后的有效风险与能力集合。这样规则可以针对工具能力匹配,而不是只依赖工具 自己声明的低风险标签。

八种基础工具与条件装配

createBuiltinTools() 在没有任何可选依赖时暴露八种基础工具:

工具风险并发安全作用
read_fileread是读取工作区文本文件
search_filesread是搜索文件名与文本内容
glob_filesread是按 *、**、? 查找文件路径
edit_fileworkspace-write否替换唯一匹配的文本块
write_fileworkspace-write否创建不存在的新文件
rename_fileworkspace-write否移动文件且不覆盖已有目标
delete_fileworkspace-write否删除普通文件
run_commandexecute否在沙箱中运行命令

这八种工具并不等于模型实际看到的完整集合。AgentCore 会在构造阶段合并基础工具、会话 工具、可选服务和扩展工具,形成一次冻结的工具表;重复工具名在启动阶段直接失败,避免权限 规则或模型调用落到不确定实现上。

注入 ArtifactStore 后,额外出现 read_artifact。它只能按当前会话读取已经外置的工具输出, 支持字节范围、行范围和关键词检索,不能接受宿主路径:

条件工具风险作用
read_artifactread分块读取会话内 artifact,供大工具输出回查

注入 CommandExecutionService 后,会同时出现后台命令工具组:

条件工具风险作用
start_commandexecute启动跨 turn 保存状态的长命令
list_commandsread列出当前会话的后台任务
command_terminal_capabilitiesread探测 PTY、resize、ANSI 和接管能力
command_statusread查询状态、退出码和 artifact 引用
command_outputread按 offset / maxBytes 读取增量日志
command_inputexecute向普通管道 stdin 或持有 token 的 PTY 写入输入
resize_command_ptyexecute调整持有控制权 PTY 的行列数
cancel_commandexecute取消任务及其进程树

Agent Core 还会注入两个会话工具:todo_write 用于全量更新任务状态,todo_list 用于读取 当前任务。它们不产生文件或命令副作用,但任务会持久化到 SQLite 的 session_todos 表, 并显示在 Web 会话中。

最后,根据运行时装配的 catalog、检索器和扩展,模型可能还会看到 skill_view、 memory_search、MCP、LSP、Subagent、worktree、插件工具和 search_tools。这些不是无条件 内置工具;它们是否出现由依赖、配置、信任状态和 deferred 激活集合共同决定。

路径边界

所有文件路径先解析为工作区内的真实路径:

ts
const root = await realpath(workspaceRoot) const absolute = resolve(root, requestedPath) if (!isInside(root, absolute)) { throw new Error(`Path is outside the workspace: ${requestedPath}`) } const target = await realpath(absolute) if (!isInside(root, target)) { throw new Error(`Path is outside the workspace: ${requestedPath}`) }

这里有两道检查:

  1. 词法路径不能通过 .. 跳出工作区;
  2. 真实路径不能通过符号链接逃逸。

第一次检查在 realpath 前完成,避免一个明显越界的路径被用来探测外部文件是否存在。 第二次检查处理“路径文本在工作区内,但链接目标在工作区外”的情况。

读取上限

read_file 默认最多读取 100,000 字节。实现会额外读取一个字节,仅用于判断是否截断:

ts
const buffer = Buffer.alloc(maxBytes + 1) const { bytesRead } = await file.read(buffer) const truncated = bytesRead > maxBytes

超限内容不会进入模型上下文,返回结果会附带明确的截断标记。

搜索预算

代码搜索同时限制结果数量、累计字节和访问文件数:

text
默认结果上限 50 硬结果上限 100 累计读取字节 2 MB 访问文件数 2,000 单文件读取上限 1 MB

node_modules、.git、dist、coverage 等目录默认跳过。搜索实现不跟随符号链接和特殊 文件,继续复用工作区路径边界。

没有命中时返回明确的“无结果”文本。空结果也是成功观察,模型可以据此判断搜索确实执行过。

Glob 文件发现

glob_files 用于先定位文件,再由模型决定读取哪些内容。它支持 *、** 和 ?,同样跳过 依赖与构建目录,并限制结果数和访问文件数。

模式必须是 workspace 相对路径。包含 ..、绝对路径或逃出 workspace 的模式会直接拒绝。

新建文件

write_file 只负责创建不存在的新文件。已有文件必须通过精确匹配的 edit_file 修改,从 工具语义上避免“顺手覆盖整个文件”。

rename_file 只接受普通文件和不存在的目标路径,目标父目录必须已经存在。它会在实际移动前 同时登记源文件与目标文件检查点,因此回退时恢复源路径并删除目标路径。delete_file 同样只 接受普通文件;目录删除没有隐式递归能力。

父目录必须已经存在,且其真实路径仍位于 workspace 内。内容上限为 1 MB,写入使用同目录 临时文件和原子 link 创建目标文件。写入前会记录“文件原本不存在”,因此 undo 可以删除 新建文件,而不只是恢复旧内容。

精确编辑

edit_file 不使用模糊匹配。oldText 必须出现且只能出现一次:

ts
if (firstIndex < 0) { throw new Error('The old text was not found') } if (firstIndex !== lastIndex) { throw new Error( 'The old text must occur exactly once; provide more surrounding context', ) }

这条规则降低“改错同名代码块”的风险。找不到文本通常意味着模型上下文已经过期,应重新 读取,而不是猜测位置。

替换前还会:

  • 检查编辑后文件不超过 1 MB;
  • 保存原文件快照,供 undo 使用;
  • 在同目录写临时文件;
  • 使用 rename 原子替换目标文件。

原文件副本现在同时登记到回合级检查点。工具写入完成后会立即记录 Agent 结果哈希,回合结束 再生成文件清单,因此即使有人在工具执行与回合结束之间手工修改文件,后续回退也不会把手工 内容误认为 Agent 输出。整轮回退先检查每个当前文件是否仍等于记录的“操作后哈希”;任一 冲突都会让整批操作停止,而不是覆盖用户修改。相同内容通过 SHA-256 blob 去重,Web 与 CLI 共享同一份持久化检查点。变更清单只带路径和统计,用户在 CLI 执行 /undo diff <turn-id> <path> 或在 Web 展开文件时才读取最多 64 KiB 的前后正文,避免历史 长会话把全部 diff 一次性塞进事件流或页面 DOM。

检查点默认保留 30 天,每个会话最多 100 MiB,全局最多 1 GiB,并且始终保留每个会话最近 5 个恢复点。修剪先删除过期或超出配额的 checkpoint 元数据与对应 journal,再扫描全部剩余 引用并清理无人引用的 blob;未完成回合的 journal 引用同样受保护。

分支/fork 会把父会话已有检查点元数据复制到子会话,但正文 blob 仍按内容哈希共享。子会话 回退只把自己的检查点标记为已撤销;父会话的恢复入口和历史元数据保持不变。若文件已被另一 分支修改,现有“操作后哈希”预检会拒绝覆盖。

每个检查点还记录回合开始时的消息数量、事件序号和 todo 快照。恢复可以选择仅会话、仅文件 或两者:仅会话会截断之后的消息和事件、清理待审批并恢复 todo;仅文件使用内容哈希回退; 两者先预检文件冲突,再回退文件与会话。CLI 使用 /undo session <turn-id> 和 /undo all <turn-id>,Web 在检查点卡片中选择恢复范围。

如果写入或替换失败,临时文件会被清理,目标文件不会停留在半写入状态。

工具层的第二道授权

写文件与命令工具都要求执行上下文包含 authorized: true:

ts
function assertAuthorized(context: ToolExecutionContext, action: string): void { if (!context.authorized) { throw new Error(`Tool ${action} requires explicit authorization`) } }

Agent Core 已经做过权限判断,工具层仍然再次检查。这不是重复审批,而是防止其他调用方绕过 Core 直接实例化工具执行副作用。

命令工具仍然受硬限制

run_command 即使获得授权,也会先执行灾难命令检查。sudo、格式化磁盘、写块设备、 rm -rf /、递归修改根路径权限、关机重启等规则没有“批准后放行”的路径。

命令随后还会经过:

  • 最小环境变量白名单;
  • POSIX ulimit;
  • 超时与进程组终止;
  • stdout/stderr 合并上限;
  • 平台命令沙箱。

沙箱会在下一篇展开。

工具结果协议

执行链不会把工具异常直接抛给用户。每个 assistant tool call 都必须产生一条 tool result, 否则下一轮 Provider 请求会缺少调用闭环。v15 协议把“给模型看的正文”和“给客户端看的 状态/展示信息”分开:

ts
type ToolResultStatus = 'success' | 'error' | 'denied' | 'cancelled' | 'unknown' interface ToolResultMetadata { version: 1 status: ToolResultStatus code?: string retryable?: boolean display?: ToolDisplay artifact?: ArtifactReference } interface ToolExecutionResult extends ToolResultMetadata { content: string }

content 是送入模型的安全文本投影;display 只给 CLI/Web 等界面使用。展示对象目前支持 text、command、search、diff、artifact 和 json 六种 kind,可带标题、有界正文 以及键值字段。模型正文和 UI 投影来自同一个工具结果,但不会互相覆盖:

情况tool result
成功status: success,可带展示投影
未知工具status: error, code: unknown_tool
deferred 工具未激活status: error, code: inactive_tool
单回合重复调用status: error, code: duplicate_call
JSON 参数损坏status: error, code: invalid_json
schema 校验失败status: error, code: invalid_arguments
Hook 拒绝status: denied, code: hook_denied
权限拒绝status: denied, code: permission_denied
用户取消status: cancelled, code: cancelled
工具执行异常status: error, code: tool_failed
进程中断恢复status: unknown, code: interrupted

这个协议保证 assistant tool call 与 tool result 成对闭合,历史可以继续发送给 Provider。

五种状态的语义要分开:

  • success:工具调用完成并返回观察,不保证业务命令 exit code 为 0;
  • error:参数、工具执行或存储增强发生可报告错误;
  • denied:授权或 Hook 明确拒绝,执行路径没有进入工具;
  • cancelled:取消信号在工具边界被观察到;如果工具有副作用能力,恢复分析仍按结果未知 处理,因为取消并不意味着外部动作没有发生;
  • unknown:通常由恢复逻辑合成,表示 started 之后没有可信完成结果。

普通工具路径不会用 unknown 猜测执行结果。只有进程退出、取消或恢复分析发现事实缺口时, 才把未闭合调用保守升级为 unknown。

retryable: true 只表示协议层允许安全地重新尝试,例如参数 JSON 可修正、deferred 工具可先 搜索再激活。未知工具、权限拒绝、Hook 拒绝和中断恢复都不能自动重放;未知副作用必须由用户 检查工作区或外部服务后显式确认。

持久化路径有两份互补数据:

  1. ModelMessage.toolResult 保存轻量元数据,完整模型正文仍在 content;
  2. tool.completed.result 暴露同一份元数据,同时保留旧字段 status、output 和 artifact 供旧客户端读取。

当正文超过 50 KB 且配置了 ArtifactStore 时,完整正文写入 artifact,消息只保留首尾预览。 同一个 artifact 引用会同时出现在 tool.completed.artifact、tool.completed.result.artifact 和持久化消息的 toolResult.artifact 中。展示数据不复制 artifact 正文,避免 Web 历史把大 输出再次塞进 DOM 或事件流。

Core 对第三方工具返回值做兼容归一化:

  • 字符串继续包装成 success 结果;
  • 合法 ToolExecutionResult 使用其状态和元数据;
  • 普通对象有界序列化后作为文本;
  • 带 version/status 但缺少合法 content 的对象不会因此获得错误或拒绝状态。

展示标题、正文、字段标签和值都会再次经过脱敏与长度限制。Web 只通过 React 文本节点渲染, 禁止 dangerouslySetInnerHTML,所以工具返回 <script>、<img onerror> 等内容只会显示为 字面文本。CLI 使用同一份状态、错误码和展示字段,但只输出适合终端阅读的摘要。

旧会话没有 toolResult 时,Web 才按历史正文前缀推断状态;新消息始终优先读取结构化状态。 这让同一个历史页面同时兼容旧数据和 v15 新数据,不需要重写 SQLite 表或批量迁移消息。

只读并行

当模型一次返回多个工具调用时,只有满足以下条件的批次可以并行:

  • 至少两个调用;
  • 全部工具标记为 concurrencySafe;
  • 全部有效风险为 read;
  • 参数全部通过 schema;
  • 没有重复调用。

任何一项不满足,整批调用退回串行路径。配置了权限服务或 Hook 时也不会直接并行,因为 每次调用必须先获得相同的 deny/ask 判断和审计记录。这个规则避免把一次“并行优化”变成 绕过权限的捷径。

扩展工具

内置工具之外,运行时还可以装配:

  • skill_view:按需读取已激活 Skill;
  • memory_search:只读检索长期记忆;
  • MCP 工具:接入外部服务;
  • LSP 诊断:只读代码分析;
  • Subagent 与 worktree 工具:把独立任务交给隔离执行上下文;
  • 插件声明的工具与服务。

扩展工具仍进入同一套 schema、风险、权限、Hook 和结果协议。扩展能力不会绕过 Core 的 执行边界。

只读 delegate_task 可以在单回合内并行执行,但单批最多四个。写型 delegate_worktree 仍按 execute 风险串行审批,并通过独立 worktree patch 返回结果。

后台只读子 Agent 通过 delegate_background 启动,使用 background_task_list 查询、background_task_cancel 取消。后台完成结果只向父会话发送 一次结构化通知;它不会共享子会话的中间消息或扩大写权限。

MCP 与 LSP 工具标记为 deferred。它们默认不进入模型 schema;模型先调用 search_tools,Core 按名称、aliases、tags、searchHint 和描述做本地加权匹配,再激活 工具,下一次模型请求才会看到对应 schema。名称不直观的扩展工具可以声明:

ts
searchMetadata: { aliases: ['db migration', 'schema update'], tags: ['database', 'migration', 'ddl'], searchHint: 'Use this when a database migration file must be applied.', }

检索不调用网络 embedding;名称、别名、标签、提示和描述的权重依次降低,同分按工具名稳定 排序。请求证据中的 toolNames、toolSchemaSha256 与 composition.activatedTools 共同 说明这一轮实际向 Provider 暴露了哪些 schema。未激活的 deferred 工具即使被模型凭空调用, 也会被 Core 拒绝执行。

工具调用如何进入可追踪链路

tool.started 在 schema、Hook 和权限决策全部通过后才产生,并携带 toolCallId、turnId、 有效 risk、sideEffect 以及可选的脱敏 toolSchema。工具完成后,tool.completed 使用 同一个 toolCallId 闭合结果。

只产生 tool.completed 而没有 tool.started,通常说明动作停在校验、Hook 或权限阶段。 已经产生 tool.started、却没有可信 completed 的副作用工具只能进入 unknown,不能自动 重放。模型请求另用 requestId 和事件 sequence 追踪;当前尚未接入 W3C traceparent 或 OpenTelemetry。完整的字段表和故障链见第 22 篇。

MCP 传输、LSP 生命周期与插件目录的信任边界会在第 13 篇展开。

浏览器、网页抓取、搜索、Computer Use Driver、MCP transport 和 SSH/Docker 执行世界的 完整实现与残余风险见第 23 篇。

代码阅读索引

关注点代码入口回归测试
基础工具装配、schema 和路径tools.tstools.test.ts
后台命令和 artifact 条件工具tools.tscommand-tools.test.ts、artifact-tools.test.ts
Core 工具生命周期agent-core/index.tsp0-loop.test.ts
工具风险和能力声明tool-security.tstool-security.test.ts
文件编辑与新建回归tools.test.ts同上

小结

工具系统的关键不只是“能读能写”,而是让每个动作具备可验证边界:

  • 基础、条件、会话和扩展工具在启动时合并成一张冻结表;
  • 输入由 schema 校验;
  • 路径由真实路径约束;
  • 能力声明决定风险下限,缺失声明按最高风险处理;
  • 风险决定审批;
  • 写操作保留快照并原子替换;
  • 结果必须回到模型;
  • 只读调用可以并行,有副作用的调用保持串行。

下一篇关注命令执行:审批通过之后,为什么仍需要第二层操作系统级隔离。

如果对你有用的话,可以打赏哦
打赏
ali pay
wechat pay

本文作者:繁星

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!