feat(plugin): add list to the tool domain (#49838)

Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
This commit is contained in:
Aiden Cline
2026-09-18 18:51:37 -05:00
committed by GitHub
parent f6353a48b5
commit 0f9504d6c5
13 changed files with 52 additions and 31 deletions
+1
View File
@@ -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: {
+5 -12
View File
@@ -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 {
+2
View File
@@ -39,6 +39,7 @@ type Data = {
}
export interface Interface extends State.Transformable<Editor> {
readonly list: () => Effect.Effect<ReadonlyArray<Tool.Info & { readonly id: string }>>
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
}
@@ -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()
+1
View File
@@ -72,6 +72,7 @@ export const registerToolPlugin = <R>(
tool: {
transform: tools.transform,
reload: tools.reload,
list: tools.list,
hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
},
})
+1
View File
@@ -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 ?? {
+1
View File
@@ -73,6 +73,7 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
tool: {
transform: () => 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.
@@ -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 })),
},
}),
+15
View File
@@ -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: "" },
+2
View File
@@ -54,5 +54,7 @@ export interface ToolFailures extends Record<keyof ToolHooks, unknown> {
export interface ToolDomain {
readonly transform: Transform<ToolEditor>
readonly reload: () => Effect.Effect<void>
/** Currently registered tools, after every transform, keyed by effective name. */
readonly list: () => Effect.Effect<readonly (Tool.Info & { readonly id: string })[]>
readonly hook: Hooks<ToolHooks, ToolFailures>
}
+4
View File
@@ -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) =>
+2
View File
@@ -65,5 +65,7 @@ interface ToolHooks {
export interface ToolDomain {
readonly transform: Transform<ToolEditor>
readonly reload: () => Promise<void>
/** Currently registered tools, after every transform, keyed by effective name. */
readonly list: () => Promise<readonly (Info & { readonly id: string })[]>
readonly hook: Hooks<ToolHooks>
}
@@ -1014,6 +1014,7 @@ interface ToolEditor {
}
interface ToolDomain {
readonly list: () => Effect.Effect<readonly (Tool.Info & { readonly id: string })[]>
readonly transform: Transform<ToolEditor>
readonly reload: () => Effect.Effect<void>
}
@@ -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<readonly (ToolInfo & { readonly id: string })[]>
transform(callback: (editor: ToolEditor) => void): Promise<Registration>
reload(): Promise<void>
}