From 0f9504d6c5ae89dd89417efbb4d4cf2a9d9be787 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:51:37 -0500 Subject: [PATCH] feat(plugin): add list to the tool domain (#49838) Co-authored-by: rekram1-node --- packages/core/src/plugin/host.ts | 1 + packages/core/src/plugin/tool-input-repair.ts | 17 ++++-------- packages/core/src/tool.ts | 2 ++ packages/core/test/lib/tool.ts | 1 + packages/core/test/plugin/host.ts | 1 + packages/core/test/plugin/plan.test.ts | 1 + .../test/plugin/tool-input-repair.test.ts | 27 ++++++------------- packages/core/test/tool-registry.test.ts | 15 +++++++++++ packages/plugin/src/effect/tool.ts | 2 ++ packages/plugin/src/promise/adapter.ts | 4 +++ packages/plugin/src/promise/tool.ts | 2 ++ .../src/docs/content/build/plugins/effect.mdx | 1 + .../src/docs/content/build/plugins/index.mdx | 9 +++++++ 13 files changed, 52 insertions(+), 31 deletions(-) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 129dbe77513..a2aee2865ea 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -453,6 +453,7 @@ export const make = Effect.fn("PluginHost.make")(function* ( tool: { transform: tools.transform, reload: tools.reload, + list: tools.list, hook: (name, callback) => hooks.register("tool", name, callback), }, vcs: { diff --git a/packages/core/src/plugin/tool-input-repair.ts b/packages/core/src/plugin/tool-input-repair.ts index aea98f9d801..984f7f343f1 100644 --- a/packages/core/src/plugin/tool-input-repair.ts +++ b/packages/core/src/plugin/tool-input-repair.ts @@ -1,7 +1,6 @@ export * as ToolInputRepairPlugin from "./tool-input-repair.js" import { define } from "@opencode/plugin/effect/plugin" -import type { ToolEditor } from "@opencode/plugin/effect/tool" import { Effect, JsonSchema, Option, Predicate, Schema } from "effect" import { definition } from "../tool/runtime.js" @@ -21,25 +20,19 @@ const maxDepth = 6 export const Plugin = define({ id: "opencode.tool.input.repair", - effect: Effect.fn(function* (ctx) { - let get: ToolEditor["get"] = () => undefined - yield* ctx.tool.transform((draft) => { - // The draft sees later tool transforms too; reload replaces this lookup. - get = draft.get - }) - yield* ctx.tool.hook("execute.before", (event) => - Effect.sync(() => { + effect: (ctx) => + ctx.tool.hook("execute.before", (event) => + Effect.gen(function* () { // The outer Code Mode tool is built per snapshot rather than registered, so it cannot be // looked up here. Its `{ code }` input is trivial; the tools it calls are repaired normally. if (event.tool === "execute") return - const tool = get(event.tool) + const tool = (yield* ctx.tool.list()).find((tool) => tool.id === event.tool) if (!tool) return const schema = definition(tool).inputSchema if (schema.type !== "object") return event.input = repair(event.input, schema, schema, 0) }), - ) - }), + ), }) function repair(value: unknown, schema: JsonSchema.JsonSchema, root: JsonSchema.JsonSchema, depth: number): unknown { diff --git a/packages/core/src/tool.ts b/packages/core/src/tool.ts index c125aee0560..1e46a289fec 100644 --- a/packages/core/src/tool.ts +++ b/packages/core/src/tool.ts @@ -39,6 +39,7 @@ type Data = { } export interface Interface extends State.Transformable { + readonly list: () => Effect.Effect> readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect } @@ -220,6 +221,7 @@ const layer = Layer.effect( return Service.of({ transform: state.transform, reload: state.reload, + list: () => Effect.sync(() => Array.from(state.get().tools.values())), snapshot: Effect.fn("Tool.snapshot")((permissions) => Effect.sync(() => { const data = state.get() diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index ae3fdaeeea3..27e8a78aa09 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -72,6 +72,7 @@ export const registerToolPlugin = ( tool: { transform: tools.transform, reload: tools.reload, + list: tools.list, hook: () => Effect.die("registerToolPlugin does not support tool hooks"), }, }) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 6cbb5230a73..eaea2bb5879 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -133,6 +133,7 @@ export function host(overrides: Overrides = {}): Plugin.Context { tool: overrides.tool ?? { transform: () => Effect.die("unused tool.transform"), reload: () => Effect.die("unused tool.reload"), + list: () => Effect.die("unused tool.list"), hook: () => Effect.die("unused tool.hook"), }, vcs: overrides.vcs ?? { diff --git a/packages/core/test/plugin/plan.test.ts b/packages/core/test/plugin/plan.test.ts index 42780803f9d..93c5498a5fa 100644 --- a/packages/core/test/plugin/plan.test.ts +++ b/packages/core/test/plugin/plan.test.ts @@ -73,6 +73,7 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray Effect.die("unused tool.transform"), reload: () => Effect.die("unused tool.reload"), + list: () => Effect.die("unused tool.list"), hook: (name, callback) => { if (name === "execute.after") { // Hook names and callbacks are correlated, but TypeScript does not narrow this generic registration API. diff --git a/packages/core/test/plugin/tool-input-repair.test.ts b/packages/core/test/plugin/tool-input-repair.test.ts index 05bebd91ab8..df3cc01413e 100644 --- a/packages/core/test/plugin/tool-input-repair.test.ts +++ b/packages/core/test/plugin/tool-input-repair.test.ts @@ -22,30 +22,19 @@ function run(input: unknown, inputSchema: JsonSchema.JsonSchema) { "execute.before": event, "execute.after": { ...event, status: "error", error: new Tool.Error({ message: "unused" }) }, } + const tool = { + id: "test", + name: "test", + description: "Test repair", + input: inputSchema, + execute: () => Effect.succeed({ content: "unused" }), + } const base = host() return ToolInputRepairPlugin.Plugin.effect( host({ tool: { ...base.tool, - transform: (callback) => - Effect.sync(() => { - const tool = { - id: "test", - name: "test", - description: "Test repair", - input: inputSchema, - execute: () => Effect.succeed({ content: "unused" }), - } - callback({ - list: () => [tool], - get: (id) => (id === tool.id ? tool : undefined), - add: () => {}, - namespace: () => {}, - update: () => {}, - remove: () => {}, - }) - return { dispose: Effect.void } - }), + list: () => Effect.succeed([tool]), hook: (name, callback) => callback(events[name]).pipe(Effect.orDie, Effect.as({ dispose: Effect.void })), }, }), diff --git a/packages/core/test/tool-registry.test.ts b/packages/core/test/tool-registry.test.ts index 5fb933b2a74..2c3e74dace7 100644 --- a/packages/core/test/tool-registry.test.ts +++ b/packages/core/test/tool-registry.test.ts @@ -855,6 +855,21 @@ describe("Tool", () => { ]) }), ) + it.effect("lists registered tools by effective name", () => + Effect.gen(function* () { + const service = yield* Tool.Service + yield* transform(service, { echo: make() }, { codemode: false }) + yield* transform(service, { count: { ...constant("1"), name: "count" } }, { namespace: "acme" }) + + expect((yield* service.list()).map((tool) => [tool.id, tool.name])).toEqual([ + ["echo", "echo"], + ["acme_count", "count"], + ]) + + yield* service.transform((editor) => editor.remove("echo")) + expect((yield* service.list()).map((tool) => tool.id)).toEqual(["acme_count"]) + }), + ) ;[ { name: "string", content: "hooked", text: "hooked" }, { name: "empty string", content: "", text: "" }, diff --git a/packages/plugin/src/effect/tool.ts b/packages/plugin/src/effect/tool.ts index 1fd90a5113e..04494b2630e 100644 --- a/packages/plugin/src/effect/tool.ts +++ b/packages/plugin/src/effect/tool.ts @@ -54,5 +54,7 @@ export interface ToolFailures extends Record { export interface ToolDomain { readonly transform: Transform readonly reload: () => Effect.Effect + /** Currently registered tools, after every transform, keyed by effective name. */ + readonly list: () => Effect.Effect readonly hook: Hooks } diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index ac0c845f121..7fd49d85ab5 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -462,6 +462,10 @@ export function fromPromise(plugin: Plugin) { }, tool: { reload: () => run(host.tool.reload()), + list: () => + run(host.tool.list()).then((tools) => + tools.map((tool) => ({ ...tool, execute: promiseExecutor(tool.execute) })), + ), transform: (callback) => register( host.tool.transform((editor) => diff --git a/packages/plugin/src/promise/tool.ts b/packages/plugin/src/promise/tool.ts index 729cb7e5493..4f6788b4243 100644 --- a/packages/plugin/src/promise/tool.ts +++ b/packages/plugin/src/promise/tool.ts @@ -65,5 +65,7 @@ interface ToolHooks { export interface ToolDomain { readonly transform: Transform readonly reload: () => Promise + /** Currently registered tools, after every transform, keyed by effective name. */ + readonly list: () => Promise readonly hook: Hooks } diff --git a/services/www/src/docs/content/build/plugins/effect.mdx b/services/www/src/docs/content/build/plugins/effect.mdx index 75e0f1ebabf..e20d15d8320 100644 --- a/services/www/src/docs/content/build/plugins/effect.mdx +++ b/services/www/src/docs/content/build/plugins/effect.mdx @@ -1014,6 +1014,7 @@ interface ToolEditor { } interface ToolDomain { + readonly list: () => Effect.Effect readonly transform: Transform readonly reload: () => Effect.Effect } diff --git a/services/www/src/docs/content/build/plugins/index.mdx b/services/www/src/docs/content/build/plugins/index.mdx index 279cb113010..0e4d35bdc2c 100644 --- a/services/www/src/docs/content/build/plugins/index.mdx +++ b/services/www/src/docs/content/build/plugins/index.mdx @@ -892,6 +892,14 @@ interface StorageScanResult { ### Tools +Read the tools currently registered, after every transform, outside of a transform callback. Each tool has an `id` +containing its effective name. + +```ts +const tools = await ctx.tool.list() +const read = tools.find((tool) => tool.id === "read") +``` + Register, update, and remove tools with a synchronous transform, including in Promise plugins. Load external data before registering or reloading. A later valid registration overrides the same effective tool name. @@ -964,6 +972,7 @@ Schemas: [`Tool.Content`](/api#schema-Tool.Content), [`Tool.TextContent`](/api#s ```ts interface ToolContext { + list(): Promise transform(callback: (editor: ToolEditor) => void): Promise reload(): Promise }