对应路径:
packages/opencode/src/tool/前置阅读:第二篇 Agent 核心系统 学习目标:理解 OpenCode 里的工具不是几个零散脚本,而是一套统一的能力注册、权限控制、结果裁剪和模型适配机制
核心概念速览
如果说 Agent 是“大脑”,那工具系统就是它和外部世界的全部接口。
在当前仓库里,OpenCode 的工具系统不是简单的“给模型几个函数”:
- 工具先在注册表里汇总
- 再按客户端、模型、实验开关做过滤
- 执行前会走权限询问
- 执行后会统一裁剪输出
- 部分工具还会触发 LSP、子任务、插件 Hook 等后续链路
这意味着你理解工具系统时,不能只盯着 read.ts 或 bash.ts,而要先看“工具是怎么被接入 Agent 的”。
当前最关键的入口有三个:
- packages/opencode/src/tool/tool.ts:工具抽象
- packages/opencode/src/tool/registry.ts:工具注册表
- packages/opencode/src/tool/schema.ts:工具 ID 类型
本章导读
这一章解决什么问题
这一章不是教你背工具清单,而是要回答:
- 工具怎样进入 Agent 可见列表
- 为什么同一仓库里,不同模型和不同客户端看到的工具集合会不同
- 工具执行前后的权限、裁剪、metadata 更新是谁负责的
- 什么时候应该写新工具,什么时候应该改用 Skill 或 Command
必看入口
- packages/opencode/src/tool/registry.ts:工具汇总和过滤入口
- packages/opencode/src/tool/tool.ts:统一工具包装器
- packages/opencode/src/tool/read.ts:典型 I/O 工具
- packages/opencode/src/tool/bash.ts:高风险环境交互工具
- packages/opencode/src/tool/task.ts:编排型工具
一张图先建立感觉
registry.ts
-> 汇总内置工具
-> 接入插件 / Skill 工具
-> 按模型 / 客户端 / 开关过滤
-> Tool.define() 包装统一执行壳
-> 权限 / 参数 / 输出裁剪
-> read / bash / task ... 具体能力落地先抓一条主链路
建议只追下面这条链路:
registry.ts
-> 生成当前可用工具列表
-> Tool.define() 统一包装
-> 权限检查 / 参数校验
-> 具体工具执行
-> 输出裁剪 / metadata 更新先理解“工具是怎样被系统接纳和约束的”,再分别去看 read、bash、task 这些具体实现。
初学者阅读顺序
- 先看
registry.ts,建立工具全景。 - 再看
tool.ts,理解每个工具共有的执行外壳。 - 最后选一个读写类工具和一个编排类工具,对比它们的输入、权限和输出结构。
最容易误解的点
- 工具不是“模型直接调用的几个函数”,而是带权限、裁剪和产品元数据的一层系统边界。
- 新需求不一定要新工具,很多时候 Skill、Command、Prompt 组合更合适。
- 看工具时不能只盯函数体,真正决定工程质量的往往是执行前后的约束层。
3.1 工具注册与发现机制
先看真实注册表,而不是只看单个工具文件
OpenCode 当前内置工具的真实集合在 packages/opencode/src/tool/registry.ts。
它不是只注册 read/write/edit/glob/grep,而是把多类工具统一收进一个列表:
return [
InvalidTool,
...(question ? [QuestionTool] : []),
BashTool,
ReadTool,
GlobTool,
GrepTool,
EditTool,
WriteTool,
TaskTool,
WebFetchTool,
TodoWriteTool,
WebSearchTool,
CodeSearchTool,
SkillTool,
ApplyPatchTool,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
...custom,
]这张列表至少说明三件事:
- 工具系统不仅处理文件,还处理提问、任务拆分、网页读取、技能装载、补丁应用
- 一部分工具是否启用,取决于运行环境
- “当前有哪些工具可用”不是静态结论,而是注册表运行后的结果
工具的来源其实有三层
注册表会收集三类工具:
- 内置工具
- 项目级自定义工具
- 插件提供的工具
其中项目级自定义工具会扫描 tool/ 或 tools/ 目录:
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true })插件工具则来自 plugin.tool:
for (const plugin of plugins) {
for (const [id, def] of Object.entries(plugin.tool ?? {})) {
custom.push(fromPlugin(id, def))
}
}也就是说,OpenCode 的工具扩展路径至少有两条:
- 你可以在项目里直接塞一个工具文件
- 也可以通过 npm 插件把工具注入进来
这比“必须改核心源码才能加工具”更接近真实产品化设计。
工具不是全部无条件暴露给模型
工具列表拿到之后,还会继续过滤。当前仓库里至少有三类过滤逻辑:
- 客户端条件
- 模型条件
- 实验开关条件
例如:
question只在app/cli/desktop或显式开关下启用websearch/codesearch只在opencodeprovider 或 Exa 开关下启用apply_patch和edit/write会根据模型类型二选一lsp、batch、plan_exit都受实验开关控制
其中最有意思的是补丁工具选择:
const usePatch =
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
if (t.id === "apply_patch") return usePatch
if (t.id === "edit" || t.id === "write") return !usePatch这说明工具系统不只是“能力目录”,还会根据模型交互风格调整暴露给模型的工具形态。
3.2 Tool 抽象:一个工具到底由什么组成
Tool.define() 才是工具的标准入口
当前工具定义统一走 packages/opencode/src/tool/tool.ts:
export function define(id, init) {
return {
id,
init: async (initCtx) => {
const toolInfo = init instanceof Function ? await init(initCtx) : init
const execute = toolInfo.execute
toolInfo.execute = async (args, ctx) => {
toolInfo.parameters.parse(args)
const result = await execute(args, ctx)
const truncated = await Truncate.output(result.output, {}, initCtx?.agent)
return {
...result,
output: truncated.content,
metadata: {
...result.metadata,
truncated: truncated.truncated,
},
}
}
return toolInfo
},
}
}从这段代码你能看出,一个工具最少要解决四件事:
- 定义参数 Schema
- 提供面向模型的描述
- 实现执行逻辑
- 接受统一的结果裁剪包装
工具上下文里真正有价值的字段
Tool.Context 里最常用的不是很多,而是下面几个:
sessionIDmessageIDagentabortmessagesmetadata()ask()
其中最重要的是两个:
ctx.ask()
这不是“可选交互”,而是权限系统和工具系统的连接点。
很多工具真正危险的地方,不在执行代码本身,而在它们拿到什么权限。
ctx.metadata()
这允许工具把中间输出同步给前端,例如 bash 工具会在命令执行过程中不断更新输出片段,而不是等进程结束后一次性给结果。
工具 ID 也是正式类型,不是随便一串字符串
packages/opencode/src/tool/schema.ts 里定义了 ToolID,虽然不复杂,但它体现的是工程态度:
- 工具名称是系统协议的一部分
- 它会被其他模块稳定引用
- 不能把工具名当成无约束文本随便传来传去
3.3 按职责理解工具,而不是按文件名死记
如果你是初学者,我更建议按“能力类型”来记工具,而不是按文件个数来记。
第一类:文件与代码操作
这类是最基础的:
readeditwriteapply_patch
它们的区别不是“都能改文件”,而是修改粒度不同:
| 工具 | 适合场景 | 特点 |
|---|---|---|
read | 看文件、看目录、读取附件 | 支持目录、分页、图片/PDF |
edit | 精确替换局部文本 | 强依赖 oldString 命中 |
write | 整体重写文件 | 适合完整生成 |
apply_patch | 用补丁格式做结构化修改 | 更适合某些 GPT 模型 |
第二类:搜索与定位
这类是 Agent 真正“会找东西”的基础:
globgrepcodesearchlsp(实验)
其中:
glob解决“去哪找”grep解决“搜什么文本”codesearch解决“语义相关代码”lsp解决“符号级理解”
一个成熟 Agent 往往不是直接 read 大量文件,而是先靠这几种定位工具收窄范围。
第三类:环境交互
最典型的是:
bashwebfetchwebsearch
这三者分别解决:
- 本地环境执行
- 单页网页抓取
- 搜索引擎级发现
注意这三类能力的权限风险都比 read 高,所以当前仓库对它们的约束也更重。
第四类:任务编排
当前仓库里这类能力很重要,但初学者常常忽略:
taskquestionskilltodoplan_exitbatch(实验)
这些工具告诉我们,OpenCode 并不把工具系统限制在”外部 I/O”。 它也把”任务拆分””向用户追问””加载工作流””管理待办”都纳入了工具系统。
这正是现代 Agent 和早期函数调用机器人最大的区别之一。
重要说明:
skill在这里指的是 SkillTool(一个工具),不是 Skill 本身- Skill 是提示词工作流(SKILL.md + 辅助文件),不是工具
- SkillTool 的作用是把 Skill 内容加载到上下文
- 详见第十二篇对 Skill 系统的完整讲解
3.4 文件工具:不只是读写,而是一整套安全修改链路
read 的重点不是读文本,而是处理真实文件世界
packages/opencode/src/tool/read.ts 的能力比表面上丰富很多:
- 支持相对路径转绝对路径
- 会检查外部目录访问
- 会走
read权限询问 - 目录可直接读取
- 图片和 PDF 会转附件返回
- 大文件会分页与字节截断
- 会预热 LSP
- 会携带文件相关 instruction prompt
也就是说,read 在 OpenCode 里不是一个简单 fs.readFile() 包装器,而是“安全文件读取协议”。
edit 体现了 Agent 修改代码最难的部分
packages/opencode/src/tool/edit.ts 值得重点看,因为它非常接近真实 Agent 编辑难题:
- 先校验参数
- 外部目录检查
- 文件锁
- 文件时间校验
- 保持原始换行风格
- 生成 diff
- 请求编辑权限
- 写回文件
- 广播文件变更事件
- 触发 LSP 诊断
这个链路说明一个现实问题:
Agent 改代码最难的不是“替换字符串”,而是确保替换过程在并发、诊断、权限和可回溯性上都成立。
write 与 edit 的区别
write 不是 edit 的简化版,而是适合“整体生成”的另一条路径。
它的典型流程是:
- 读取旧内容
- 生成整文件 diff
- 请求
edit权限 - 写入新内容
- 发布文件变更事件
- 触发项目级 LSP 诊断
值得注意的是:
write会汇总当前文件诊断- 还会附带其他文件的部分诊断
这意味着 OpenCode 假设“一个新文件或整文件重写,可能影响整个工程”,而不是只影响当前文件。
3.5 bash 工具:最强也最危险的能力
为什么 bash 是 Agent 系统里的分水岭
packages/opencode/src/tool/bash.ts 不是简单 spawn(command)。
它先做了很多准备:
- 解析命令 AST
- 识别潜在外部目录
- 推导命令前缀权限模式
- 请求
external_directory权限 - 请求
bash权限 - 通过插件 Hook 注入环境变量
- 处理超时、终止、流式输出和元数据更新
尤其是这两段逻辑很关键:
await ctx.ask({
permission: "external_directory",
patterns: globs,
always: globs,
metadata: {},
})
await ctx.ask({
permission: "bash",
patterns: Array.from(patterns),
always: Array.from(always),
metadata: {},
})这说明 OpenCode 不是粗暴地把“执行命令”当成一个整体权限,而是拆成:
- 你要不要碰外部目录
- 你要不要执行这类命令
bash 也是插件插手运行环境的入口
命令执行前,bash 会触发:
const shellEnv = await Plugin.trigger("shell.env", ...)这意味着插件可以给 shell 注入环境变量。
所以工具系统和插件系统不是割裂的,它们在运行期会实际协作。
这类设计很适合写进电子书,因为它能让初学者明白:
Agent 的能力不是单模块决定的,而是多个子系统在工具执行时汇合。
3.6 搜索、网页与远程上下文工具
glob / grep 是真正的第一跳
虽然这两类工具看起来普通,但在 Agent 工作流里优先级很高。
经验上,一个稳健的 Agent 搜仓库通常是:
glob先缩小文件范围grep再找文本命中read只读少量候选文件- 必要时才上
lsp
这也是你写电子书时可以反复强调的一条方法论:
Agent 高效,不是因为它什么都能做,而是因为它先缩小搜索空间。
webfetch 解决的是“拉正文”,不是“全网搜索”
packages/opencode/src/tool/webfetch.ts 的职责很清晰:
- URL 校验
webfetch权限确认- 超时控制
- 请求头协商
- 按需返回 text / markdown / html
- 图片直接转附件
它适合的是:
- 已经知道目标 URL
- 需要拉正文
- 需要把网页转成适合模型消费的格式
websearch / codesearch 受供应商能力控制
注册表里对这两个工具有专门条件:
if (t.id === "codesearch" || t.id === "websearch") {
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
}这背后有两个现实约束:
- 搜索能力通常依赖外部服务,不是所有部署都能用
- 工具集本身会受到产品套餐、provider 或环境开关影响
从 Agent 工程角度看,这说明工具系统同时也是商业能力和运行时能力的分发层。
3.7 task、question、skill:工具系统里的编排能力
task 工具其实是在创建子 Agent 会话
packages/opencode/src/tool/task.ts 很值得看,因为它不是传统意义上的“工具调用外部 API”。
它会:
- 检查
task权限 - 找到目标
Subagent - 创建或恢复子会话
- 继承父消息模型信息
- 禁掉一部分工具
- 调用
SessionPrompt.prompt()运行子任务 - 返回
task_id
这意味着 task 工具其实是 OpenCode 的“多 Agent 编排入口”。
所以你在写书时完全可以把它定义为:
任务型工具,而不是 I/O 型工具。
question 工具是 Agent 何时该停下来问人的正式机制
packages/opencode/src/tool/question.ts 的价值在于,它把“问用户补充信息”做成了一个标准工具,而不是让模型随便输出一句问题。
这会带来两个好处:
- 前端可以针对它做专门交互
- 回答结果能结构化返回给 Agent
这也是为什么一个成熟 Agent 系统不该把所有交互都塞进纯文本。
skill 工具负责”按需加载 Skill 内容”
重要概念区分:
很多初学者会困惑:Skill 是工具吗?
答案:Skill 不是工具,但有一个工具用来加载 Skill。
- Skill:一份 SKILL.md + 辅助文件(提示词工作流)
- SkillTool:一个内置工具,用来把 Skill 内容注入上下文
- 关系:
SkillTool.execute(name)→ 返回Skill.content
SkillTool 的实现:
packages/opencode/src/tool/skill.ts 会做三件事:
- 列出当前可用 Skill
- 请求
skill权限 - 把
SKILL.md内容和目录样本注入上下文
所以 Skill 的本质不是”静态文档”,而是上下文按需装载机制。
为什么这个区分很重要:
- Skill 在工具注册表中的体现是 SkillTool
- Skill 本身存储在
.opencode/skill/目录 - 第十二篇会详细讲解 Skill 系统的完整设计
todo 把待办列表也变成了工具
todo:把待办列表正式结构化
这说明 OpenCode 的工具系统并不局限于”操作外部世界”,它同样承担”组织 Agent 自身工作流”的角色。
3.8 自定义工具开发指南
先选扩展方式
在当前仓库里,自定义工具主要有两条路:
- 项目级工具
- 插件工具
如果你只是给当前项目自己用,优先选项目级工具。
如果你要做复用或发布,才考虑插件工具。
项目级工具的思路
注册表会扫描 tool/*.ts 或 tools/*.ts,并把导出的定义转成标准工具。
所以一个最小项目级工具通常只需要:
import { tool } from "@opencode-ai/plugin"
export const hello = tool({
description: "返回测试文本",
args: {
name: tool.schema.string(),
},
async execute(args) {
return `hello ${args.name}`
},
})然后把文件放进项目的 tool/ 目录即可。
写工具时最容易忽略的四件事
1. 先想权限,不要先想功能
如果你的工具会:
- 改文件
- 跑命令
- 访问外部目录
- 发网络请求
那第一件事就是设计 ctx.ask(),而不是先写业务逻辑。
2. 输出要面向模型,而不只是面向人
工具返回的 output 不是日志,而是下一步推理的输入。
所以输出结构要清楚、短、可继续推理。
3. metadata 是给界面和调试用的
不要把所有信息都塞进 output。
能放进 metadata 的中间状态,尽量放进 metadata。
4. 接受输出裁剪这件事
只要你不自己声明 metadata.truncated,工具返回值默认会被 Truncate.output() 统一处理。
这意味着你不能假设模型一定能看到完整原始输出。
什么时候不该新增工具
这是给初学者最重要的一条提醒。
下面几种情况通常不需要新工具:
- 只是想复用一段提示词:用 Command
- 只是想固定一个流程:用 Skill
- 只是想把多个已有工具串起来:先用 Agent Prompt 或 Skill
很多新手会过早把问题“代码化”。
但在 Agent 工程里,真正稀缺的往往不是新工具,而是合理的流程约束。
本章小结
这一篇最该记住什么
- 工具系统的核心入口不是单个工具文件,而是注册表
- 工具集合会随着客户端、模型和开关变化
- OpenCode 的工具不只做文件和命令,也做提问、任务拆分、技能装载
- 每个工具的真实价值不在函数体,而在权限、输出、裁剪和后续链路
- 不是所有需求都该新增工具,很多需求更适合 Skill 或 Command
关键代码位置
| 模块 | 位置 | 建议重点 |
|---|---|---|
| 工具抽象 | packages/opencode/src/tool/tool.ts | Tool.define()、参数校验、输出裁剪 |
| 工具注册表 | packages/opencode/src/tool/registry.ts | 内置工具、模型过滤、自定义工具接入 |
| 文件读取 | packages/opencode/src/tool/read.ts | 目录读取、附件、分页、权限 |
| 精确编辑 | packages/opencode/src/tool/edit.ts | diff、锁、LSP、文件时间校验 |
| 整体写入 | packages/opencode/src/tool/write.ts | 覆写、诊断汇总、文件事件 |
| 命令执行 | packages/opencode/src/tool/bash.ts | AST 分析、权限、插件环境、流式元数据 |
| 子任务编排 | packages/opencode/src/tool/task.ts | Subagent、子会话、任务恢复 |
| 用户追问 | packages/opencode/src/tool/question.ts | 结构化提问与回答 |
| 网页抓取 | packages/opencode/src/tool/webfetch.ts | 内容协商、超时、附件 |
源码阅读路径
- 先看
packages/opencode/src/tool/registry.ts,把当前工具全景建立起来。 - 再看
packages/opencode/src/tool/tool.ts,理解一个工具如何被统一包装执行。 - 最后任选一个 I/O 工具和一个编排型工具,例如
read.ts+task.ts,比较它们的输入、权限和输出差异。
动手练习
- 把当前工具按“文件操作 / 搜索定位 / 环境交互 / 编排”分成四类,各举两个例子。
- 顺着一条真实链路追一次:从
registry.ts找到bash或read,再看它是怎样进入 Agent 可见工具列表的。
下一篇预告
理解了工具之后,再看会话系统就更容易了:
- 工具输出如何进入消息流
- 子任务和父任务如何形成会话树
- 为什么上下文压缩会影响工具使用
- 流式响应和 SSE 为什么是会话层问题
这也是第四篇要解决的核心问题。
思考题
- 为什么工具系统的核心入口应该先看
registry.ts和tool.ts,而不是直接看某个具体工具文件? - 如果一个需求既可以写成新工具,也可以写成 Skill 或 Command,你会怎么判断边界?
- 为什么同一个工具在不同模型或不同客户端下,可能不应该暴露成同一套能力?