系列文章:从零实现一个通用智能体
本篇主题:工具协议、工作区路径、读写操作与扩展工具
代码版本:clawsmith 0.1.7
工具是模型接触真实环境的接口。一个工具定义需要同时回答四个问题:
clawsmith 的 ToolDefinition 把这些信息放在同一个协议对象中。模型只看到 JSON Schema,
运行时则使用同一份 Zod schema 做严格校验:
tsexport 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 计算风险下限的依据:
textfilesystem.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_file | read | 是 | 读取工作区文本文件 |
search_files | read | 是 | 搜索文件名与文本内容 |
glob_files | read | 是 | 按 *、**、? 查找文件路径 |
edit_file | workspace-write | 否 | 替换唯一匹配的文本块 |
write_file | workspace-write | 否 | 创建不存在的新文件 |
rename_file | workspace-write | 否 | 移动文件且不覆盖已有目标 |
delete_file | workspace-write | 否 | 删除普通文件 |
run_command | execute | 否 | 在沙箱中运行命令 |
这八种工具并不等于模型实际看到的完整集合。AgentCore 会在构造阶段合并基础工具、会话
工具、可选服务和扩展工具,形成一次冻结的工具表;重复工具名在启动阶段直接失败,避免权限
规则或模型调用落到不确定实现上。
注入 ArtifactStore 后,额外出现 read_artifact。它只能按当前会话读取已经外置的工具输出,
支持字节范围、行范围和关键词检索,不能接受宿主路径:
| 条件工具 | 风险 | 作用 |
|---|---|---|
read_artifact | read | 分块读取会话内 artifact,供大工具输出回查 |
注入 CommandExecutionService 后,会同时出现后台命令工具组:
| 条件工具 | 风险 | 作用 |
|---|---|---|
start_command | execute | 启动跨 turn 保存状态的长命令 |
list_commands | read | 列出当前会话的后台任务 |
command_terminal_capabilities | read | 探测 PTY、resize、ANSI 和接管能力 |
command_status | read | 查询状态、退出码和 artifact 引用 |
command_output | read | 按 offset / maxBytes 读取增量日志 |
command_input | execute | 向普通管道 stdin 或持有 token 的 PTY 写入输入 |
resize_command_pty | execute | 调整持有控制权 PTY 的行列数 |
cancel_command | execute | 取消任务及其进程树 |
Agent Core 还会注入两个会话工具:todo_write 用于全量更新任务状态,todo_list 用于读取
当前任务。它们不产生文件或命令副作用,但任务会持久化到 SQLite 的 session_todos 表,
并显示在 Web 会话中。
最后,根据运行时装配的 catalog、检索器和扩展,模型可能还会看到 skill_view、
memory_search、MCP、LSP、Subagent、worktree、插件工具和 search_tools。这些不是无条件
内置工具;它们是否出现由依赖、配置、信任状态和 deferred 激活集合共同决定。
所有文件路径先解析为工作区内的真实路径:
tsconst 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}`)
}
这里有两道检查:
.. 跳出工作区;第一次检查在 realpath 前完成,避免一个明显越界的路径被用来探测外部文件是否存在。
第二次检查处理“路径文本在工作区内,但链接目标在工作区外”的情况。
read_file 默认最多读取 100,000 字节。实现会额外读取一个字节,仅用于判断是否截断:
tsconst 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_files 用于先定位文件,再由模型决定读取哪些内容。它支持 *、** 和 ?,同样跳过
依赖与构建目录,并限制结果数和访问文件数。
模式必须是 workspace 相对路径。包含 ..、绝对路径或逃出 workspace 的模式会直接拒绝。
write_file 只负责创建不存在的新文件。已有文件必须通过精确匹配的 edit_file 修改,从
工具语义上避免“顺手覆盖整个文件”。
rename_file 只接受普通文件和不存在的目标路径,目标父目录必须已经存在。它会在实际移动前
同时登记源文件与目标文件检查点,因此回退时恢复源路径并删除目标路径。delete_file 同样只
接受普通文件;目录删除没有隐式递归能力。
父目录必须已经存在,且其真实路径仍位于 workspace 内。内容上限为 1 MB,写入使用同目录 临时文件和原子 link 创建目标文件。写入前会记录“文件原本不存在”,因此 undo 可以删除 新建文件,而不只是恢复旧内容。
edit_file 不使用模糊匹配。oldText 必须出现且只能出现一次:
tsif (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',
)
}
这条规则降低“改错同名代码块”的风险。找不到文本通常意味着模型上下文已经过期,应重新 读取,而不是猜测位置。
替换前还会:
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:
tsfunction 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 /、递归修改根路径权限、关机重启等规则没有“批准后放行”的路径。
命令随后还会经过:
ulimit;沙箱会在下一篇展开。
执行链不会把工具异常直接抛给用户。每个 assistant tool call 都必须产生一条 tool result, 否则下一轮 Provider 请求会缺少调用闭环。v15 协议把“给模型看的正文”和“给客户端看的 状态/展示信息”分开:
tstype 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 拒绝和中断恢复都不能自动重放;未知副作用必须由用户
检查工作区或外部服务后显式确认。
持久化路径有两份互补数据:
ModelMessage.toolResult 保存轻量元数据,完整模型正文仍在 content;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;任何一项不满足,整批调用退回串行路径。配置了权限服务或 Hook 时也不会直接并行,因为 每次调用必须先获得相同的 deny/ask 判断和审计记录。这个规则避免把一次“并行优化”变成 绕过权限的捷径。
内置工具之外,运行时还可以装配:
skill_view:按需读取已激活 Skill;memory_search:只读检索长期记忆;扩展工具仍进入同一套 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。名称不直观的扩展工具可以声明:
tssearchMetadata: {
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.ts | tools.test.ts |
| 后台命令和 artifact 条件工具 | tools.ts | command-tools.test.ts、artifact-tools.test.ts |
| Core 工具生命周期 | agent-core/index.ts | p0-loop.test.ts |
| 工具风险和能力声明 | tool-security.ts | tool-security.test.ts |
| 文件编辑与新建回归 | tools.test.ts | 同上 |
工具系统的关键不只是“能读能写”,而是让每个动作具备可验证边界:
下一篇关注命令执行:审批通过之后,为什么仍需要第二层操作系统级隔离。


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