diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md index 4f195917fd..0f9f5d6050 100644 --- a/packages/opencode/specs/effect-migration.md +++ b/packages/opencode/specs/effect-migration.md @@ -127,7 +127,7 @@ Done now: Still open and likely worth migrating: - [ ] `Plugin` -- [ ] `ToolRegistry` +- [x] `ToolRegistry` - [ ] `Pty` - [ ] `Worktree` - [ ] `Installation` diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index c05458d5df..e982b0af73 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -10,6 +10,7 @@ import { ProviderAuth } from "@/provider/auth" import { Question } from "@/question" import { Skill } from "@/skill/skill" import { Snapshot } from "@/snapshot" +import { ToolRegistry } from "@/tool/registry" import { InstanceContext } from "./instance-context" import { registerDisposer } from "./instance-registry" @@ -26,6 +27,7 @@ export type InstanceServices = | File.Service | Skill.Service | Snapshot.Service + | ToolRegistry.Service // NOTE: LayerMap only passes the key (directory string) to lookup, but we need // the full instance context (directory, worktree, project). We read from the @@ -46,6 +48,7 @@ function lookup(_key: string) { Layer.fresh(File.layer), Layer.fresh(Skill.defaultLayer), Layer.fresh(Snapshot.defaultLayer), + Layer.fresh(ToolRegistry.layer), ).pipe(Layer.provide(ctx)) } diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index da9a897905..fc7f1fcdea 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -14,7 +14,6 @@ import { InvalidTool } from "./invalid" import { SkillTool } from "./skill" import type { Agent } from "../agent/agent" import { Tool } from "./tool" -import { Instance } from "../project/instance" import { Config } from "../config/config" import path from "path" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" @@ -31,102 +30,171 @@ import { Truncate } from "./truncate" import { ApplyPatchTool } from "./apply_patch" import { Glob } from "../util/glob" import { pathToFileURL } from "url" +import { Effect, Layer, ServiceMap } from "effect" +import { InstanceContext } from "@/effect/instance-context" +import { runPromiseInstance } from "@/effect/runtime" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) - export const state = Instance.state(async () => { - const custom = [] as Tool.Info[] - - const matches = await Config.directories().then((dirs) => - dirs.flatMap((dir) => - Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }), - ), - ) - if (matches.length) await Config.waitForDependencies() - for (const match of matches) { - const namespace = path.basename(match, path.extname(match)) - const mod = await import(pathToFileURL(match).href) - for (const [id, def] of Object.entries(mod)) { - custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) - } - } - - const plugins = await Plugin.list() - for (const plugin of plugins) { - for (const [id, def] of Object.entries(plugin.tool ?? {})) { - custom.push(fromPlugin(id, def)) - } - } - - return { custom } - }) - - function fromPlugin(id: string, def: ToolDefinition): Tool.Info { - return { - id, - init: async (initCtx) => ({ - parameters: z.object(def.args), - description: def.description, - execute: async (args, ctx) => { - const pluginCtx = { - ...ctx, - directory: Instance.directory, - worktree: Instance.worktree, - } as unknown as PluginToolContext - const result = await def.execute(args as any, pluginCtx) - const out = await Truncate.output(result, {}, initCtx?.agent) - return { - title: "", - output: out.truncated ? out.content : result, - metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined }, - } - }, - }), - } + export interface Interface { + readonly register: (tool: Tool.Info) => Effect.Effect + readonly ids: () => Effect.Effect + readonly tools: ( + model: { providerID: ProviderID; modelID: ModelID }, + agent?: Agent.Info, + ) => Effect.Effect<{ id: string; description: string; parameters: z.ZodType; execute: Awaited>["execute"] }[]> } + export class Service extends ServiceMap.Service()("@opencode/ToolRegistry") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const instance = yield* InstanceContext + + const custom: Tool.Info[] = [] + + yield* Effect.promise(async () => { + const matches = await Config.directories().then((dirs) => + dirs.flatMap((dir) => + Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }), + ), + ) + if (matches.length) await Config.waitForDependencies() + for (const match of matches) { + const namespace = path.basename(match, path.extname(match)) + const mod = await import(pathToFileURL(match).href) + for (const [id, def] of Object.entries(mod)) { + custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) + } + } + + const plugins = await Plugin.list() + for (const plugin of plugins) { + for (const [id, def] of Object.entries(plugin.tool ?? {})) { + custom.push(fromPlugin(id, def)) + } + } + }) + + function fromPlugin(id: string, def: ToolDefinition): Tool.Info { + return { + id, + init: async (initCtx) => ({ + parameters: z.object(def.args), + description: def.description, + execute: async (args, ctx) => { + const pluginCtx = { + ...ctx, + directory: instance.directory, + worktree: instance.worktree, + } as unknown as PluginToolContext + const result = await def.execute(args as any, pluginCtx) + const out = await Truncate.output(result, {}, initCtx?.agent) + return { + title: "", + output: out.truncated ? out.content : result, + metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined }, + } + }, + }), + } + } + + async function all(): Promise { + const config = await Config.get() + const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL + + return [ + InvalidTool, + ...(question ? [QuestionTool] : []), + BashTool, + ReadTool, + GlobTool, + GrepTool, + EditTool, + WriteTool, + TaskTool, + WebFetchTool, + TodoWriteTool, + // TodoReadTool, + WebSearchTool, + CodeSearchTool, + SkillTool, + ApplyPatchTool, + ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), + ...(config.experimental?.batch_tool === true ? [BatchTool] : []), + ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []), + ...custom, + ] + } + + const register = Effect.fn("ToolRegistry.register")(function* (tool: Tool.Info) { + const idx = custom.findIndex((t) => t.id === tool.id) + if (idx >= 0) { + custom.splice(idx, 1, tool) + return + } + custom.push(tool) + }) + + const ids = Effect.fn("ToolRegistry.ids")(function* () { + const tools = yield* Effect.promise(() => all()) + return tools.map((t) => t.id) + }) + + const tools = Effect.fn("ToolRegistry.tools")(function* ( + model: { providerID: ProviderID; modelID: ModelID }, + agent?: Agent.Info, + ) { + const allTools = yield* Effect.promise(() => all()) + return yield* Effect.promise(() => + Promise.all( + allTools + .filter((t) => { + // Enable websearch/codesearch for zen users OR via enable flag + if (t.id === "codesearch" || t.id === "websearch") { + return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA + } + + // use apply tool in same format as codex + const usePatch = + model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4") + if (t.id === "apply_patch") return usePatch + if (t.id === "edit" || t.id === "write") return !usePatch + + return true + }) + .map(async (t) => { + using _ = log.time(t.id) + const tool = await t.init({ agent }) + const output = { + description: tool.description, + parameters: tool.parameters, + } + await Plugin.trigger("tool.definition", { toolID: t.id }, output) + return { + id: t.id, + ...tool, + description: output.description, + parameters: output.parameters, + } + }), + ), + ) + }) + + return Service.of({ register, ids, tools }) + }), + ) + export async function register(tool: Tool.Info) { - const { custom } = await state() - const idx = custom.findIndex((t) => t.id === tool.id) - if (idx >= 0) { - custom.splice(idx, 1, tool) - return - } - custom.push(tool) - } - - async function all(): Promise { - const custom = await state().then((x) => x.custom) - const config = await Config.get() - const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL - - return [ - InvalidTool, - ...(question ? [QuestionTool] : []), - BashTool, - ReadTool, - GlobTool, - GrepTool, - EditTool, - WriteTool, - TaskTool, - WebFetchTool, - TodoWriteTool, - // TodoReadTool, - WebSearchTool, - CodeSearchTool, - SkillTool, - ApplyPatchTool, - ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), - ...(config.experimental?.batch_tool === true ? [BatchTool] : []), - ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []), - ...custom, - ] + return runPromiseInstance(Service.use((svc) => svc.register(tool))) } export async function ids() { - return all().then((x) => x.map((t) => t.id)) + return runPromiseInstance(Service.use((svc) => svc.ids())) } export async function tools( @@ -136,39 +204,6 @@ export namespace ToolRegistry { }, agent?: Agent.Info, ) { - const tools = await all() - const result = await Promise.all( - tools - .filter((t) => { - // Enable websearch/codesearch for zen users OR via enable flag - if (t.id === "codesearch" || t.id === "websearch") { - return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA - } - - // use apply tool in same format as codex - const usePatch = - model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4") - if (t.id === "apply_patch") return usePatch - if (t.id === "edit" || t.id === "write") return !usePatch - - return true - }) - .map(async (t) => { - using _ = log.time(t.id) - const tool = await t.init({ agent }) - const output = { - description: tool.description, - parameters: tool.parameters, - } - await Plugin.trigger("tool.definition", { toolID: t.id }, output) - return { - id: t.id, - ...tool, - description: output.description, - parameters: output.parameters, - } - }), - ) - return result + return runPromiseInstance(Service.use((svc) => svc.tools(model, agent))) } }