opencode 工具层 Effect 迁移指南:Tool.define 目标形态、Effect 原生服务与测试对齐
本文基于 opencode 仓库中的迁移规格文档 tools.md 展开,梳理 packages/opencode 内建工具(tool)迁移到 Effect 的当前状态:Tool.define / Info.init() / execute 的 Effect 化目标形态、16 个已导出工具的盘点与剩余清理项,以及如何用仓库自带的 Effect 测试助手(testEffect / it.live)让工具测试与生产服务图保持一致。读完本文,你可以独立判断一个工具是否已完成 Effect 迁移、识别工具体内残留的 Promise / 平台桥接,并按仓库既有规范编写对齐服务图的工具测试。
一、迁移状态:不再是“是否要迁到 Effect”
tools.md 开篇即明确了当前的基线:在本分支上,Tool.Def.execute 与 Tool.Info.init 已经返回 Effect,内建工具的整体形态已经落在目标形状上。src/tool 下当前导出的工具全部使用 Tool.define(...) 做 Effect 化初始化,且几乎都使用 Effect.gen(...) 与 Effect.fn(...) 构建工具主体。
因此剩余工作已经收窄为三件事(直接继承自规格文档):
- 移除各工具体内的 Promise 与裸平台桥接(raw platform bridges);
- 把工具内部实现替换为 Effect 原生服务:
FSUtil、HttpClient、ChildProcessSpawner等; - 让测试与调用方对齐
yield* info.init()以及真实的服务图(service graph)。
这个判断可以从当前源码中得到印证:tool.ts 中的类型定义已经完全是 Effect 化的。
二、目标形态:Tool.define 的 Effect 原生结构
2.1 Info / Def 类型:init 与 execute 都是 Effect
tool.ts 中定义了工具的核心契约:
export interface Def<
Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,
M extends Metadata = Metadata,
> {
id: string
description: string
parameters: Parameters
jsonSchema?: JSONSchema7
execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
formatValidationError?(error: unknown): string
}
export interface Info<
Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,
M extends Metadata = Metadata,
> {
id: string
init: () => Effect.Effect<DefWithoutID<Parameters, M>>
}
关键点:
Info.init()返回Effect.Effect<DefWithoutID<...>>——工具初始化本身就是一个 Effect,依赖(文件系统、HTTP 客户端、LSP、Agent 服务等)通过yield*从服务图中解析;Def.execute返回Effect.Effect<ExecuteResult<M>>——执行路径全程 Effect,参数解码、权限询问、截断都发生在 Effect 组合内;ExecuteResult统一产出title/metadata/output/attachments?,其中attachments用于图片、PDF 等二进制附件。
规格文档特别强调:Tool.define(...) 已经是 Effect 原生的 helper,不需要再引入单独的 Tool.defineEffect(...) 辅助函数才算完成迁移。判断标准是“init 与 execute 路径保持 Effect 原生”,即使某些内部细节仍桥接到 Promise 或裸 API。
2.2 define 的包装逻辑:参数解码、错误归一与输出截断
tool.ts 中的 wrap(...) 与 define(...) 展示了每个工具在注册后获得的统一行为:
- 参数解码一次编译:
Schema.decodeUnknownEffect(toolInfo.parameters)在每次工具 init 时编译一次,避免每次 LLM 调用都重新闭包,这是注释里明确写出的性能考量(#L108-L111); - 无效参数归一为类型化错误:解码失败会被映射为
InvalidArgumentsError(tool.ts),其messagegetter 生成面向模型的改写提示——“The {tool} tool was called with invalid arguments: …Please rewrite the input so it satisfies the expected schema.”。这是一个Schema.TaggedErrorClass,上游可以精确匹配; - 输出统一截断:
execute返回后,若工具未自行标记metadata.truncated,包装层会用Truncate.Service与当前 Agent 配置截断输出,并把outputPath写入 metadata(#L130-L144); - 可观测性:每次执行都被
Effect.withSpan("Tool.execute", ...)包裹,携带tool.name、session.id、message.id、tool.call_id属性(#L113-L119, #L145)。
也就是说,完成迁移的工具不仅自身是 Effect,还自动继承了参数校验、截断与 tracing 这套横切逻辑——这正是“迁移收益”所在。
2.3 注册表:所有内建工具走 Tool.init
registry.ts 中的 ToolRegistry 服务展示了迁移后统一的服务图接入方式:
const tool = yield* Effect.all({
invalid: Tool.init(invalid),
shell: Tool.init(shell),
read: Tool.init(read),
glob: Tool.init(globtool),
grep: Tool.init(greptool),
edit: Tool.init(edit),
write: Tool.init(writetool),
task: Tool.init(task),
fetch: Tool.init(webfetch),
todo: Tool.init(todo),
search: Tool.init(websearch),
skill: Tool.init(skilltool),
patch: Tool.init(patchtool),
question: Tool.init(question),
lsp: Tool.init(lsptool),
plan: Tool.init(plan),
...(codeModeTool ? { execute: Tool.init(codeModeTool) : {}),
})
每个内建工具先 yield* ReadTool 之类的 Info(Effect),再 Tool.init(...) 得到 Def。从当前源码结构看,还有两个值得注意的点:
- 插件工具仍保留 Promise 桥接:
fromPlugin通过EffectBridge.make()把宿主 Effect 侧的ask桥成 Promise 返回的函数,再用Effect.promise包装插件的def.execute(registry.ts)。这是迁移边界上刻意保留的兼容层,对应规格文档里“移除工具体内的 Promise 桥接”这一清理方向; - 模型相关的工具选择策略:
tools()中当modelID包含gpt-(且非oss/gpt-4)时启用apply_patch并隐藏edit/write;websearch仅在 opencode 官方 provider 或启用 exa/parallel 时开放(registry.ts)。code-mode则通过flags.experimentalCodeMode实验开关动态加载。
三、已导出工具清单与模块边界
规格文档列出当前 src/tool 下使用 Tool.define(...) 的导出工具(全部已勾选完成):
| 工具文件 | 状态 |
|---|---|
apply_patch.ts |
[x] 已迁移 |
bash.ts(shell 工具) |
[x] 已迁移 |
edit.ts |
[x] 已迁移 |
glob.ts |
[x] 已迁移 |
grep.ts |
[x] 已迁移 |
invalid.ts |
[x] 已迁移 |
lsp.ts |
[x] 已迁移 |
plan.ts |
[x] 已迁移 |
question.ts |
[x] 已迁移 |
read.ts |
[x] 已迁移 |
skill.ts |
[x] 已迁移 |
task.ts |
[x] 已迁移 |
todo.ts |
[x] 已迁移 |
webfetch.ts |
[x] 已迁移 |
websearch.ts |
[x] 已迁移 |
write.ts |
[x] 已迁移 |
规格文档同时划定了模块边界,避免把“工具”误判为“工具支撑模块”:
- 当前分支没有
ls.ts工具文件(目录列举能力内嵌在read工具中,见下文 4.1 的目录分支); truncate.ts是工具使用的 Effect 服务,不是工具定义本身;mcp-exa.ts、external-directory.ts、schema.ts是支撑模块,不是独立工具定义。
用当前仓库交叉验证:src/tool 下确实不存在 ls.ts;Tool.define 调用点覆盖 read.ts、edit.ts、write.ts、grep.ts、glob.ts、apply_patch.ts、webfetch.ts、websearch.ts、todo.ts、question.ts、plan.ts、skill.ts、task.ts、invalid.ts、lsp.ts 与 shell 工具(当前文件为 shell.ts,导出 ShellTool);此外还有一个实验性的 code-mode.ts(CodeModeTool)同样使用 Tool.define,由实验开关控制是否注册。
四、剩余清理项逐项拆解
规格文档的 “Follow-up cleanup” 指出:多数导出工具已经在目标形态上,剩余清理比旧清单暗示的要窄。下面按当前源码逐项展开。
4.1 read.ts —— 已完成:FSUtil 流式读取替换 legacy Node stream
规格文档标记该项为已完成:read.ts 现在通过 FSUtil.Service.stream 配合 Stream.splitLines 流式读取,legacy 的 Node stream / readline helper 已被移除。
read.ts 的 ReadTool.lines 展示了完整实现:
const lines = Effect.fn("ReadTool.lines")(function* (filepath, opts) {
const start = opts.offset - 1
const raw: string[] = []
const flags = { bytes: 0, count: 0, cut: false, more: false, done: false }
// 手动 TextDecoder 而非 Stream.decodeText:源流未 flush 结束时
// decodeText 会丢弃最后一个未终止行
const decoder = new TextDecoder("utf-8")
yield* fs.stream(filepath).pipe(
Stream.map((bytes) => decoder.decode(bytes, { stream: true })),
Stream.splitLines,
Stream.runForEach((text) =>
Effect.gen(function* () {
if (flags.done) return yield* new ReadStop()
// ... 跳过 offset 前的行、按 limit 截断、按 MAX_BYTES 截断
}),
),
Effect.catchTag("ReadStop", () => Effect.void),
)
return { raw, count: flags.count, cut: flags.cut, more: flags.more, offset: opts.offset }
})
这里有两个 Effect 化后特有的工程细节,值得复用:
- 用标签化错误(tagged error)
ReadStop提前终止上游文件流:达到字节上限时yield* new ReadStop(),再由Effect.catchTag兜底,避免读完整个文件; - 刻意回避
Stream.decodeText与Stream.runForEachWhile:源码注释说明二者会吞掉上游splitLines管道中最后一个未终止行,因此改用手动TextDecoder(#L142-L146)。
常量边界(read.ts):默认读取 2000 行、单行最长 2000 字符、输出硬上限 MAX_BYTES = 50 * 1024(50 KB,超过则提示 Use offset=N to continue)。
此外,ReadTool 的 init 签名本身就是 Effect 依赖解析的范例(read.ts):
export const ReadTool = Tool.define<
typeof Parameters,
Metadata,
FSUtil.Service | Instruction.Service | LSP.Service | Scope.Scope
>(
"read",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const instruction = yield* Instruction.Service
const lsp = yield* LSP.Service
const scope = yield* Scope.Scope
// ...
执行路径还包含:外部目录断言(assertExternalDirectoryEffect)、ctx.ask 权限询问、文件未找到时的“Did you mean”候选提示(ReadTool.miss,基于 fs.readDirectory)、二进制文件嗅探(采样 4096 字节 + 不可打印字符占比 >30% 判定)、以及目录参数时的目录列举分支(无 ls 工具的替代)。
4.2 bash/shell 工具 —— 已是 Effect 子进程原语,仅余平台桥接跟踪
规格文档标记为“部分完成”:已使用 Effect 子进程原语,只需持续跟踪 shell 相关的平台桥接与解析器加载细节。
当前 shell.ts 印证了这一点。init 中注入的服务包括(#L338-L347):
export const ShellTool = Tool.define(
ShellID.ToolID,
Effect.gen(function* () {
const config = yield* Config.Service
const spawner = yield* ChildProcessSpawner
const fs = yield* FSUtil.Service
const trunc = yield* Truncate.Service
const plugin = yield* Plugin.Service
const flags = yield* RuntimeFlags.Service
const defaultTimeoutMs = flags.bashDefaultTimeoutMs ?? 2 * 60 * 1000
进程创建使用 Effect 的 ChildProcess.make(...)(#L303-L309),路径解析使用 Effect.fn 化的 ShellTool.resolvePath(Windows 下经 cygpath 子进程归一化,#L349-L367)。而规格文档所说的“parser/loading 细节”在源码中对应 tree-sitter 解析器的懒加载(#L311-L336):
const parser = lazy(async () => {
const { Parser } = await import("web-tree-sitter")
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm", { with: { type: "wasm" } })
// ... 加载 bash / powershell wasm 语法
const [bashLanguage, psLanguage] = await Promise.all([Language.load(bashPath), Language.load(psPath)])
// ...
})
这里的 Promise.all + wasm import 就是规格文档中“still tracking”的典型残留:它被包在 lazy 单例里以隔离影响面,属于待清理的 Promise 桥接点。
4.3 webfetch.ts —— 已用 HttpClient,剩余工作集中在边界 helper
规格文档标记为“部分完成”:已使用 HttpClient,剩余工作限于 HTML 文本提取等较小的边界 helper。
webfetch.ts 的 init 直接从服务图取 HttpClient 并派生 filterStatusOk:
export const WebFetchTool = Tool.define(
"webfetch",
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const httpOk = HttpClient.filterStatusOk(http)
return {
description: DESCRIPTION,
parameters: Parameters,
execute: (params, ctx) => Effect.gen(function* () { /* ... */ }),
}
}),
)
请求细节(可直接引用的参数事实):
- 参数 schema:
url(必须http:///https://)、format(text|markdown|html,默认markdown,通过Schema.withDecodingDefault注入默认值)、timeout(秒,可选,上限 120); - 超时与体积上限:默认 30 秒、硬上限 120 秒(
DEFAULT_TIMEOUT/MAX_TIMEOUT),响应上限 5MB(MAX_RESPONSE_SIZE,同时检查content-length头与实际arrayBuffer.byteLength); - Cloudflare 拦截重试:命中
403 + cf-mitigated: challenge时改用真实 UAopencode重试一次(#L78-L92); - Accept 头按 format 分级降级,例如 markdown 请求是
text/markdown;q=1.0, …, text/html;q=0.7, */*;q=0.1。
而规格文档所说的“边界 helper”正对应文件末尾的两个纯函数 extractTextFromHTML(基于 htmlparser2 的 Parser,跳过 script/style/noscript/iframe/object/embed)与 convertHTMLToMarkdown(基于 turndown,#L158-L192)。它们是同步的 DOM-less 解析,属于可继续收编为 Effect 原生能力的残留边界。
4.4 ripgrep / 文件搜索 —— 相邻模块的原始 fs/process 用法
规格文档将 file/ripgrep.ts 列为工具迁移的相邻项:仍有 raw fs/process 用法,影响 grep.ts 与文件搜索路由。在“Filesystem notes”一节中,文档当时列出的主要 raw fs 用户就是它(fs/promises)。
对照当前仓库:该模块位于 packages/core/src/ripgrep.ts,从源码结构看已基于 Effect 构建(导入 Effect、Fiber、Layer、Stream 与 effect/unstable/process 的 ChildProcess),说明这条清理线在规格文档之后持续推进。而 packages/core/src 下仍可见 fs/promises 直接用法,例如 fs-util.ts、shell.ts 与 flock.ts——这类 Effect 服务底层的 Node 原语封装,与规格文档指出的“工具体内的裸平台桥接”是不同层次的问题:前者是服务实现的合理边界,后者才是工具迁移要清掉的对象。
4.5 apply_patch.ts —— 已完成:Effect 落在 FSUtil 上,解析器保持纯函数
规格文档标记为已完成:apply 路径现在基于 FSUtil.Service 返回 Effect;解析器与 chunk 替换器保持纯函数(pure)。
apply_patch.ts 印证了这一分工:
export const ApplyPatchTool = Tool.define(
"apply_patch",
Effect.gen(function* () {
const lsp = yield* LSP.Service
const afs = yield* FSUtil.Service
const format = yield* Format.Service
const events = yield* EventV2Bridge.Service
const run = Effect.fn("ApplyPatchTool.execute")(function* (params, ctx) {
if (!params.patchText) {
return yield* Effect.fail(new Error("patchText is required"))
}
let hunks: Patch.Hunk[]
try {
const parseResult = Patch.parsePatch(params.patchText) // 纯函数解析
hunks = parseResult.hunks
} catch (error) {
return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`))
}
// ...
模式很清楚:I/O(文件 stat/读写、事件发布)全部走 Effect 服务,语法解析与内容替换保持纯函数,解析失败用 Effect.fail 归一为类型化错误。这种“纯解析 + Effect 执行”的切分方式,是规格文档建议给其余工具沿用的做法。
规格文档还列出了一批“已在目标路径上、无需单独立项”的工具:apply_patch.ts、grep.ts、write.ts、websearch.ts、edit.ts。
五、测试规范:与生产服务图对齐的 Effect 测试
规格文档 “Tests” 一节的要求是工具测试必须使用 test/lib/effect.ts 中现成的 Effect 助手:
- 用
testEffect(...)/it.live(...)替代为 effect 工具伪造本地 wrapper; - yield 真实工具导出再初始化:
const info = yield* ReadTool,然后const tool = yield* info.init(); - 用
provideTmpdirInstance(...)或provideInstance(tmpdirScoped(...))一类的临时目录实例运行测试,让实例级服务按生产方式解析。
当前 effect.ts 的实现比文档描述更进一步,直接提供了 instance 变体:
// test/lib/effect.ts(节选)
const make = <R, E>(testLayer, liveLayer, run: Runner = isolatedRun) => {
const effect = (name, value, opts) => test(name, () => run(value, testLayer), opts)
// ...
const instance = (name, value, options?, opts?) => {
const args = instanceArgs(options, opts)
return test(name, () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), args.testOptions)
}
return { effect, live, instance }
}
export const it = make<never, never>(testEnv, liveEnv)
export const testEffect = <R, E>(layer) =>
make<R, E>(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv))
即:testEffect(SomeLayer).instance("name", Effect.gen(function* () { ... }), { git: true, config: {...} }) 会自动把测试体包进 withTmpdirInstance(...)(临时目录实例,fixture 提供),实例级服务如 InstanceState 的解析与生产完全一致。几个关键设计点:
- test 与 live 双环境:
test环境合并TestClock.layer()与TestConsole.layer(可控时间、可捕获日志),live环境用真实时钟但保留TestConsole(#L131-L137)。涉及真实子进程、网络的工具测试用live变体,可确定性的逻辑用test变体; - sharedRun 与 memoMap:
testEffectShared通过进程级memoMap构建测试 layer,使Bus、Session等缓存服务与Server.Default解析到同一实例——注释明确说明这是给“需要与进程内 HTTP server 保持 pub/sub 同一性”的测试用的,大多数测试应停留在testEffect(#L49-L53, #L142-L147); - 辅助原语:
awaitWithTimeout(默认 2 秒)与pollWithTimeout(20ms 轮询、默认 5 秒,#L149-L177)用于等待异步状态收敛,替代裸setTimeout轮询。
工具测试的完整范式因此是:
const info = yield* ReadTool // yield 真实工具导出(Info 即 Effect)
const tool = yield* info.init() // 走 wrap:获得解码/截断/tracing
// 随后以 tool.execute(args, ctx) 驱动真实执行路径
仓库中对应的测试文件覆盖各工具,例如 tool-read.test.ts、tool-bash.test.ts、tool-apply-patch.test.ts、tool-edit.test.ts、tool-question.test.ts、tool-webfetch.test.ts 等,均位于 packages/opencode/test/ 下。规格文档的结论是:这样做的目的是让工具测试与生产服务图对齐,使后续清理工作基本是机械性的。
六、迁移检查清单(可直接用于自检)
综合规格文档与源码现状,判断一个工具是否“完成 Effect 迁移”可按以下清单检查:
| 检查项 | 判定依据(当前仓库) |
|---|---|
工具由 Tool.define 导出 |
src/tool/*.ts 中 export const XxxTool = Tool.define(...);返回 Effect<Info> |
| init 依赖全部经服务图解析 | init 体内 yield* FSUtil.Service / yield* HttpClient / yield* ChildProcessSpawner 等,无直接 import "fs/promises" |
| execute 全路径 Effect | execute 返回 Effect.Effect<ExecuteResult>;错误用 Effect.fail 或标签化错误,不用裸 throw 处理业务失败 |
| 无 Promise / 平台桥接 | 工具体内不出现 Promise.all、setTimeout、readline 等;shell 工具的 tree-sitter 加载是已知残留点 |
| 纯解析与 I/O 分离 | 解析器/替换器等纯函数单独可测(参考 apply_patch.ts 的 Patch.parsePatch) |
| 测试对齐生产服务图 | 使用 testEffect(...).instance(...) 或 it.live,yield* ToolXxx + yield* info.init(),不造本地 fake wrapper |
七、小结:剩余工作地图
按 tools.md 的口径,工具迁移的主线已经完成:Tool.define 的 init/execute 双 Effect 契约在 tool.ts 中定型,16 个内建工具全部就位并由 registry.ts 统一经 Tool.init 注册。剩余的清理面已经非常具体:
- read.ts / apply_patch.ts:已完成(
FSUtil.Service.stream+Stream.splitLines;apply 路径 Effect overFSUtil.Service,解析保持纯函数); - shell(bash)工具:主体已是 Effect 子进程原语,持续跟踪 wasm parser 加载与 shell 平台桥接(shell.ts);
- webfetch:
HttpClient已就位,收尾 HTML 文本提取等边界 helper(webfetch.ts); - ripgrep 相邻模块:raw fs/process 用法影响
grep与文件搜索路由,当前 packages/core/src/ripgrep.ts 已从源码结构看转向 Effect +ChildProcess实现,属于持续跟踪项。
对后续参与开发的人而言,规格文档给出的判断标准值得记住:一个工具“有效迁移完成”的充要条件,是它的 init 与 execute 路径保持 Effect 原生、依赖从真实服务图解析,并且测试通过 yield* info.init() 走生产同构路径——其余的内部桥接只需按上表逐项收编。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00