🛠️ 工具系统
OC2 看到 runLoop 调 SessionTools.resolve 解析工具。本章拆解 OpenCode 的统一工具模式:Tool.define 工厂函数。我们从 46 行的 todo.ts(最小范例)起步,看懂 tool.ts 的 wrap 装饰器如何统一所有工具。对应 LC6 的 @tool 和 LG8 的 ToolNode。
本章目标
- 理解
Tool.define(id, Effect.gen(...))工厂模式 - 看懂
Def接口(id + description + parameters + execute) - 掌握 wrap 装饰器如何统一校验/截断/追踪
- 理解 .ts 和 .txt 文件配对的约定(逻辑 vs 描述)
- 对照 LC6 的 @tool,理解"生产级工具"多了什么
Tool.define:统一入口
OpenCode 所有工具都用 Tool.define 定义。先看它的签名:
export function define<Parameters, Result, R, ID extends string = string>(
id: ID, // 工具唯一标识
init: Effect.Effect<Init<Parameters, Result>, never, R>, // 初始化逻辑(返回 Def)
): Effect.Effect<Info<Parameters, Result>, never, R | Truncate.Service | Agent.Service>
& { id: ID } {
return Object.assign(
Effect.gen(function* () {
const resolved = yield* init // 执行初始化
const truncate = yield* Truncate.Service // 截断服务(输出太长时截断)
const agents = yield* Agent.Service // agent 服务
return { id, init: wrap(id, resolved, truncate, agents) } // 用 wrap 包一层
}),
{ id },
)
}
Def 接口:工具的四要素
和 LC6 的 @tool 一样,OpenCode 工具也有四要素,但用 Def 接口显式定义:
export interface Def<Parameters, M extends Metadata> {
id: string // 工具唯一标识(如 "todowrite")
description: string // 给模型看的描述(对应 LC6 的 docstring)
parameters: Parameters // 参数 Schema(对应 LC6 的 args_schema)
jsonSchema?: JSONSchema7 // 可选:自定义 JSON Schema
execute( // ★ 执行逻辑(对应 LC6 的 _run)
args: Schema.Schema.Type<Parameters>,
ctx: Context // 工具上下文(sessionID/messages/ask 等,OC4 详讲)
): Effect.Effect<ExecuteResult<M>>
formatValidationError?(error: unknown): string // 可选:参数错误格式化
}
| LC6 @tool 要素 | OC3 Tool.define 要素 | 说明 |
|---|---|---|
函数名 → name | id | 工具标识 |
docstring → description | description(来自 .txt 文件) | 给模型看 |
类型注解 → args_schema | parameters: Schema.Struct | 参数 schema |
函数体 → _run | execute(args, ctx) | 执行逻辑 |
最小范例:todo.ts(46 行)
这是理解 OpenCode 工具的最佳起点——最简单的完整工具:
import { Effect, Schema } from "effect"
import * as Tool from "./tool"
import DESCRIPTION_WRITE from "./todowrite.txt" // ← 描述来自独立的 .txt 文件!
import { Todo } from "../session/todo"
// ① 参数 Schema(对应 LC6 的 args_schema)
export const Parameters = Schema.Struct({
todos: Schema.mutable(Schema.Array(Todo.Info))
.annotate({ description: "The updated todo list" }),
})
// ② 定义工具
export const TodoWriteTool = Tool.define(
"todowrite", // id
Effect.gen(function* () {
const todo = yield* Todo.Service // 注入依赖(Effect 的 DI)
return {
description: DESCRIPTION_WRITE, // 从 .txt 导入描述
parameters: Parameters,
// ③ 执行逻辑
execute: (params, ctx) => Effect.gen(function* () {
// ★ 权限检查(OC4 详讲)——每次写操作都要问
yield* ctx.ask({
permission: "todowrite",
patterns: ["*"], always: ["*"],
})
// 实际逻辑
yield* todo.update({ sessionID: ctx.sessionID, todos: params.todos })
// 返回结果(title + output + metadata)
return {
title: `${params.todos.filter(x => x.status !== "completed").length} todos`,
output: JSON.stringify(params.todos, null, 2),
metadata: { todos: params.todos },
}
}),
}
}),
)
- .txt 文件分离描述——
import DESCRIPTION from "./todowrite.txt"。OpenCode 把工具描述放独立文件,便于维护和迭代(不像 @tool 塞在 docstring 里)。 - ctx.ask 权限检查——执行前先问权限(OC4 详讲)。这是生产级工具的关键:危险操作要拦截。
- Effect.gen 注入依赖——
yield* Todo.Service拿到 Todo 服务。Effect 的依赖注入让工具可测试、可替换。
.ts 与 .txt 的配对约定
OpenCode 每个工具都是两个文件:.ts 是逻辑,.txt 是描述:
| 工具 | 逻辑文件 | 描述文件 |
|---|---|---|
| 读文件 | read.ts (386行) | read.txt (14行) |
| 编辑文件 | edit.ts (737行) | edit.txt (10行) |
| 写文件 | write.ts (104行) | write.txt |
| 执行命令 | shell.ts (645行) | shell.txt |
| 子任务 | task.ts (346行) | task.txt (19行) |
| 待办 | todo.ts (46行) | todowrite.txt |
工具描述(告诉模型"何时用这个工具")需要反复打磨,是影响 Agent 效能的关键。把它放独立 .txt:① 不污染代码逻辑;② 产品/PM 也能改;③ 可以做 A/B 测试;④ 支持多语言/多版本。这是生产级工具管理的最佳实践,比 @tool 的 docstring 更专业。
wrap 装饰器:统一增强
Tool.define 内部用 wrap 给每个工具的 execute 包一层通用逻辑:
function wrap(id, def, truncate, agents) {
return async (args, ctx) => {
// ① 参数 Schema 校验(失败抛 InvalidArgumentsError → 回传给模型重试)
try {
args = Schema.decodeSync(def.parameters)(args)
} catch (error) {
throw new InvalidArgumentsError({
tool: id,
message: def.formatValidationError?.(error) ?? "Invalid arguments",
})
}
// ② 调用真正的 execute
let result
try {
result = await def.execute(args, ctx).pipe(...)
} catch (error) {
// 错误处理(转成 ToolInvocationError 或回传)
}
// ③ 输出截断(防止超长输出撑爆 context,对应 F2 的记忆管理)
result.output = await truncate.process(result.output)
// ④ OpenTelemetry span(追踪每个工具调用)
return result
}
}
wrap 是经典的装饰器模式——在不修改原 execute 的前提下,统一添加横切关注点(校验、截断、追踪)。LC6 的 @tool 内部也做了类似的事(args_schema 校验)。OpenCode 把它做得更显式、更完整(加了 OTel 可观测)。生产代码的通用做法。
registry:工具注册表
所有工具在 registry.ts 注册,runLoop 通过它取工具:
// registry 内部实例化所有内置工具(每个都是 Tool.define Effect)
const builtInTools = Effect.all([
ReadTool, WriteTool, EditTool, BashTool, GlobTool, GrepTool,
TaskTool, TodoWriteTool, WebFetchTool, WebSearchTool, ...
])
// tools(model):按模型过滤返回可用工具
// - GPT 系用 apply_patch(合并编辑),其他用 edit/write 分开
// - websearch 按 provider 可用性过滤
// - 还会注入 MCP server 工具、插件工具
// describeTask:动态生成 task 工具描述(列出可用子 agent)
复杂工具示例:edit.ts
对比 todo.ts 的简单,看一个复杂工具——edit.ts(737 行)体现生产工具的工程细节:
// edit 工具:基于 oldString/newString 的精确替换
export const EditTool = Tool.define("edit", Effect.gen(function* () {
return {
description: DESCRIPTION, // edit.txt:告诉模型如何用
parameters: Schema.Struct({
filePath: Schema.String,
oldString: Schema.String, // 要替换的原文(必须精确匹配且唯一)
newString: Schema.String, // 替换为的新文本
}),
execute: (params, ctx) => Effect.gen(function* () {
// ① 权限检查(OC4)
yield* ctx.ask({ permission: "edit", patterns: [params.filePath] })
// ② 每文件加 Semaphore 锁(防止并发编辑同一文件冲突)
const sem = fileLocks.getOrCreate(params.filePath)
yield* sem.take(1)
// ... 执行替换(引用 cline/gemini-cli 的 diff 算法)
yield* sem.release(1)
// ③ 返回 diff 让模型看到改动
return { title: "已编辑", output: diff, ... }
}),
}
}))
生产工具比教学工具多的工程细节:
- 权限检查(OC4):每次危险操作拦截
- 并发控制:Semaphore 锁防冲突
- 算法引用:复用成熟开源实现(diff 算法)
- 错误恢复:oldString 不唯一/不匹配时给清晰反馈
- LSP 集成:写后触发诊断反馈(write.ts)
从 Tool.Def 到 AI SDK tool
runLoop 调用的 SessionTools.resolve 做的事——把 OpenCode 的 Tool.Def 包成 AI SDK 的 tool():
export function resolve({ agent, session, model, processor, messages }) {
return Effect.gen(function* () {
const tools = yield* registry.tools(model) // 取所有工具
const result = {}
for (const [id, def] of Object.entries(tools)) {
result[id] = tool({ // AI SDK 的 tool()
description: def.description,
inputSchema: def.parameters, // Schema 转 JSON Schema
execute: async (args, options) => {
// 构建 Tool.Context(sessionID/messages/ask/metadata)
const ctx = buildContext(args, options, agent, session)
// 调用真正的 execute(经 wrap 包装的)
const result = await def.execute(args, ctx)
// processor 处理结果(流式写为 ToolPart)
return result.output
},
})
}
return result // 返回 { read: tool(...), edit: tool(...), ... }
})
}
与框架对照
| OpenCode 概念 | LangChain/LangGraph 对应 | |
|---|---|---|
| Tool.define(id, Effect.gen) | → | @tool 装饰器(LC6) |
| Def 接口(id+desc+params+execute) | → | BaseTool(name+description+args_schema+_run) |
| wrap 装饰器(校验/截断/追踪) | → | @tool 内部的 schema 校验 |
| .txt 描述文件 | → | docstring |
| registry.tools(model) | → | LG8 ToolNode(tools) |
| SessionTools.resolve | → | bind_tools 把工具包给模型(LC3) |
| ctx.ask 权限 | → | LG6 interrupt(HITL) |
| truncate 截断输出 | → | RemoveMessage 裁剪(LG2) |
小结
Tool.define(id, Effect.gen(...))是所有工具的统一入口,返回 Def 接口(id+description+parameters+execute)。- .ts 是逻辑,.txt 是描述——分离便于维护(生产最佳实践)。
wrap装饰器统一添加:参数校验、输出截断、OTel 追踪。registry注册所有工具,SessionTools.resolve把它们包成 AI SDK tool 传给 LLM。- 生产工具比教学工具多:权限检查、并发锁、算法引用、错误恢复、LSP 集成。
工具里有 ctx.ask 权限检查。下一章 OC4 · 权限系统:拆解 OpenCode 的三层权限合并(默认/配置/用户)和 ctx.ask 人在回路机制——这是 LG6 interrupt 的生产落地,也是真实 Agent 的安全基石。