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/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index 7fe13833c8..1d9a31d4a2 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -1,5 +1,5 @@ import z from "zod" -import type { ZodType } from "zod" +import type { ZodObject, ZodRawShape } from "zod" import { Log } from "../util/log" export namespace BusEvent { @@ -9,7 +9,7 @@ export namespace BusEvent { const registry = new Map() - export function define(type: Type, properties: Properties) { + export function define>(type: Type, properties: Properties) { const result = { type, properties, diff --git a/packages/opencode/src/bus/global.ts b/packages/opencode/src/bus/global.ts index 43386dd6b2..dcc7664007 100644 --- a/packages/opencode/src/bus/global.ts +++ b/packages/opencode/src/bus/global.ts @@ -4,7 +4,7 @@ export const GlobalBus = new EventEmitter<{ event: [ { directory?: string - payload: any + payload: { type: string; properties: Record } }, ] }>() diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index e5294844b1..b519895b2f 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -124,7 +124,7 @@ export namespace Workspace { await parseSSE(res.body, stop, (event) => { GlobalBus.emit("event", { directory: space.id, - payload: event, + payload: event as { type: string; properties: Record }, }) }) // Wait 250ms and retry if SSE connection fails diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index 6fcfddb24f..327691a9e9 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -10,6 +10,7 @@ import { ProviderAuth } from "@/provider/auth-service" import { Question } from "@/question/service" import { Skill } from "@/skill/service" import { Snapshot } from "@/snapshot/service" +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) { File.layer, Skill.defaultLayer, Snapshot.defaultLayer, + ToolRegistry.layer, ).pipe(Layer.provide(ctx)) } diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 6d648a097a..dc57d14961 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,132 +1,240 @@ -import { PlanExitTool } from "./plan" -import { QuestionTool } from "./question" -import { BashTool } from "./bash" -import { EditTool } from "./edit" -import { GlobTool } from "./glob" -import { GrepTool } from "./grep" -import { BatchTool } from "./batch" -import { ReadTool } from "./read" -import { TaskTool } from "./task" -import { TodoWriteTool, TodoReadTool } 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" import z from "zod" -import { Plugin } from "../plugin" import { ProviderID, type ModelID } from "../provider/schema" -import { WebSearchTool } from "./websearch" -import { CodeSearchTool } from "./codesearch" 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 { InstanceContext } from "@/effect/instance-context" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) - export const state = Instance.state(async () => { - const custom = [] as 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 })[]> + } - 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)) - } - } + export class Service extends ServiceMap.Service()("@opencode/ToolRegistry") {} - const plugins = await Plugin.list() - for (const plugin of plugins) { - for (const [id, def] of Object.entries(plugin.tool ?? {})) { - custom.push(fromPlugin(id, def)) - } - } + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const instance = yield* InstanceContext - return { custom } - }) + const custom: Tool.Info[] = [] + let task: Promise | undefined - 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 }, + const load = Effect.fn("ToolRegistry.load")(function* () { + yield* Effect.promise(async () => { + const [{ Config }, { Plugin }] = await Promise.all([import("../config/config"), import("../plugin")]) + 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)) + } + } + }) + }) + + const ensure = Effect.fn("ToolRegistry.ensure")(function* () { + yield* Effect.promise(() => { + task ??= Effect.runPromise( + load().pipe(Effect.catchCause((cause) => Effect.sync(() => log.error("init failed", { cause })))), + ) + return task + }) + }) + + 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 import("../config/config") + const config = await Config.get() + const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL + const [ + invalid, + questionMod, + bash, + read, + glob, + grep, + edit, + write, + task, + webfetch, + todo, + websearch, + codesearch, + skill, + applyPatch, + lsp, + batch, + plan, + ] = await Promise.all([ + import("./invalid"), + import("./question"), + import("./bash"), + import("./read"), + import("./glob"), + import("./grep"), + import("./edit"), + import("./write"), + import("./task"), + import("./webfetch"), + import("./todo"), + import("./websearch"), + import("./codesearch"), + import("./skill"), + import("./apply_patch"), + import("./lsp"), + import("./batch"), + import("./plan"), + ]) + + return [ + invalid.InvalidTool, + ...(question ? [questionMod.QuestionTool] : []), + bash.BashTool, + read.ReadTool, + glob.GlobTool, + grep.GrepTool, + edit.EditTool, + write.WriteTool, + task.TaskTool, + webfetch.WebFetchTool, + todo.TodoWriteTool, + // TodoReadTool, + websearch.WebSearchTool, + codesearch.CodeSearchTool, + skill.SkillTool, + applyPatch.ApplyPatchTool, + ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [lsp.LspTool] : []), + ...(config.experimental?.batch_tool === true ? [batch.BatchTool] : []), + ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [plan.PlanExitTool] : []), + ...custom, + ] + } + + const register = Effect.fn("ToolRegistry.register")(function* (tool: Tool.Info) { + yield* ensure() + 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* () { + yield* ensure() + 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, + ) { + yield* ensure() + 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, + } + const { Plugin } = await import("../plugin") + 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 }) + }), + ).pipe(Layer.fresh) + + async function run(effect: Effect.Effect) { + const { runPromiseInstance } = await import("@/effect/runtime") + return runPromiseInstance(effect) } 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 run(Service.use((svc) => svc.register(tool))) } export async function ids() { - return all().then((x) => x.map((t) => t.id)) + return run(Service.use((svc) => svc.ids())) } export async function tools( @@ -136,39 +244,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 run(Service.use((svc) => svc.tools(model, agent))) } } diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index 2cd27643e8..d4893e963e 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -16,7 +16,7 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc // Helpers // --------------------------------------------------------------------------- -type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } } +type BusUpdate = { directory?: string; payload: { type: string; properties: Record } } type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } /** Run `body` with a live FileWatcher service. */ @@ -40,18 +40,18 @@ function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: ( if (done) return if (evt.directory !== directory) return if (evt.payload.type !== FileWatcher.Event.Updated.type) return - if (!check(evt.payload.properties)) return - hit(evt.payload.properties) + const props = evt.payload.properties as WatcherEvent + if (!check(props)) return + hit(props) } - function cleanup() { + GlobalBus.on("event", on) + + return () => { if (done) return done = true GlobalBus.off("event", on) } - - GlobalBus.on("event", on) - return cleanup } function wait(directory: string, check: (evt: WatcherEvent) => boolean) {