diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md index d00bc766b..12017b0e4 100644 --- a/packages/opencode/specs/effect-migration.md +++ b/packages/opencode/specs/effect-migration.md @@ -162,7 +162,7 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade): Still open and likely worth migrating: - [x] `Plugin` -- [ ] `ToolRegistry` +- [x] `ToolRegistry` - [ ] `Pty` - [ ] `Worktree` - [ ] `Bus` diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 6d648a097..6381fcfbc 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -7,14 +7,13 @@ import { GrepTool } from "./grep" import { BatchTool } from "./batch" import { ReadTool } from "./read" import { TaskTool } from "./task" -import { TodoWriteTool, TodoReadTool } from "./todo" +import { TodoWriteTool } from "./todo" import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" 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" @@ -27,106 +26,186 @@ import { Flag } from "@/flag/flag" import { Log } from "@/util/log" import { LspTool } from "./lsp" 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 { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-service" 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(process.platform === "win32" ? match : 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 }, - } - }, - }), - } + type State = { + custom: Tool.Info[] } + 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<(Awaited> & { id: string })[]> + } + + export class Service extends ServiceMap.Service()("@opencode/ToolRegistry") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const cache = yield* InstanceState.make( + Effect.fn("ToolRegistry.state")(function* (ctx) { + const custom: Tool.Info[] = [] + + function fromPlugin(id: string, def: ToolDefinition): Tool.Info { + return { + id, + init: async (initCtx) => ({ + parameters: z.object(def.args), + description: def.description, + execute: async (args, toolCtx) => { + const pluginCtx = { + ...toolCtx, + directory: ctx.directory, + worktree: ctx.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 }, + } + }, + }), + } + } + + 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(process.platform === "win32" ? match : 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 } + }), + ) + + async function all(custom: Tool.Info[]): Promise { + const cfg = 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, + WebSearchTool, + CodeSearchTool, + SkillTool, + ApplyPatchTool, + ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), + ...(cfg.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 state = yield* InstanceState.get(cache) + const idx = state.custom.findIndex((t) => t.id === tool.id) + if (idx >= 0) { + state.custom.splice(idx, 1, tool) + return + } + state.custom.push(tool) + }) + + const ids = Effect.fn("ToolRegistry.ids")(function* () { + const state = yield* InstanceState.get(cache) + const tools = yield* Effect.promise(() => all(state.custom)) + return tools.map((t) => t.id) + }) + + const tools = Effect.fn("ToolRegistry.tools")(function* ( + model: { providerID: ProviderID; modelID: ModelID }, + agent?: Agent.Info, + ) { + const state = yield* InstanceState.get(cache) + const allTools = yield* Effect.promise(() => all(state.custom)) + return yield* Effect.promise(() => + Promise.all( + allTools + .filter((tool) => { + // Enable websearch/codesearch for zen users OR via enable flag + if (tool.id === "codesearch" || tool.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 (tool.id === "apply_patch") return usePatch + if (tool.id === "edit" || tool.id === "write") return !usePatch + + return true + }) + .map(async (tool) => { + using _ = log.time(tool.id) + const next = await tool.init({ agent }) + const output = { + description: next.description, + parameters: next.parameters, + } + await Plugin.trigger("tool.definition", { toolID: tool.id }, output) + return { + id: tool.id, + ...next, + description: output.description, + parameters: output.parameters, + } + }), + ), + ) + }) + + return Service.of({ register, ids, tools }) + }), + ) + + const runPromise = makeRunPromise(Service, layer) + 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 runPromise((svc) => svc.register(tool)) } export async function ids() { - return all().then((x) => x.map((t) => t.id)) + return runPromise((svc) => svc.ids()) } export async function tools( @@ -136,39 +215,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 runPromise((svc) => svc.tools(model, agent)) } }