Tool 调用系统
eec0843ce422
Agent 生成档案
Section titled “Agent 生成档案”- 章节 ID:
05-tool-calling - 章节摘要:从 read 能力出发,理解 Tool.define、registry 筛选、本轮绑定、参数校验、权限、hook、截断与 MCP 汇合。
- 教程版本:
eec0843c - 源码基线:
eec0843ce42298080569ca31a6455bc3f699d213 - 章节元数据:/versions/eec0843c/data/chapters.json
- 源码映射:/versions/eec0843c/data/source-map.json
主要源码路径
Section titled “主要源码路径”packages/opencode/src/tool/tool.tspackages/opencode/src/tool/registry.tspackages/opencode/src/session/tools.tspackages/plugin/src/tool.ts
本章以 OpenCode 源码版本
eec0843ce422为证据基线。我们追踪一条典型源码路径:本轮 agent 获得read工具,模型提交参数后,OpenCode 怎样校验、申请权限、执行并返回结果?这条路径由源码结构证明,不代表模型一定会在某次真实会话中选择read。
0. 本章学习目标
Section titled “0. 本章学习目标”学完这一章,你应该能够:
- 画出
Tool.define -> ToolRegistry -> SessionTools.resolve -> model execute的位置图。 - 区分工具定义、registry 中的工具、本轮暴露给模型的工具与一次 tool call。
- 沿源码解释参数校验、schema 转换、权限、metadata、插件 hook 和输出截断的位置。
- 说明内置工具、项目工具、plugin tool 与 MCP tool 怎样汇入同一调用表。
- 判断模型选择工具、权限允许工具和工具成功执行为何是三件不同的事。
- 为自己的 mini agent 设计一个可扩展、可审计的工具边界。
1. 一句话讲明白
Section titled “1. 一句话讲明白”OpenCode 的 Tool 系统是能力注册表加安全执行适配器:registry 决定“这一轮有哪些能力”,SessionTools.resolve 决定“模型怎样调用它们”,具体工具只实现受 Schema 与 Context 约束的动作。
本章的中心问题是:
模型输出
{ tool: "read", args: ... }后,为什么不会直接变成一次不受控的函数调用?
因为模型只提出调用;OpenCode 仍要经过工具筛选、provider schema 转换、运行时参数解码、权限判断、执行 hook、输出截断和附件归属补全。核心合同见 packages/opencode/src/tool/tool.ts:16-45、packages/opencode/src/session/tools.ts:24-116。
packages/opencode/src/tool/tool.ts
packages/opencode/src/tool/tool.ts:16-45
16export type Context<M extends Metadata = Metadata> = {定义数据结构约束。17 sessionID: SessionID18 messageID: MessageID19 agent: string20 abort: AbortSignal用于中断运行任务。21 callID?: string22 extra?: { [key: string]: unknown }23 messages: MessageV2.WithParts[]会话消息片段结构。24 metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>Effect 异步工作流。25 ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>Effect 异步工作流。26}2728export interface ExecuteResult<M extends Metadata = Metadata> {定义数据结构约束。29 title: string30 metadata: M31 output: string32 attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]会话消息片段结构。33}3435export interface Def<定义数据结构约束。36 Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,定义并校验数据形状。37 M extends Metadata = Metadata,38> {39 id: string40 description: string41 parameters: Parameters42 jsonSchema?: JSONSchema743 execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>工具真正执行入口。44 formatValidationError?(error: unknown): string45}
packages/opencode/src/session/tools.ts
packages/opencode/src/session/tools.ts:24-116
24export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {Effect 异步工作流。25 agent: Agent.Info26 model: Provider.Model选择模型或 provider。27 session: Session.Info28 processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">处理模型流事件。29 bypassAgentCheck: boolean30 messages: MessageV2.WithParts[]会话消息片段结构。31 promptOps: TaskPromptOps32}) {33 using _ = log.time("resolveTools")34 const tools: Record<string, AITool> = {}35 const run = yield* EffectBridge.make()等待 Effect 结果。36 const plugin = yield* Plugin.Service调用插件扩展点。37 const permission = yield* Permission.Service等待 Effect 结果。38 const registry = yield* ToolRegistry.Service等待 Effect 结果。39 const mcp = yield* MCP.Service等待 Effect 结果。40 const truncate = yield* Truncate.Service等待 Effect 结果。4142 const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({43 sessionID: input.session.id,44 abort: options.abortSignal!,45 messageID: input.processor.message.id,46 callID: options.toolCallId,47 extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps },选择模型或 provider。48 agent: input.agent.name,49 messages: input.messages,50 metadata: (val) =>51 input.processor.updateToolCall(options.toolCallId, (match) => {52 if (!["running", "pending"].includes(match.state.status)) return match按条件进入分支。53 return {返回给上一层。54 ...match,55 state: {56 title: val.title,57 metadata: val.metadata,58 status: "running",59 input: args,60 time: { start: Date.now() },61 },62 }63 }),64 ask: (req) =>65 permission66 .ask({67 ...req,68 sessionID: input.session.id,69 tool: { messageID: input.processor.message.id, callID: options.toolCallId },70 ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),71 })72 .pipe(Effect.orDie),Effect 异步工作流。73 })7475 for (const item of yield* registry.tools({等待 Effect 结果。76 modelID: ModelID.make(input.model.api.id),选择模型或 provider。77 providerID: input.model.providerID,选择模型或 provider。78 agent: input.agent,79 })) {80 const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))定义并校验数据形状。81 tools[item.id] = tool({82 description: item.description,83 inputSchema: jsonSchema(schema),84 execute(args, options) {工具真正执行入口。85 return run.promise(返回给上一层。86 Effect.gen(function* () {Effect 异步工作流。87 const ctx = context(args, options)88 yield* plugin.trigger(调用插件扩展点。89 "tool.execute.before",90 { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },91 { args },92 )93 const result = yield* item.execute(args, ctx)工具真正执行入口。94 const output = {95 ...result,96 attachments: result.attachments?.map((attachment) => ({97 ...attachment,98 id: PartID.ascending(),99 sessionID: ctx.sessionID,100 messageID: input.processor.message.id,101 })),102 }103 yield* plugin.trigger(调用插件扩展点。104 "tool.execute.after",105 { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },106 output,107 )108 if (options.abortSignal?.aborted) {按条件进入分支。109 yield* input.processor.completeToolCall(options.toolCallId, output)等待 Effect 结果。110 }111 return output返回给上一层。112 }),113 )114 },115 })116 }
2. 为什么工具不是“给模型一组函数”这么简单
Section titled “2. 为什么工具不是“给模型一组函数”这么简单”一个本地函数只需要参数和返回值;一个 coding-agent tool 还必须回答:
- 它怎样向模型描述自己?
- 模型给的 JSON 参数若不合法,谁拦住?
- 当前 agent/session 是否允许这次动作?
- 如何响应取消?
- 工具执行进度怎样让 UI 看见?
- 超长输出怎样避免挤爆上下文?
- 插件与 MCP 工具怎样接入而不改 agent loop?
所以 Tool 不是裸函数,而是一条能力边界。生活类比可以是工具房:目录告诉你有哪些工具,领用窗口核对身份与用途,真正的器械执行动作,结果再登记回工单。类比只用于看形状;真实标识符是 Tool.Def、ToolRegistry.Service 与 SessionTools.resolve。
3. 先画地图:从定义到一次执行
Section titled “3. 先画地图:从定义到一次执行”内置 Tool.define 项目 tool/*.ts plugin tool | | | +------------- ToolRegistry.State ------------------+ | 按 model / flags 筛选 | v SessionTools.resolve | +----------------+----------------+ | | registry tools MCP tools | | +------ schema + execute ----------+ | v Record<string, AITool> | v LLM runtime | model tool call | v Context -> validate -> ask -> execute | v result / attachments / error| 层 | 它回答的问题 | 它不负责什么 |
|---|---|---|
Tool.define / Tool.Def | 单个能力怎样描述、校验、执行 | 不决定本轮是否可见 |
ToolRegistry | 系统有哪些工具,当前 model/flag 下留下哪些 | 不绑定本次 session/call id |
SessionTools.resolve | 怎样绑定 session、permission、processor 与 AI SDK | 不决定模型选择哪一个 |
| 具体 tool | 怎样执行 read/edit/shell 等动作 | 不维护整个 agent loop |
| processor/loop | 怎样记录结果并决定下一轮 | 不属于本章的工具注册内核 |
通用 Agent 最少需要 definition、registry、runtime adapter 三层;OpenCode 又加入 Effect Schema、插件、MCP、agent 权限、输出文件化截断和 provider schema 兼容。
4. 最小机制:先看 16 行能力边界
Section titled “4. 最小机制:先看 16 行能力边界”1type ToolDef<A> = {定义数据结构约束。2 name: string3 description: string4 schema: RuntimeSchema<A>定义并校验数据形状。5 execute(args: A, ctx: ToolContext): Promise<ToolResult>工具真正执行入口。6}78function toolsForRound(registry: ToolDef<unknown>[], round: RoundContext) {定义一段可复用逻辑。9 return Object.fromEntries(registry.map((def) => [返回给上一层。10 def.name,11 llmTool({12 description: def.description,13 inputSchema: adaptSchema(def.schema, round.model),14 async execute(raw, call) {工具真正执行入口。15 const args = decode(def.schema, raw)16 const ctx = bindContext(round, call)17 return def.execute(args, ctx)工具真正执行入口。18 },19 }),20 ]))21}
这不是源码逐行翻译。OpenCode 的 wrapper 还负责 tracing 与截断,session adapter 还负责 permission、metadata、plugin hook 和附件 ID。
5. 读源码前,只分清四种对象
Section titled “5. 读源码前,只分清四种对象”5.1 Tool.Info:可延迟初始化的注册信息
Section titled “5.1 Tool.Info:可延迟初始化的注册信息”1export interface Info<P, M> {定义数据结构约束。2 id: string3 init: () => Effect.Effect<DefWithoutID<P, M>>Effect 异步工作流。4}
来源:packages/opencode/src/tool/tool.ts:51-57
packages/opencode/src/tool/tool.ts
packages/opencode/src/tool/tool.ts:51-57
51export interface Info<定义数据结构约束。52 Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,定义并校验数据形状。53 M extends Metadata = Metadata,54> {55 id: string56 init: () => Effect.Effect<DefWithoutID<Parameters, M>>Effect 异步工作流。57}
内置工具模块通常先暴露这种 info;依赖齐备后,Tool.init(info) 才得到完整 Tool.Def。
5.2 Tool.Def:可执行的内部工具
Section titled “5.2 Tool.Def:可执行的内部工具”Tool.Def 包含 id、description、运行时 parameters、可选 jsonSchema、execute 与可选验证错误格式化函数。来源:packages/opencode/src/tool/tool.ts:35-45。
packages/opencode/src/tool/tool.ts
packages/opencode/src/tool/tool.ts:35-45
35export interface Def<定义数据结构约束。36 Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,定义并校验数据形状。37 M extends Metadata = Metadata,38> {39 id: string40 description: string41 parameters: Parameters42 jsonSchema?: JSONSchema743 execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>工具真正执行入口。44 formatValidationError?(error: unknown): string45}
5.3 AI SDK Tool:发给模型 runtime 的适配形状
Section titled “5.3 AI SDK Tool:发给模型 runtime 的适配形状”SessionTools.resolve 把内部 Tool.Def 包装成 AI SDK 的 { description, inputSchema, execute }。它已经绑定本轮 session、assistant message、agent 和 permission。
5.4 tool call:模型提出的一次动作
Section titled “5.4 tool call:模型提出的一次动作”tool call 带 tool name、call id 与 args。它不是定义,也不是授权。一次定义可以被调用多次,每次都有不同 call id、abort signal 与权限判断。
6. 追一条典型源码旅程:read 怎样成为本轮能力
Section titled “6. 追一条典型源码旅程:read 怎样成为本轮能力”本章不进入 read.ts 的路径解析细节;只追它如何进入 registry、暴露给模型并执行统一 wrapper。文件读写的内部行为由下一章展开。
6.1 第一站:工具合同把执行环境显式化
Section titled “6.1 第一站:工具合同把执行环境显式化”Tool.Context 包含:
1{2 sessionID,3 messageID,4 agent,5 abort,6 callID?,7 extra?,8 messages,9 metadata(...),10 ask(...),11}
来源:packages/opencode/src/tool/tool.ts:16-26
packages/opencode/src/tool/tool.ts
packages/opencode/src/tool/tool.ts:16-26
16export type Context<M extends Metadata = Metadata> = {定义数据结构约束。17 sessionID: SessionID18 messageID: MessageID19 agent: string20 abort: AbortSignal用于中断运行任务。21 callID?: string22 extra?: { [key: string]: unknown }23 messages: MessageV2.WithParts[]会话消息片段结构。24 metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>Effect 异步工作流。25 ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>Effect 异步工作流。26}
工具执行合同
packages/opencode/src/tool/tool.ts:16-45
参数与返回值只是半个合同;session、取消、进度和权限也属于执行边界。
16export type Context<M extends Metadata = Metadata> = {定义数据结构约束。17 sessionID: SessionID18 messageID: MessageID19 agent: string20 abort: AbortSignal用于中断运行任务。21 callID?: string22 extra?: { [key: string]: unknown }23 messages: MessageV2.WithParts[]会话消息片段结构。24 metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>Effect 异步工作流。25 ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>Effect 异步工作流。26}2728export interface ExecuteResult<M extends Metadata = Metadata> {定义数据结构约束。29 title: string30 metadata: M31 output: string32 attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]会话消息片段结构。33}3435export interface Def<定义数据结构约束。36 Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,定义并校验数据形状。37 M extends Metadata = Metadata,38> {39 id: string40 description: string41 parameters: Parameters42 jsonSchema?: JSONSchema743 execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>工具真正执行入口。44 formatValidationError?(error: unknown): string45}
ExecuteResult 统一返回 title、metadata、output 与可选 attachments。来源:packages/opencode/src/tool/tool.ts:28-33。
packages/opencode/src/tool/tool.ts
packages/opencode/src/tool/tool.ts:28-33
28export interface ExecuteResult<M extends Metadata = Metadata> {定义数据结构约束。29 title: string30 metadata: M31 output: string32 attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]会话消息片段结构。33}
为什么 context 不使用全局变量?同一进程可能同时处理不同 session/call;显式 context 才能正确归属状态、权限和取消。
6.2 第二站:Tool.define 装上统一 wrapper
Section titled “6.2 第二站:Tool.define 装上统一 wrapper”Tool.define(id, init) 取得 Truncate 与 Agent service,返回带静态 id 的 Effect;之后 Tool.init 执行延迟 init 并补回 id。来源:packages/opencode/src/tool/tool.ts:132-161。
packages/opencode/src/tool/tool.ts
packages/opencode/src/tool/tool.ts:132-161
132export function define<对外暴露模块成员。133 Parameters extends Schema.Decoder<unknown>,定义并校验数据形状。134 Result extends Metadata,135 R,136 ID extends string = string,137>(138 id: ID,139 init: Effect.Effect<Init<Parameters, Result>, never, R>,Effect 异步工作流。140): Effect.Effect<Info<Parameters, Result>, never, R | Truncate.Service | Agent.Service> & { id: ID } {Effect 异步工作流。141 return Object.assign(返回给上一层。142 Effect.gen(function* () {Effect 异步工作流。143 const resolved = yield* init等待 Effect 结果。144 const truncate = yield* Truncate.Service等待 Effect 结果。145 const agents = yield* Agent.Service等待 Effect 结果。146 return { id, init: wrap(id, resolved, truncate, agents) }返回给上一层。147 }),148 { id },149 )150}151152export function init<P extends Schema.Decoder<unknown>, M extends Metadata>(定义并校验数据形状。153 info: Info<P, M>,154): Effect.Effect<Def<P, M>> {Effect 异步工作流。155 return Effect.gen(function* () {Effect 异步工作流。156 const init = yield* info.init()等待 Effect 结果。157 return {返回给上一层。158 ...init,159 id: info.id,160 }161 })
真正的公共护栏在 wrap:
- 预编译
Schema.decodeUnknownEffect(parameters); - 每次调用先 decode 模型传入的未知 JSON;
- 调具体
execute(decoded, ctx); - 若工具未自行声明截断信息,按 agent 规则统一截断;
- 写入 tracing span 属性。
来源:packages/opencode/src/tool/tool.ts:79-130。
packages/opencode/src/tool/tool.ts
packages/opencode/src/tool/tool.ts:79-130
79function wrap<Parameters extends Schema.Decoder<unknown>, Result extends Metadata>(定义并校验数据形状。80 id: string,81 init: Init<Parameters, Result>,82 truncate: Truncate.Interface,83 agents: Agent.Interface,84) {85 return () =>返回给上一层。86 Effect.gen(function* () {Effect 异步工作流。87 const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init }等待 Effect 结果。88 // Compile the parser closure once per tool init; `decodeUnknownEffect`89 // allocates a new closure per call, so hoisting avoids re-closing it for90 // every LLM tool invocation.91 const decode = Schema.decodeUnknownEffect(toolInfo.parameters)定义并校验数据形状。92 const execute = toolInfo.execute93 toolInfo.execute = (args, ctx) => {94 const attrs = {95 "tool.name": id,96 "session.id": ctx.sessionID,97 "message.id": ctx.messageID,98 ...(ctx.callID ? { "tool.call_id": ctx.callID } : {}),99 }100 return Effect.gen(function* () {Effect 异步工作流。101 const decoded = yield* decode(args).pipe(等待 Effect 结果。102 Effect.mapError((error) =>Effect 异步工作流。103 toolInfo.formatValidationError104 ? new Error(toolInfo.formatValidationError(error), { cause: error })105 : new Error(106 `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,107 { cause: error },108 ),109 ),110 )111 const result = yield* execute(decoded as Schema.Schema.Type<Parameters>, ctx)工具真正执行入口。112 if (result.metadata.truncated !== undefined) {按条件进入分支。113 return result返回给上一层。114 }115 const agent = yield* agents.get(ctx.agent)等待 Effect 结果。116 const truncated = yield* truncate.output(result.output, {}, agent)等待 Effect 结果。117 return {返回给上一层。118 ...result,119 output: truncated.content,120 metadata: {121 ...result.metadata,122 truncated: truncated.truncated,123 ...(truncated.truncated && { outputPath: truncated.outputPath }),124 },125 }126 }).pipe(Effect.orDie, Effect.withSpan("Tool.execute", { attributes: attrs }))Effect 异步工作流。127 }128 return toolInfo返回给上一层。129 })130}
内置工具的参数校验与输出截断 wrapper
packages/opencode/src/tool/tool.ts:79-130
decode 在 execute 之前;truncate 在具体工具返回之后。
79function wrap<Parameters extends Schema.Decoder<unknown>, Result extends Metadata>(定义并校验数据形状。80 id: string,81 init: Init<Parameters, Result>,82 truncate: Truncate.Interface,83 agents: Agent.Interface,84) {85 return () =>返回给上一层。86 Effect.gen(function* () {Effect 异步工作流。87 const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init }等待 Effect 结果。88 // Compile the parser closure once per tool init; `decodeUnknownEffect`89 // allocates a new closure per call, so hoisting avoids re-closing it for90 // every LLM tool invocation.91 const decode = Schema.decodeUnknownEffect(toolInfo.parameters)定义并校验数据形状。92 const execute = toolInfo.execute93 toolInfo.execute = (args, ctx) => {94 const attrs = {95 "tool.name": id,96 "session.id": ctx.sessionID,97 "message.id": ctx.messageID,98 ...(ctx.callID ? { "tool.call_id": ctx.callID } : {}),99 }100 return Effect.gen(function* () {Effect 异步工作流。101 const decoded = yield* decode(args).pipe(等待 Effect 结果。102 Effect.mapError((error) =>Effect 异步工作流。103 toolInfo.formatValidationError104 ? new Error(toolInfo.formatValidationError(error), { cause: error })105 : new Error(106 `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,107 { cause: error },108 ),109 ),110 )111 const result = yield* execute(decoded as Schema.Schema.Type<Parameters>, ctx)工具真正执行入口。112 if (result.metadata.truncated !== undefined) {按条件进入分支。113 return result返回给上一层。114 }115 const agent = yield* agents.get(ctx.agent)等待 Effect 结果。116 const truncated = yield* truncate.output(result.output, {}, agent)等待 Effect 结果。117 return {返回给上一层。118 ...result,119 output: truncated.content,120 metadata: {121 ...result.metadata,122 truncated: truncated.truncated,123 ...(truncated.truncated && { outputPath: truncated.outputPath }),124 },125 }126 }).pipe(Effect.orDie, Effect.withSpan("Tool.execute", { attributes: attrs }))Effect 异步工作流。127 }128 return toolInfo返回给上一层。129 })130}
这回答了第一个安全问题:模型参数是“不可信的 unknown”,不能因为 TypeScript 标了类型就跳过运行时校验。
6.3 第三站:registry 初始化内置工具
Section titled “6.3 第三站:registry 初始化内置工具”ToolRegistry.layer 先取得各工具的 Info,再用 Tool.init 并发初始化 invalid、shell、read、glob、grep、edit、write、task 等工具。来源:packages/opencode/src/tool/registry.ts:120-139、packages/opencode/src/tool/registry.ts:229-249。
packages/opencode/src/tool/registry.ts
packages/opencode/src/tool/registry.ts:120-139
120 const invalid = yield* InvalidTool等待 Effect 结果。121 const task = yield* TaskTool等待 Effect 结果。122 const taskStatus = yield* TaskStatusTool等待 Effect 结果。123 const read = yield* ReadTool等待 Effect 结果。124 const question = yield* QuestionTool等待 Effect 结果。125 const todo = yield* TodoWriteTool等待 Effect 结果。126 const lsptool = yield* LspTool等待 Effect 结果。127 const plan = yield* PlanExitTool等待 Effect 结果。128 const webfetch = yield* WebFetchTool等待 Effect 结果。129 const websearch = yield* WebSearchTool等待 Effect 结果。130 const repoClone = yield* RepoCloneTool等待 Effect 结果。131 const repoOverview = yield* RepoOverviewTool等待 Effect 结果。132 const shell = yield* ShellTool处理命令执行。133 const globtool = yield* GlobTool读写本地文件。134 const writetool = yield* WriteTool等待 Effect 结果。135 const edit = yield* EditTool等待 Effect 结果。136 const greptool = yield* GrepTool等待 Effect 结果。137 const patchtool = yield* ApplyPatchTool等待 Effect 结果。138 const skilltool = yield* SkillTool等待 Effect 结果。139 const agent = yield* Agent.Service等待 Effect 结果。
packages/opencode/src/tool/registry.ts
packages/opencode/src/tool/registry.ts:229-249
229 const tool = yield* Effect.all({Effect 异步工作流。230 invalid: Tool.init(invalid),声明可调用工具。231 shell: Tool.init(shell),声明可调用工具。232 read: Tool.init(read),声明可调用工具。233 glob: Tool.init(globtool),声明可调用工具。234 grep: Tool.init(greptool),声明可调用工具。235 edit: Tool.init(edit),声明可调用工具。236 write: Tool.init(writetool),声明可调用工具。237 task: Tool.init(task),声明可调用工具。238 task_status: Tool.init(taskStatus),声明可调用工具。239 fetch: Tool.init(webfetch),声明可调用工具。240 todo: Tool.init(todo),声明可调用工具。241 search: Tool.init(websearch),声明可调用工具。242 repo_clone: Tool.init(repoClone),声明可调用工具。243 repo_overview: Tool.init(repoOverview),声明可调用工具。244 skill: Tool.init(skilltool),声明可调用工具。245 patch: Tool.init(patchtool),声明可调用工具。246 question: Tool.init(question),声明可调用工具。247 lsp: Tool.init(lsptool),声明可调用工具。248 plan: Tool.init(plan),声明可调用工具。249 })
最终 builtin 数组还受 runtime flags 影响,例如 question、background task status、experimental scout/LSP/plan。来源:packages/opencode/src/tool/registry.ts:251-275。
packages/opencode/src/tool/registry.ts
packages/opencode/src/tool/registry.ts:251-275
251 return {返回给上一层。252 custom,253 builtin: [254 tool.invalid,255 ...(questionEnabled ? [tool.question] : []),256 tool.shell,处理命令执行。257 tool.read,258 tool.glob,259 tool.grep,260 tool.edit,261 tool.write,262 tool.task,263 ...(flags.experimentalBackgroundSubagents ? [tool.task_status] : []),264 tool.fetch,265 tool.todo,266 tool.search,267 ...(flags.experimentalScout ? [tool.repo_clone, tool.repo_overview] : []),268 tool.skill,269 tool.patch,270 ...(flags.experimentalLspTool ? [tool.lsp] : []),271 ...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []),272 ],273 task: tool.task,274 read: tool.read,275 }
所以“OpenCode 有哪些内置工具”不是一个只由 import 列表决定的常量;客户端类型和实验开关会影响实际集合。
6.4 第四站:项目工具与 plugin tool 进入同一 registry
Section titled “6.4 第四站:项目工具与 plugin tool 进入同一 registry”registry 扫描配置目录中的 {tool,tools}/*.{js,ts},动态 import,并把合法 export 转为内部 Tool.Def;随后再收集 plugin.list() 中的 p.tool。来源:packages/opencode/src/tool/registry.ts:203-224。
packages/opencode/src/tool/registry.ts
packages/opencode/src/tool/registry.ts:203-224
203 const dirs = yield* config.directories()读取运行配置。204 const matches = dirs.flatMap((dir) =>205 Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),读写本地文件。206 )207 if (matches.length) yield* config.waitForDependencies()读取运行配置。208 for (const match of matches) {遍历集合。209 const namespace = path.basename(match, path.extname(match))210 // `match` is an absolute filesystem path from `Glob.scanSync(..., { absolute: true })`.211 // Import it as `file://` so Node on Windows accepts the dynamic import.212 const mod = yield* Effect.promise(() => import(pathToFileURL(match).href))Effect 异步工作流。213 for (const [id, def] of Object.entries(mod)) {遍历集合。214 if (!isPluginTool(def)) continue调用插件扩展点。215 custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))调用插件扩展点。216 }217 }218219 const plugins = yield* plugin.list()等待 Effect 结果。220 for (const p of plugins) {遍历集合。221 for (const [id, def] of Object.entries(p.tool ?? {})) {遍历集合。222 custom.push(fromPlugin(id, def))调用插件扩展点。223 }224 }
plugin 公共 API 很轻:
1tool({2 description,3 args,4 async execute(args, context) { /* ... */ },工具真正执行入口。5})
来源:packages/plugin/src/tool.ts:45-52
packages/plugin/src/tool.ts
packages/plugin/src/tool.ts:45-52
45export function tool<Args extends z.ZodRawShape>(input: {定义并校验数据形状。46 description: string47 args: Args48 execute(args: z.infer<z.ZodObject<Args>>, context: ToolContext): Promise<ToolResult>工具真正执行入口。49}) {50 return input返回给上一层。51}52tool.schema = z
Plugin ToolContext 还暴露 directory、worktree、abort、metadata 与 Promise 形式的 ask。来源:packages/plugin/src/tool.ts:3-27。
packages/plugin/src/tool.ts
packages/plugin/src/tool.ts:3-27
3export type ToolContext = {定义数据结构约束。4 sessionID: string5 messageID: string6 agent: string7 /**8 * Current project directory for this session.9 * Prefer this over process.cwd() when resolving relative paths.10 */11 directory: string12 /**13 * Project worktree root for this session.14 * Useful for generating stable relative paths (e.g. path.relative(worktree, absPath)).15 */16 worktree: string17 abort: AbortSignal用于中断运行任务。18 metadata(input: { title?: string; metadata?: { [key: string]: any } }): void19 ask(input: AskInput): Promise<void>20}2122type AskInput = {定义数据结构约束。23 permission: string24 patterns: string[]25 always: string[]26 metadata: { [key: string]: any }27}
registry 的 fromPlugin 是兼容盒:把 Zod args 转为 JSON Schema,把 Promise context 与 Effect context 桥接,规范化字符串/对象结果,并为插件输出截断。来源:packages/opencode/src/tool/registry.ts:145-200。
packages/opencode/src/tool/registry.ts
packages/opencode/src/tool/registry.ts:145-200
145 function fromPlugin(id: string, def: ToolDefinition): Tool.Def {调用插件扩展点。146 // Plugin tools still expose Zod args publicly; keep that compatibility147 // boxed at the registry boundary and give the LLM the original JSON Schema.148 // Normalize missing args to `{}` once — pre-1.14.49 the code was149 // `z.object(def.args)` and Zod silently tolerated undefined (#27451, #27630).150 const args = def.args ?? {}151 const entries = Object.entries(args)152 const allZod = entries.every((entry) => isZodType(entry[1]))153 const zodParams = allZod ? z.object(args) : undefined定义并校验数据形状。154 const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries)155 const parameters = zodParams156 ? Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success)定义并校验数据形状。157 : Schema.Unknown定义并校验数据形状。158 return {返回给上一层。159 id,160 parameters,161 jsonSchema,162 description: def.description,163 execute: (args, toolCtx) =>164 Effect.gen(function* () {Effect 异步工作流。165 // Bridge the host's Effect-based `ask` into a Promise-returning166 // function for the plugin to make sure context persists167 const bridge = yield* EffectBridge.make()等待 Effect 结果。168 const pluginCtx: PluginToolContext = {调用插件扩展点。169 ...toolCtx,170 ask: (req) => bridge.promise(toolCtx.ask(req)),171 directory: ctx.directory,172 worktree: ctx.worktree,173 }174 const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))工具真正执行入口。175 const output = typeof result === "string" ? result : result.output176 const metadata = typeof result === "string" ? {} : (result.metadata ?? {})177 const attachments = typeof result === "string" ? undefined : result.attachments178 const info = yield* agent.get(toolCtx.agent)等待 Effect 结果。179 const out = yield* truncate.output(output, {}, info)等待 Effect 结果。180 return {返回给上一层。181 title: typeof result === "string" ? "" : (result.title ?? ""),182 output: out.truncated ? out.content : output,183 attachments,184 metadata: {185 ...metadata,186 truncated: out.truncated,187 ...(out.truncated && { outputPath: out.outputPath }),188 },189 }190 }).pipe(191 Effect.withSpan("Tool.execute", {Effect 异步工作流。192 attributes: {193 "tool.name": id,194 "session.id": toolCtx.sessionID,195 "message.id": toolCtx.messageID,196 ...(toolCtx.callID ? { "tool.call_id": toolCtx.callID } : {}),197 },198 }),199 ),200 }
plugin tool 适配成内部 Tool.Def
packages/opencode/src/tool/registry.ts:145-200
公共插件 API 保持 Promise/Zod 形状,Effect 与截断细节留在宿主边界。
145 function fromPlugin(id: string, def: ToolDefinition): Tool.Def {调用插件扩展点。146 // Plugin tools still expose Zod args publicly; keep that compatibility147 // boxed at the registry boundary and give the LLM the original JSON Schema.148 // Normalize missing args to `{}` once — pre-1.14.49 the code was149 // `z.object(def.args)` and Zod silently tolerated undefined (#27451, #27630).150 const args = def.args ?? {}151 const entries = Object.entries(args)152 const allZod = entries.every((entry) => isZodType(entry[1]))153 const zodParams = allZod ? z.object(args) : undefined定义并校验数据形状。154 const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries)155 const parameters = zodParams156 ? Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success)定义并校验数据形状。157 : Schema.Unknown定义并校验数据形状。158 return {返回给上一层。159 id,160 parameters,161 jsonSchema,162 description: def.description,163 execute: (args, toolCtx) =>164 Effect.gen(function* () {Effect 异步工作流。165 // Bridge the host's Effect-based `ask` into a Promise-returning166 // function for the plugin to make sure context persists167 const bridge = yield* EffectBridge.make()等待 Effect 结果。168 const pluginCtx: PluginToolContext = {调用插件扩展点。169 ...toolCtx,170 ask: (req) => bridge.promise(toolCtx.ask(req)),171 directory: ctx.directory,172 worktree: ctx.worktree,173 }174 const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))工具真正执行入口。175 const output = typeof result === "string" ? result : result.output176 const metadata = typeof result === "string" ? {} : (result.metadata ?? {})177 const attachments = typeof result === "string" ? undefined : result.attachments178 const info = yield* agent.get(toolCtx.agent)等待 Effect 结果。179 const out = yield* truncate.output(output, {}, info)等待 Effect 结果。180 return {返回给上一层。181 title: typeof result === "string" ? "" : (result.title ?? ""),182 output: out.truncated ? out.content : output,183 attachments,184 metadata: {185 ...metadata,186 truncated: out.truncated,187 ...(out.truncated && { outputPath: out.outputPath }),188 },189 }190 }).pipe(191 Effect.withSpan("Tool.execute", {Effect 异步工作流。192 attributes: {193 "tool.name": id,194 "session.id": toolCtx.sessionID,195 "message.id": toolCtx.messageID,196 ...(toolCtx.callID ? { "tool.call_id": toolCtx.callID } : {}),197 },198 }),199 ),200 }
6.5 第五站:registry 按本轮 model 与 flags 过滤
Section titled “6.5 第五站:registry 按本轮 model 与 flags 过滤”ToolRegistry.tools(...) 从所有 builtin/custom 工具中筛选:
- web search 是否可用由 provider 与 runtime flags 决定;
- 部分 GPT 模型使用 patch,其他模型保留 edit/write;
- task/skill description 会按当前 agent 动态补充可用 subagent/skill;
tool.definitionhook 可以修改 description、parameters/jsonSchema。
来源:packages/opencode/src/tool/registry.ts:288-367。
packages/opencode/src/tool/registry.ts
packages/opencode/src/tool/registry.ts:288-367
288 const describeSkill = Effect.fn("ToolRegistry.describeSkill")(function* (agent: Agent.Info) {Effect 异步工作流。289 const list = yield* skill.available(agent)等待 Effect 结果。290 if (list.length === 0) return "No skills are currently available."按条件进入分支。291 return [返回给上一层。292 "Load a specialized skill that provides domain-specific instructions and workflows.",293 "",294 "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",295 "",296 "The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",297 "",298 'Tool output includes a `<skill_content name="...">` block with the loaded content.',299 "",300 "The following skills provide specialized sets of instructions for particular tasks",301 "Invoke this tool to load a skill when a task matches one of the available skills listed below:",302 "",303 Skill.fmt(list, { verbose: false }),304 ].join("\n")305 })306307 const describeTask = Effect.fn("ToolRegistry.describeTask")(function* (agent: Agent.Info) {Effect 异步工作流。308 const items = (yield* agents.list()).filter((item) => item.mode !== "primary")等待 Effect 结果。309 const filtered = items.filter(310 (item) => Permission.evaluate("task", item.name, agent.permission).action !== "deny",311 )312 const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name))313 const description = list314 .map(315 (item) =>316 `- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`,317 )318 .join("\n")319 return ["Available agent types and the tools they have access to:", description].join("\n")返回给上一层。320 })321322 const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {Effect 异步工作流。323 const filtered = (yield* all()).filter((tool) => {等待 Effect 结果。324 if (tool.id === WebSearchTool.id) {按条件进入分支。325 return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel })选择模型或 provider。326 }327328 const usePatch =329 input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4")选择模型或 provider。330 if (tool.id === ApplyPatchTool.id) return usePatch按条件进入分支。331 if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch按条件进入分支。332333 return true返回给上一层。334 })335336 return yield* Effect.forEach(Effect 异步工作流。337 filtered,338 Effect.fnUntraced(function* (tool: Tool.Def) {Effect 异步工作流。339 using _ = log.time(tool.id)340 const output = {341 description: tool.description,342 parameters: tool.parameters,343 jsonSchema: tool.jsonSchema,344 }345 yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)调用插件扩展点。346 const jsonSchema =347 output.parameters === tool.parameters || output.jsonSchema !== tool.jsonSchema348 ? output.jsonSchema349 : undefined350 return {返回给上一层。351 id: tool.id,352 description: [353 output.description,354 tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,等待 Effect 结果。355 tool.id === SkillTool.id ? yield* describeSkill(input.agent) : undefined,等待 Effect 结果。356 ]357 .filter(Boolean)358 .join("\n"),359 parameters: output.parameters,360 jsonSchema,361 execute: tool.execute,362 formatValidationError: tool.formatValidationError,363 }364 }),365 { concurrency: "unbounded" },366 )367 })
本轮 registry 工具筛选与描述扩展
packages/opencode/src/tool/registry.ts:322-367
registry 管可见能力;它还没有绑定 session 或真正执行。
322 const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {Effect 异步工作流。323 const filtered = (yield* all()).filter((tool) => {等待 Effect 结果。324 if (tool.id === WebSearchTool.id) {按条件进入分支。325 return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel })选择模型或 provider。326 }327328 const usePatch =329 input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4")选择模型或 provider。330 if (tool.id === ApplyPatchTool.id) return usePatch按条件进入分支。331 if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch按条件进入分支。332333 return true返回给上一层。334 })335336 return yield* Effect.forEach(Effect 异步工作流。337 filtered,338 Effect.fnUntraced(function* (tool: Tool.Def) {Effect 异步工作流。339 using _ = log.time(tool.id)340 const output = {341 description: tool.description,342 parameters: tool.parameters,343 jsonSchema: tool.jsonSchema,344 }345 yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)调用插件扩展点。346 const jsonSchema =347 output.parameters === tool.parameters || output.jsonSchema !== tool.jsonSchema348 ? output.jsonSchema349 : undefined350 return {返回给上一层。351 id: tool.id,352 description: [353 output.description,354 tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,等待 Effect 结果。355 tool.id === SkillTool.id ? yield* describeSkill(input.agent) : undefined,等待 Effect 结果。356 ]357 .filter(Boolean)358 .join("\n"),359 parameters: output.parameters,360 jsonSchema,361 execute: tool.execute,362 formatValidationError: tool.formatValidationError,363 }364 }),365 { concurrency: "unbounded" },366 )367 })
这意味着 registry 不是静态 Map<String, Tool>,而是一个按 model、agent 与运行标志求值的能力目录。
6.6 第六站:SessionTools.resolve 绑定本次调用环境
Section titled “6.6 第六站:SessionTools.resolve 绑定本次调用环境”Agent loop 把 agent、model、session、processor、messages 与 promptOps 交给 SessionTools.resolve。它为每个工具构造本轮 Tool.Context:
1const context = (args, options): Tool.Context => ({2 sessionID: input.session.id,3 messageID: input.processor.message.id,4 callID: options.toolCallId,5 abort: options.abortSignal!,6 agent: input.agent.name,7 messages: input.messages,8 metadata: /* 更新当前 tool call 状态 */,9 ask: /* 合并权限后询问 */,10})
节选自 packages/opencode/src/session/tools.ts:42-73。
packages/opencode/src/session/tools.ts
packages/opencode/src/session/tools.ts:42-73
42 const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({43 sessionID: input.session.id,44 abort: options.abortSignal!,45 messageID: input.processor.message.id,46 callID: options.toolCallId,47 extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps },选择模型或 provider。48 agent: input.agent.name,49 messages: input.messages,50 metadata: (val) =>51 input.processor.updateToolCall(options.toolCallId, (match) => {52 if (!["running", "pending"].includes(match.state.status)) return match按条件进入分支。53 return {返回给上一层。54 ...match,55 state: {56 title: val.title,57 metadata: val.metadata,58 status: "running",59 input: args,60 time: { start: Date.now() },61 },62 }63 }),64 ask: (req) =>65 permission66 .ask({67 ...req,68 sessionID: input.session.id,69 tool: { messageID: input.processor.message.id, callID: options.toolCallId },70 ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),71 })72 .pipe(Effect.orDie),Effect 异步工作流。73 })
metadata(...) 调 processor 的 updateToolCall,把 pending/running part 更新为 running,并记录 title、metadata、input 与开始时间。ask(...) 合并 agent permission 与 session permission,再交给 Permission service。来源同上。
每次 tool call 的 session 与权限上下文
packages/opencode/src/session/tools.ts:42-73
同一个 Tool.Def 因调用轮次不同而获得不同 messageID、callID 与 ruleset。
42 const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({43 sessionID: input.session.id,44 abort: options.abortSignal!,45 messageID: input.processor.message.id,46 callID: options.toolCallId,47 extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps },选择模型或 provider。48 agent: input.agent.name,49 messages: input.messages,50 metadata: (val) =>51 input.processor.updateToolCall(options.toolCallId, (match) => {52 if (!["running", "pending"].includes(match.state.status)) return match按条件进入分支。53 return {返回给上一层。54 ...match,55 state: {56 title: val.title,57 metadata: val.metadata,58 status: "running",59 input: args,60 time: { start: Date.now() },61 },62 }63 }),64 ask: (req) =>65 permission66 .ask({67 ...req,68 sessionID: input.session.id,69 tool: { messageID: input.processor.message.id, callID: options.toolCallId },70 ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),71 })72 .pipe(Effect.orDie),Effect 异步工作流。73 })
6.7 第七站:Schema 先适配 provider,再交给模型
Section titled “6.7 第七站:Schema 先适配 provider,再交给模型”对 registry 中每个工具,代码先取得 JSON Schema,并调用 ProviderTransform.schema(input.model, ...),再包装为 AI SDK tool:
1const schema = ProviderTransform.schema(选择模型或 provider。2 input.model,3 ToolJsonSchema.fromTool(item),定义并校验数据形状。4)56tools[item.id] = tool({7 description: item.description,8 inputSchema: jsonSchema(schema),9 execute(args, options) { /* ... */ },工具真正执行入口。10})
来源:packages/opencode/src/session/tools.ts:75-115
packages/opencode/src/session/tools.ts
packages/opencode/src/session/tools.ts:75-115
75 for (const item of yield* registry.tools({等待 Effect 结果。76 modelID: ModelID.make(input.model.api.id),选择模型或 provider。77 providerID: input.model.providerID,选择模型或 provider。78 agent: input.agent,79 })) {80 const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))定义并校验数据形状。81 tools[item.id] = tool({82 description: item.description,83 inputSchema: jsonSchema(schema),84 execute(args, options) {工具真正执行入口。85 return run.promise(返回给上一层。86 Effect.gen(function* () {Effect 异步工作流。87 const ctx = context(args, options)88 yield* plugin.trigger(调用插件扩展点。89 "tool.execute.before",90 { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },91 { args },92 )93 const result = yield* item.execute(args, ctx)工具真正执行入口。94 const output = {95 ...result,96 attachments: result.attachments?.map((attachment) => ({97 ...attachment,98 id: PartID.ascending(),99 sessionID: ctx.sessionID,100 messageID: input.processor.message.id,101 })),102 }103 yield* plugin.trigger(调用插件扩展点。104 "tool.execute.after",105 { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },106 output,107 )108 if (options.abortSignal?.aborted) {按条件进入分支。109 yield* input.processor.completeToolCall(options.toolCallId, output)等待 Effect 结果。110 }111 return output返回给上一层。112 }),113 )114 },115 })
为什么内部 parameters 已经能校验,还要转换 JSON Schema?两者面向不同边界:Effect Schema 保护本地执行,JSON Schema 告诉当前 provider/model 应怎样生成参数;某些 provider 对 JSON Schema 子集还有额外限制。
6.8 第八站:模型调用 read 后,执行仍经过 hook 与 wrapper
Section titled “6.8 第八站:模型调用 read 后,执行仍经过 hook 与 wrapper”AI SDK 调用该 execute 时,OpenCode:
- 用 args/options 创建本轮 context;
- 触发
tool.execute.before,允许插件修改 args; - 调用
item.execute(args, ctx); - 为返回附件补
PartID、sessionID 与 assistant messageID; - 触发
tool.execute.after; - 把 output 返回给 LLM runtime。
来源:packages/opencode/src/session/tools.ts:84-115。
packages/opencode/src/session/tools.ts
packages/opencode/src/session/tools.ts:84-115
84 execute(args, options) {工具真正执行入口。85 return run.promise(返回给上一层。86 Effect.gen(function* () {Effect 异步工作流。87 const ctx = context(args, options)88 yield* plugin.trigger(调用插件扩展点。89 "tool.execute.before",90 { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },91 { args },92 )93 const result = yield* item.execute(args, ctx)工具真正执行入口。94 const output = {95 ...result,96 attachments: result.attachments?.map((attachment) => ({97 ...attachment,98 id: PartID.ascending(),99 sessionID: ctx.sessionID,100 messageID: input.processor.message.id,101 })),102 }103 yield* plugin.trigger(调用插件扩展点。104 "tool.execute.after",105 { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },106 output,107 )108 if (options.abortSignal?.aborted) {按条件进入分支。109 yield* input.processor.completeToolCall(options.toolCallId, output)等待 Effect 结果。110 }111 return output返回给上一层。112 }),113 )114 },115 })
item.execute 对内置工具已经被 Tool.wrap 包装,所以真实顺序可以重新组装为:
provider 生成 args -> before hook -> Schema decode -> 具体 read.execute -> output truncate -> 附件补归属 ID -> after hook -> 返回 runtime
内部工具到 AI SDK execute 的桥
packages/opencode/src/session/tools.ts:75-115
这一段把 registry 能力变成模型真的可以调用的函数。
75 for (const item of yield* registry.tools({等待 Effect 结果。76 modelID: ModelID.make(input.model.api.id),选择模型或 provider。77 providerID: input.model.providerID,选择模型或 provider。78 agent: input.agent,79 })) {80 const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))定义并校验数据形状。81 tools[item.id] = tool({82 description: item.description,83 inputSchema: jsonSchema(schema),84 execute(args, options) {工具真正执行入口。85 return run.promise(返回给上一层。86 Effect.gen(function* () {Effect 异步工作流。87 const ctx = context(args, options)88 yield* plugin.trigger(调用插件扩展点。89 "tool.execute.before",90 { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },91 { args },92 )93 const result = yield* item.execute(args, ctx)工具真正执行入口。94 const output = {95 ...result,96 attachments: result.attachments?.map((attachment) => ({97 ...attachment,98 id: PartID.ascending(),99 sessionID: ctx.sessionID,100 messageID: input.processor.message.id,101 })),102 }103 yield* plugin.trigger(调用插件扩展点。104 "tool.execute.after",105 { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },106 output,107 )108 if (options.abortSignal?.aborted) {按条件进入分支。109 yield* input.processor.completeToolCall(options.toolCallId, output)等待 Effect 结果。110 }111 return output返回给上一层。112 }),113 )114 },115 })
本章证据到“返回 runtime”为止。正常结果怎样经 LLM event 被 processor 持久化成 completed ToolPart,是上一章和 Agent 核心循环的职责;不要误称 SessionTools.resolve 自己完成了所有落库。
6.9 第九站:MCP 工具并行汇入同一个表
Section titled “6.9 第九站:MCP 工具并行汇入同一个表”SessionTools.resolve 还遍历 mcp.tools():
- 把 MCP input schema 转为 JSON Schema,并做 provider transform;
- 执行前统一
ctx.ask({ permission: key, patterns: ["*"] ... }); - 触发相同 before/after hooks;
- 把 MCP text/resource/image content 归一为 output 与 attachments;
- 用相同 truncate service 处理文本输出。
来源:packages/opencode/src/session/tools.ts:118-203。
packages/opencode/src/session/tools.ts
packages/opencode/src/session/tools.ts:118-203
118 for (const [key, item] of Object.entries(yield* mcp.tools())) {等待 Effect 结果。119 const execute = item.execute120 if (!execute) continue按条件进入分支。121122 const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))Effect 异步工作流。123 const transformed = ProviderTransform.schema(input.model, schema)选择模型或 provider。124 item.inputSchema = jsonSchema(transformed)125 item.execute = (args, opts) =>126 run.promise(127 Effect.gen(function* () {Effect 异步工作流。128 const ctx = context(args, opts)129 yield* plugin.trigger(调用插件扩展点。130 "tool.execute.before",131 { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },132 { args },133 )134 const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () {Effect 异步工作流。135 yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })进入权限审批。136 return yield* Effect.promise(() => execute(args, opts))工具真正执行入口。137 }).pipe(138 Effect.withSpan("Tool.execute", {Effect 异步工作流。139 attributes: {140 "tool.name": key,141 "tool.call_id": opts.toolCallId,142 "session.id": ctx.sessionID,143 "message.id": input.processor.message.id,144 },145 }),146 )147 yield* plugin.trigger(调用插件扩展点。148 "tool.execute.after",149 { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args },150 result,151 )152153 const textParts: string[] = []154 const attachments: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[] = []会话消息片段结构。155 for (const contentItem of result.content) {遍历集合。156 if (contentItem.type === "text") textParts.push(contentItem.text)按条件进入分支。157 else if (contentItem.type === "image") {158 attachments.push({159 type: "file",160 mime: contentItem.mimeType,161 url: `data:${contentItem.mimeType};base64,${contentItem.data}`,162 })163 } else if (contentItem.type === "resource") {164 const { resource } = contentItem165 if (resource.text) textParts.push(resource.text)按条件进入分支。166 if (resource.blob) {按条件进入分支。167 attachments.push({168 type: "file",169 mime: resource.mimeType ?? "application/octet-stream",170 url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,171 filename: resource.uri,172 })173 }174 }175 }176177 const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent)等待 Effect 结果。178 const metadata = {179 ...result.metadata,180 truncated: truncated.truncated,181 ...(truncated.truncated && { outputPath: truncated.outputPath }),182 }183184 const output = {185 title: "",186 metadata,187 output: truncated.content,188 attachments: attachments.map((attachment) => ({189 ...attachment,190 id: PartID.ascending(),191 sessionID: ctx.sessionID,192 messageID: input.processor.message.id,193 })),194 content: result.content,195 }196 if (opts.abortSignal?.aborted) {按条件进入分支。197 yield* input.processor.completeToolCall(opts.toolCallId, output)等待 Effect 结果。198 }199 return output返回给上一层。200 }),201 )202 tools[key] = item203 }
MCP 工具没有先变成 registry Tool.Def,但在本轮最终都进入同一个 Record<string, AITool>。这是一种“汇合在 runtime adapter”而非“强迫所有来源共享定义类型”的设计。
7. 五个决定安全与可维护性的分支
Section titled “7. 五个决定安全与可维护性的分支”7.1 模型选择不等于权限允许
Section titled “7.1 模型选择不等于权限允许”模型只能看到暴露的工具并产生调用;具体工具在需要受保护动作时调用 ctx.ask(...)。ruleset 来自 agent 与 session permission 合并。来源:packages/opencode/src/session/tools.ts:64-72。
packages/opencode/src/session/tools.ts
packages/opencode/src/session/tools.ts:64-72
64 ask: (req) =>65 permission66 .ask({67 ...req,68 sessionID: input.session.id,69 tool: { messageID: input.processor.message.id, callID: options.toolCallId },70 ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),71 })72 .pipe(Effect.orDie),Effect 异步工作流。
可见性和授权是两道门,不能用“没暴露某工具”替代所有执行时检查。
7.2 TypeScript 类型不等于运行时可信
Section titled “7.2 TypeScript 类型不等于运行时可信”模型输出来自进程外,编译期泛型无法保证 JSON 合法。Schema.decodeUnknownEffect 在工具 wrapper 中做真正的运行时解码。来源:packages/opencode/src/tool/tool.ts:87-111。
packages/opencode/src/tool/tool.ts
packages/opencode/src/tool/tool.ts:87-111
87 const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init }等待 Effect 结果。88 // Compile the parser closure once per tool init; `decodeUnknownEffect`89 // allocates a new closure per call, so hoisting avoids re-closing it for90 // every LLM tool invocation.91 const decode = Schema.decodeUnknownEffect(toolInfo.parameters)定义并校验数据形状。92 const execute = toolInfo.execute93 toolInfo.execute = (args, ctx) => {94 const attrs = {95 "tool.name": id,96 "session.id": ctx.sessionID,97 "message.id": ctx.messageID,98 ...(ctx.callID ? { "tool.call_id": ctx.callID } : {}),99 }100 return Effect.gen(function* () {Effect 异步工作流。101 const decoded = yield* decode(args).pipe(等待 Effect 结果。102 Effect.mapError((error) =>Effect 异步工作流。103 toolInfo.formatValidationError104 ? new Error(toolInfo.formatValidationError(error), { cause: error })105 : new Error(106 `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,107 { cause: error },108 ),109 ),110 )111 const result = yield* execute(decoded as Schema.Schema.Type<Parameters>, ctx)工具真正执行入口。
7.3 输出过长也属于工具边界
Section titled “7.3 输出过长也属于工具边界”内置工具在 Tool.wrap 统一截断;plugin adapter 和 MCP adapter 也分别调用 Truncate。截断 metadata 记录 truncated 与可选 outputPath。来源:packages/opencode/src/tool/tool.ts:111-125、packages/opencode/src/tool/registry.ts:174-188、packages/opencode/src/session/tools.ts:177-193。
packages/opencode/src/tool/tool.ts
packages/opencode/src/tool/tool.ts:111-125
111 const result = yield* execute(decoded as Schema.Schema.Type<Parameters>, ctx)工具真正执行入口。112 if (result.metadata.truncated !== undefined) {按条件进入分支。113 return result返回给上一层。114 }115 const agent = yield* agents.get(ctx.agent)等待 Effect 结果。116 const truncated = yield* truncate.output(result.output, {}, agent)等待 Effect 结果。117 return {返回给上一层。118 ...result,119 output: truncated.content,120 metadata: {121 ...result.metadata,122 truncated: truncated.truncated,123 ...(truncated.truncated && { outputPath: truncated.outputPath }),124 },125 }
packages/opencode/src/tool/registry.ts
packages/opencode/src/tool/registry.ts:174-188
174 const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))工具真正执行入口。175 const output = typeof result === "string" ? result : result.output176 const metadata = typeof result === "string" ? {} : (result.metadata ?? {})177 const attachments = typeof result === "string" ? undefined : result.attachments178 const info = yield* agent.get(toolCtx.agent)等待 Effect 结果。179 const out = yield* truncate.output(output, {}, info)等待 Effect 结果。180 return {返回给上一层。181 title: typeof result === "string" ? "" : (result.title ?? ""),182 output: out.truncated ? out.content : output,183 attachments,184 metadata: {185 ...metadata,186 truncated: out.truncated,187 ...(out.truncated && { outputPath: out.outputPath }),188 },
packages/opencode/src/session/tools.ts
packages/opencode/src/session/tools.ts:177-193
177 const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent)等待 Effect 结果。178 const metadata = {179 ...result.metadata,180 truncated: truncated.truncated,181 ...(truncated.truncated && { outputPath: truncated.outputPath }),182 }183184 const output = {185 title: "",186 metadata,187 output: truncated.content,188 attachments: attachments.map((attachment) => ({189 ...attachment,190 id: PartID.ascending(),191 sessionID: ctx.sessionID,192 messageID: input.processor.message.id,193 })),
这样做保护模型上下文,但调用者必须知道展示内容可能不是完整结果。
7.4 取消后仍需要尽量闭合工具状态
Section titled “7.4 取消后仍需要尽量闭合工具状态”context 把 AI SDK abortSignal 传给具体工具。若返回时 signal 已 aborted,adapter 会调用 processor 的 completeToolCall 作为收口路径。来源:packages/opencode/src/session/tools.ts:42-49、packages/opencode/src/session/tools.ts:108-110。
packages/opencode/src/session/tools.ts
packages/opencode/src/session/tools.ts:42-49
42 const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({43 sessionID: input.session.id,44 abort: options.abortSignal!,45 messageID: input.processor.message.id,46 callID: options.toolCallId,47 extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps },选择模型或 provider。48 agent: input.agent.name,49 messages: input.messages,
packages/opencode/src/session/tools.ts
packages/opencode/src/session/tools.ts:108-110
108 if (options.abortSignal?.aborted) {按条件进入分支。109 yield* input.processor.completeToolCall(options.toolCallId, output)等待 Effect 结果。110 }
源码能证明这条 abort 分支;普通成功的最终状态仍由 LLM event/processor 主链处理。
7.5 Tool ID 是协议的一部分
Section titled “7.5 Tool ID 是协议的一部分”registry 用 id 作为 tools 对象 key,插件 default export 又会从文件名派生 namespace。来源:packages/opencode/src/tool/registry.ts:203-216、packages/opencode/src/session/tools.ts:75-81。改名不仅是重构函数名,也可能改变模型提示、权限规则与历史 tool call 的协议标识。
packages/opencode/src/tool/registry.ts
packages/opencode/src/tool/registry.ts:203-216
203 const dirs = yield* config.directories()读取运行配置。204 const matches = dirs.flatMap((dir) =>205 Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),读写本地文件。206 )207 if (matches.length) yield* config.waitForDependencies()读取运行配置。208 for (const match of matches) {遍历集合。209 const namespace = path.basename(match, path.extname(match))210 // `match` is an absolute filesystem path from `Glob.scanSync(..., { absolute: true })`.211 // Import it as `file://` so Node on Windows accepts the dynamic import.212 const mod = yield* Effect.promise(() => import(pathToFileURL(match).href))Effect 异步工作流。213 for (const [id, def] of Object.entries(mod)) {遍历集合。214 if (!isPluginTool(def)) continue调用插件扩展点。215 custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))调用插件扩展点。216 }
packages/opencode/src/session/tools.ts
packages/opencode/src/session/tools.ts:75-81
75 for (const item of yield* registry.tools({等待 Effect 结果。76 modelID: ModelID.make(input.model.api.id),选择模型或 provider。77 providerID: input.model.providerID,选择模型或 provider。78 agent: input.agent,79 })) {80 const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))定义并校验数据形状。81 tools[item.id] = tool({
8. OpenCode 的选择:替代方案与代价
Section titled “8. OpenCode 的选择:替代方案与代价”| 设计问题 | OpenCode 的选择 | 可选做法 | 收益与代价 |
|---|---|---|---|
| 工具怎样组织 | definition -> registry -> per-session adapter | loop 内 switch(toolName) | 易扩展、职责清晰;调用链更长 |
| 参数怎样保护 | 编译期类型 + 运行时 Schema decode | 只信 TypeScript 类型 | 外部 JSON 更安全;schema 要维护 |
| 权限放哪里 | Context 中按调用询问 | 注册时一次性决定 | 能结合具体路径/命令;每个工具必须正确调用 ask |
| provider schema 差异放哪里 | 暴露本轮工具时 transform | 每个工具写 provider 分支 | 工具实现干净;adapter 更复杂 |
| 多种工具来源怎样汇合 | registry 与 MCP 在 SessionTools 汇合 | 强制所有来源使用同一 SDK | 保留各自协议;存在两套适配逻辑 |
表中选择由源码直接证明;收益与代价是设计解释。
9. TypeScript / Effect:只补四个阅读障碍
Section titled “9. TypeScript / Effect:只补四个阅读障碍”9.1 泛型 Schema 与参数类型
Section titled “9.1 泛型 Schema 与参数类型”Def<Parameters extends Schema.Decoder<unknown>> 让参数类型从 Schema 推导;Schema 同时提供运行时 decode。这比 Java 泛型多了一层运行时合同。
9.2 Object.assign(effect, { id })
Section titled “9.2 Object.assign(effect, { id })”Tool.define 返回一个 Effect,同时给它挂上静态 id 属性。这样注册代码可在初始化前引用工具 ID。它是函数/对象可组合的 TypeScript 写法,Java 通常会用显式类表示。
9.3 Record<string, AITool>
Section titled “9.3 Record<string, AITool>”它是普通对象形式的名字到工具映射。模型看到的是 key、description 和 schema;真正执行函数留在 runtime 本地。
9.4 Effect 与 Promise bridge
Section titled “9.4 Effect 与 Promise bridge”OpenCode 内部工具返回 Effect,AI SDK/plugin API 使用 Promise。EffectBridge.make() 在调用边界把 Effect 安全地运行成 Promise,同时保留当前服务上下文。来源:packages/opencode/src/session/tools.ts:34-35、packages/opencode/src/tool/registry.ts:163-174。
packages/opencode/src/session/tools.ts
packages/opencode/src/session/tools.ts:34-35
34 const tools: Record<string, AITool> = {}35 const run = yield* EffectBridge.make()等待 Effect 结果。
packages/opencode/src/tool/registry.ts
packages/opencode/src/tool/registry.ts:163-174
163 execute: (args, toolCtx) =>164 Effect.gen(function* () {Effect 异步工作流。165 // Bridge the host's Effect-based `ask` into a Promise-returning166 // function for the plugin to make sure context persists167 const bridge = yield* EffectBridge.make()等待 Effect 结果。168 const pluginCtx: PluginToolContext = {调用插件扩展点。169 ...toolCtx,170 ask: (req) => bridge.promise(toolCtx.ask(req)),171 directory: ctx.directory,172 worktree: ctx.worktree,173 }174 const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))工具真正执行入口。
10. 可以带走的方法
Section titled “10. 可以带走的方法”方法一:把工具拆成“定义、发现、绑定、执行”
Section titled “方法一:把工具拆成“定义、发现、绑定、执行””定义只描述能力;registry 负责发现;每轮 adapter 绑定 session/permission;执行处理具体动作。
验证问题:同一个 read tool 能否被两个 session 同时调用而不串 messageID?
方法二:在最靠近副作用的位置授权
Section titled “方法二:在最靠近副作用的位置授权”工具可见性先缩小攻击面,ctx.ask 再根据具体参数做执行时授权。
验证问题:同一个 shell 工具能否允许安全命令、询问敏感命令,而不是只能全开或全关?
方法三:让失败与截断成为显式协议
Section titled “方法三:让失败与截断成为显式协议”参数错误要返回可理解信息,超长输出要标记 truncated,附件要补全归属 ID。
验证问题:模型和 UI 能否区分“完整输出”“被截断输出”和“工具失败”?
11. 费曼复述与练习阶梯
Section titled “11. 费曼复述与练习阶梯”11.1 60 秒复述
Section titled “11.1 60 秒复述”不要看上文,补全:
Tool.Def描述 ______;ToolRegistry决定 ______;SessionTools.resolve绑定 ______。模型给出的 args 先经过 ______,受保护动作通过 ______ 申请权限。结果过长时由 ______ 处理,最后回到 ______,而不是工具层自己决定 agent 是否继续。
如果你把 ToolRegistry 与 SessionTools 说成同一个“工具列表”,请回看 packages/opencode/src/tool/registry.ts:322-367 和 packages/opencode/src/session/tools.ts:24-116。
packages/opencode/src/tool/registry.ts
packages/opencode/src/tool/registry.ts:322-367
322 const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {Effect 异步工作流。323 const filtered = (yield* all()).filter((tool) => {等待 Effect 结果。324 if (tool.id === WebSearchTool.id) {按条件进入分支。325 return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel })选择模型或 provider。326 }327328 const usePatch =329 input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4")选择模型或 provider。330 if (tool.id === ApplyPatchTool.id) return usePatch按条件进入分支。331 if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch按条件进入分支。332333 return true返回给上一层。334 })335336 return yield* Effect.forEach(Effect 异步工作流。337 filtered,338 Effect.fnUntraced(function* (tool: Tool.Def) {Effect 异步工作流。339 using _ = log.time(tool.id)340 const output = {341 description: tool.description,342 parameters: tool.parameters,343 jsonSchema: tool.jsonSchema,344 }345 yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)调用插件扩展点。346 const jsonSchema =347 output.parameters === tool.parameters || output.jsonSchema !== tool.jsonSchema348 ? output.jsonSchema349 : undefined350 return {返回给上一层。351 id: tool.id,352 description: [353 output.description,354 tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,等待 Effect 结果。355 tool.id === SkillTool.id ? yield* describeSkill(input.agent) : undefined,等待 Effect 结果。356 ]357 .filter(Boolean)358 .join("\n"),359 parameters: output.parameters,360 jsonSchema,361 execute: tool.execute,362 formatValidationError: tool.formatValidationError,363 }364 }),365 { concurrency: "unbounded" },366 )367 })
packages/opencode/src/session/tools.ts
packages/opencode/src/session/tools.ts:24-116
24export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {Effect 异步工作流。25 agent: Agent.Info26 model: Provider.Model选择模型或 provider。27 session: Session.Info28 processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">处理模型流事件。29 bypassAgentCheck: boolean30 messages: MessageV2.WithParts[]会话消息片段结构。31 promptOps: TaskPromptOps32}) {33 using _ = log.time("resolveTools")34 const tools: Record<string, AITool> = {}35 const run = yield* EffectBridge.make()等待 Effect 结果。36 const plugin = yield* Plugin.Service调用插件扩展点。37 const permission = yield* Permission.Service等待 Effect 结果。38 const registry = yield* ToolRegistry.Service等待 Effect 结果。39 const mcp = yield* MCP.Service等待 Effect 结果。40 const truncate = yield* Truncate.Service等待 Effect 结果。4142 const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({43 sessionID: input.session.id,44 abort: options.abortSignal!,45 messageID: input.processor.message.id,46 callID: options.toolCallId,47 extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps },选择模型或 provider。48 agent: input.agent.name,49 messages: input.messages,50 metadata: (val) =>51 input.processor.updateToolCall(options.toolCallId, (match) => {52 if (!["running", "pending"].includes(match.state.status)) return match按条件进入分支。53 return {返回给上一层。54 ...match,55 state: {56 title: val.title,57 metadata: val.metadata,58 status: "running",59 input: args,60 time: { start: Date.now() },61 },62 }63 }),64 ask: (req) =>65 permission66 .ask({67 ...req,68 sessionID: input.session.id,69 tool: { messageID: input.processor.message.id, callID: options.toolCallId },70 ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),71 })72 .pipe(Effect.orDie),Effect 异步工作流。73 })7475 for (const item of yield* registry.tools({等待 Effect 结果。76 modelID: ModelID.make(input.model.api.id),选择模型或 provider。77 providerID: input.model.providerID,选择模型或 provider。78 agent: input.agent,79 })) {80 const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))定义并校验数据形状。81 tools[item.id] = tool({82 description: item.description,83 inputSchema: jsonSchema(schema),84 execute(args, options) {工具真正执行入口。85 return run.promise(返回给上一层。86 Effect.gen(function* () {Effect 异步工作流。87 const ctx = context(args, options)88 yield* plugin.trigger(调用插件扩展点。89 "tool.execute.before",90 { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },91 { args },92 )93 const result = yield* item.execute(args, ctx)工具真正执行入口。94 const output = {95 ...result,96 attachments: result.attachments?.map((attachment) => ({97 ...attachment,98 id: PartID.ascending(),99 sessionID: ctx.sessionID,100 messageID: input.processor.message.id,101 })),102 }103 yield* plugin.trigger(调用插件扩展点。104 "tool.execute.after",105 { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },106 output,107 )108 if (options.abortSignal?.aborted) {按条件进入分支。109 yield* input.processor.completeToolCall(options.toolCallId, output)等待 Effect 结果。110 }111 return output返回给上一层。112 }),113 )114 },115 })116 }
11.2 读源码练习
Section titled “11.2 读源码练习”- 入门:列出
Tool.Context与ExecuteResult的字段。 - 进阶:从 registry 的
read初始化追到 AI SDK tools 对象中的readkey。 - 辨析:解释内部 parameters、jsonSchema 与 provider-transformed inputSchema 的区别。
- 失败路径:画出非法参数、权限拒绝、执行异常、输出截断四条分支。
11.3 小实现
Section titled “11.3 小实现”实现一个 mini tool runtime:支持两个内置工具和一个 plugin tool;所有 raw args 必须运行时校验;每次调用绑定 sessionID/callID;副作用前询问 permission;输出超过上限时保存完整内容并返回摘要与路径。
12. 最后复盘:工具已经接通,接下来进入具体副作用
Section titled “12. 最后复盘:工具已经接通,接下来进入具体副作用”本章主链是:
Tool.define -> ToolRegistry 发现并筛选 -> SessionTools.resolve 绑定本轮上下文 -> provider schema + AI SDK execute -> validate / permission / hook / execute / truncate -> result 返回 LLM runtime你现在应该能回答中心问题:模型只提出工具调用;OpenCode runtime 才拥有定义、授权和执行能力,并把每次动作绑定到明确的 session/message/call。
下一章要把抽象能力落到最典型的副作用:read、edit、write 怎样处理路径、权限、diff、格式化与诊断?这就是“文件读写与代码修改”。