import { PlanExitTool } from "./plan" import { Session } from "@/session/session" import { QuestionTool } from "./question" import { ShellTool } from "./shell" import { EditTool } from "./edit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" import { ReadTool } from "./read" import { TaskTool } from "./task" import { Database } from "@opencode-ai/core/database/database" import { TodoWriteTool } from "./todo" import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" import { SkillTool } from "./skill" import * as Tool from "./tool" import { Config } from "@/config/config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" import type { JSONSchema7, JSONSchema7Definition } from "@ai-sdk/provider" import { Schema } from "effect" import z from "zod" import { Plugin } from "../plugin" import { Provider } from "@/provider/provider" import { WebSearchTool } from "./websearch" import * as Log from "@opencode-ai/core/util/log" import { LspTool } from "./lsp" import * as Truncate from "./truncate" import { ApplyPatchTool } from "./apply_patch" import { Glob } from "@opencode-ai/core/util/glob" import path from "path" import { pathToFileURL } from "url" import { Effect, Layer, Context } from "effect" import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Search } from "@opencode-ai/core/filesystem/search" import { Format } from "../format" import { InstanceState } from "@/effect/instance-state" import { EffectBridge } from "@/effect/bridge" import { Question } from "../question" import { Todo } from "../session/todo" import { LSP } from "@/lsp/lsp" import { Instruction } from "../session/instruction" import { FSUtil } from "@opencode-ai/core/fs-util" import { EventV2Bridge } from "@/event-v2-bridge" import { Agent } from "../agent/agent" import { Skill } from "../skill" import { Permission } from "@/permission" import { Reference } from "@/reference/reference" import { BackgroundJob } from "@/background/job" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" const log = Log.create({ service: "tool.registry" }) export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) { return providerID === ProviderV2.ID.opencode || flags.exa || flags.parallel } type TaskDef = Tool.InferDef type ReadDef = Tool.InferDef type State = { custom: Tool.Def[] builtin: Tool.Def[] task: TaskDef read: ReadDef } export interface Interface { readonly ids: () => Effect.Effect readonly all: () => Effect.Effect readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }> readonly tools: (model: { providerID: ProviderV2.ID modelID: ModelV2.ID agent: Agent.Info }) => Effect.Effect } export class Service extends Context.Service()("@opencode/ToolRegistry") {} export const layer: Layer.Layer< Service, never, | Config.Service | Plugin.Service | Question.Service | Todo.Service | Agent.Service | Skill.Service | Session.Service | BackgroundJob.Service | Provider.Service | Reference.Service | LSP.Service | Instruction.Service | FSUtil.Service | EventV2Bridge.Service | HttpClient.HttpClient | ChildProcessSpawner | Search.Service | Format.Service | Truncate.Service | RuntimeFlags.Service | Database.Service > = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service const plugin = yield* Plugin.Service const agents = yield* Agent.Service const truncate = yield* Truncate.Service const flags = yield* RuntimeFlags.Service const invalid = yield* InvalidTool const task = yield* TaskTool const read = yield* ReadTool const question = yield* QuestionTool const todo = yield* TodoWriteTool const lsptool = yield* LspTool const plan = yield* PlanExitTool const webfetch = yield* WebFetchTool const websearch = yield* WebSearchTool const shell = yield* ShellTool const globtool = yield* GlobTool const writetool = yield* WriteTool const edit = yield* EditTool const greptool = yield* GrepTool const patchtool = yield* ApplyPatchTool const skilltool = yield* SkillTool const agent = yield* Agent.Service const state = yield* InstanceState.make( Effect.fn("ToolRegistry.state")(function* (ctx) { const custom: Tool.Def[] = [] function fromPlugin(id: string, def: ToolDefinition): Tool.Def { // Plugin tools still expose Zod args publicly; keep that compatibility // boxed at the registry boundary and give the LLM the original JSON Schema. // Normalize missing args to `{}` once — pre-1.14.49 the code was // `z.object(def.args)` and Zod silently tolerated undefined (#27451, #27630). const args = def.args ?? {} const entries = Object.entries(args) const allZod = entries.every((entry) => isZodType(entry[1])) const zodParams = allZod ? z.object(args) : undefined const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries) const parameters = zodParams ? Schema.declare((u): u is unknown => zodParams.safeParse(u).success) : Schema.Unknown return { id, parameters, jsonSchema, description: def.description, execute: (args, toolCtx) => Effect.gen(function* () { // Bridge the host's Effect-based `ask` into a Promise-returning // function for the plugin to make sure context persists const bridge = yield* EffectBridge.make() const pluginCtx: PluginToolContext = { ...toolCtx, ask: (req) => bridge.promise(toolCtx.ask(req)), directory: ctx.directory, worktree: ctx.worktree, } const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx)) const output = typeof result === "string" ? result : result.output const metadata = typeof result === "string" ? {} : (result.metadata ?? {}) const attachments = typeof result === "string" ? undefined : result.attachments const info = yield* agent.get(toolCtx.agent) const out = yield* truncate.output(output, {}, info) return { title: typeof result === "string" ? "" : (result.title ?? ""), output: out.truncated ? out.content : output, attachments, metadata: { ...metadata, truncated: out.truncated, ...(out.truncated && { outputPath: out.outputPath }), }, } }).pipe( Effect.withSpan("Tool.execute", { attributes: { "tool.name": id, "session.id": toolCtx.sessionID, "message.id": toolCtx.messageID, ...(toolCtx.callID ? { "tool.call_id": toolCtx.callID } : {}), }, }), ), } } const dirs = yield* config.directories() const matches = dirs.flatMap((dir) => Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }), ) if (matches.length) yield* config.waitForDependencies() for (const match of matches) { const namespace = path.basename(match, path.extname(match)) // `match` is an absolute filesystem path from `Glob.scanSync(..., { absolute: true })`. // Import it as `file://` so Node on Windows accepts the dynamic import. const mod = yield* Effect.promise(() => import(pathToFileURL(match).href)) for (const [id, def] of Object.entries(mod)) { if (!isPluginTool(def)) continue custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) } } const plugins = yield* plugin.list() for (const p of plugins) { for (const [id, def] of Object.entries(p.tool ?? {})) { custom.push(fromPlugin(id, def)) } } yield* config.get() const questionEnabled = ["app", "cli", "desktop"].includes(flags.client) || flags.enableQuestionTool 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), }) return { custom, builtin: [ tool.invalid, ...(questionEnabled ? [tool.question] : []), tool.shell, tool.read, tool.glob, tool.grep, tool.edit, tool.write, tool.task, tool.fetch, tool.todo, tool.search, tool.skill, tool.patch, ...(flags.experimentalLspTool ? [tool.lsp] : []), ...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []), ], task: tool.task, read: tool.read, } }), ) const all: Interface["all"] = Effect.fn("ToolRegistry.all")(function* () { const s = yield* InstanceState.get(state) return [...s.builtin, ...s.custom] as Tool.Def[] }) const ids: Interface["ids"] = Effect.fn("ToolRegistry.ids")(function* () { return (yield* all()).map((tool) => tool.id) }) const describeTask = Effect.fn("ToolRegistry.describeTask")(function* (agent: Agent.Info) { const items = (yield* agents.list()).filter((item) => item.mode !== "primary") const filtered = items.filter( (item) => Permission.evaluate("task", item.name, agent.permission).action !== "deny", ) const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name)) const description = list .map( (item) => `- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`, ) .join("\n") return ["Available agent types and the tools they have access to:", description].join("\n") }) const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { const filtered = (yield* all()).filter((tool) => { if (tool.id === WebSearchTool.id) { return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel }) } const usePatch = input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4") if (tool.id === ApplyPatchTool.id) return usePatch if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch return true }) return yield* Effect.forEach( filtered, Effect.fnUntraced(function* (tool: Tool.Def) { using _ = log.time(tool.id) const output = { description: tool.description, parameters: tool.parameters, jsonSchema: tool.jsonSchema, } yield* plugin.trigger("tool.definition", { toolID: tool.id }, output) const jsonSchema = output.parameters === tool.parameters || output.jsonSchema !== tool.jsonSchema ? output.jsonSchema : undefined return { id: tool.id, description: [ output.description, tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined, ] .filter(Boolean) .join("\n"), parameters: output.parameters, jsonSchema, execute: tool.execute, formatValidationError: tool.formatValidationError, } }), { concurrency: "unbounded" }, ) }) const named: Interface["named"] = Effect.fn("ToolRegistry.named")(function* () { const s = yield* InstanceState.get(state) return { task: s.task, read: s.read } }) return Service.of({ ids, all, named, tools }) }), ) export const defaultLayer = Layer.suspend(() => layer .pipe( Layer.provide(Config.defaultLayer), Layer.provide(Plugin.defaultLayer), Layer.provide(Question.defaultLayer), Layer.provide(Todo.defaultLayer), Layer.provide(Skill.defaultLayer), Layer.provide(Agent.defaultLayer), Layer.provide(Session.defaultLayer), Layer.provide(BackgroundJob.defaultLayer), Layer.provide(Provider.defaultLayer), Layer.provide(Reference.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(FSUtil.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(Format.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Search.defaultLayer), Layer.provide(Truncate.defaultLayer), ) .pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer)), ) function isZodType(value: unknown): value is z.ZodType { return typeof value === "object" && value !== null && "_zod" in value } function isPluginTool(value: unknown): value is ToolDefinition { return typeof value === "object" && value !== null && "args" in value && "description" in value && "execute" in value } function isJsonSchemaDefinition(value: unknown): value is JSONSchema7Definition { return typeof value === "boolean" || (typeof value === "object" && value !== null && !Array.isArray(value)) } function legacyJsonSchema(entries: [string, unknown][]): JSONSchema7 { const properties = Object.fromEntries( entries.filter((entry): entry is [string, JSONSchema7Definition] => isJsonSchemaDefinition(entry[1])), ) return { type: "object", properties, required: Object.keys(properties), } } function zodJsonSchema(schema: z.ZodType): JSONSchema7 { const result = normalizeZodJsonSchema(z.toJSONSchema(schema, { io: "input", metadata: zodMetadataRegistry(schema) })) if (!isJsonSchemaObject(result)) throw new Error("plugin tool Zod schema produced a non-object JSON Schema") const { $defs, ...rest } = result return ( $defs && isJsonSchemaObject($defs) ? { ...rest, definitions: $defs as JSONSchema7["definitions"] } : rest ) as JSONSchema7 } function zodMetadataRegistry(schema: z.ZodType) { const registry = z.registry>() const seen = new WeakSet() const collect = (value: unknown) => { if (typeof value !== "object" || value === null) return if (seen.has(value)) return seen.add(value) if (isZodType(value)) { const metadata = typeof value.meta === "function" ? value.meta() : undefined const description = typeof value.description === "string" ? value.description : undefined const merged = { ...(metadata && typeof metadata === "object" ? metadata : {}), ...(description ? { description } : {}), } if (Object.keys(merged).length) registry.add(value, merged) collect(value._zod.def) return } for (const item of Object.values(value)) collect(item) } collect(schema) return registry } function normalizeZodJsonSchema(value: unknown): unknown { if (Array.isArray(value)) return value.map((item) => normalizeZodJsonSchema(item)) if (typeof value !== "object" || value === null) return value return Object.fromEntries( Object.entries(value) .filter((entry) => (entry[0] === "exclusiveMaximum" || entry[0] === "exclusiveMinimum") && typeof entry[1] === "boolean" ? false : true, ) .map(([key, item]) => [key, normalizeZodJsonSchema(item)]), ) } function isJsonSchemaObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } export * as ToolRegistry from "./registry"