feat(plugin): add tool updates and removal (#45436)

This commit is contained in:
Aiden Cline
2026-08-26 22:00:01 -05:00
committed by GitHub
parent 48d4e52143
commit 2bcb67a71e
10 changed files with 299 additions and 8 deletions
+21 -1
View File
@@ -4,7 +4,7 @@ export type { Context, Metadata, Options, Result } from "@opencode-ai/schema/too
import { ToolDefinition, type ToolCall } from "@opencode-ai/ai"
import { Tool } from "@opencode-ai/schema/tool"
import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"
import { Context, Effect, Layer, Result, Schema, SchemaIssue, Types } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import type { Agent } from "./agent.js"
import { CodeModeCatalog } from "./codemode/catalog.js"
@@ -25,6 +25,8 @@ export class RegistrationError extends Schema.TaggedError<RegistrationError>()("
export interface Draft {
readonly add: (tool: Tool.Info) => void
readonly update: (id: string, update: (tool: Types.Mutable<Tool.Info>) => void) => void
readonly remove: (id: string) => void
}
type Data = {
@@ -156,6 +158,24 @@ const layer = Layer.effect(
}
draft.tools.set(effectiveName(tool), { ...tool, options: tool.options && { ...tool.options } })
},
update: (id, update) => {
const current = draft.tools.get(id)
if (!current) return
const tool = { ...current, options: current.options && { ...current.options } }
update(tool)
tool.name = current.name
if (tool.options?.namespace !== current.options?.namespace)
tool.options = { ...tool.options, namespace: current.options?.namespace }
const error = registrationError(tool)
if (error) {
draft.errors.push({ tool, error })
return
}
draft.tools.set(id, tool)
},
remove: (id) => {
draft.tools.delete(id)
},
}),
finalize: () =>
Effect.forEach(
+2 -1
View File
@@ -30,11 +30,12 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
## Registration
Built-ins, plugins, and MCP install tools through `Tool.Service.transform`, adding complete tool objects to the add-only draft. A tool may provide a namespace, which flattens direct model names to `<namespace>_<tool>`, and defaults into CodeMode (`codemode` defaults true; `codemode: false` keeps the tool on the provider's native tool list).
Built-ins, plugins, and MCP install tools through `Tool.Service.transform`, adding complete tool objects to the draft. A tool may provide a namespace, which flattens direct model names to `<namespace>_<tool>`, and defaults into CodeMode (`codemode` defaults true; `codemode: false` keeps the tool on the provider's native tool list).
The service uses shared `State` to replay synchronous transforms in registration order against a fresh draft. `Tool.Service.reload()` rebuilds from captured source data without changing registration precedence. Registrations are scoped and return a real, idempotent `dispose` Effect:
- The latest valid active registration for the same effective name wins.
- `update` and `remove` target effective names and do nothing for missing tools. Updates preserve the name and namespace; invalid updates leave the previous definition intact. Creating a tool requires `add`.
- Disposing a registration or closing its scope removes only its transform and rebuilds from the remaining transforms, revealing any earlier definition it overrode.
- Each model request captures the effective definitions and executors it advertises; later reloads and disposal affect later snapshots. Captured executors may still reference mutable producer-owned state.
+18 -2
View File
@@ -1282,7 +1282,7 @@ test("serializes concurrent MCP lifecycle operations", async () => {
)
})
testEffect(Layer.empty).live("isolates invalid MCP tools and preserves overrides through catalog updates", () =>
testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin transforms through catalog updates", () =>
Effect.gen(function* () {
const tool = (server: string, name: string, description = name) =>
new MCP.Tool({
@@ -1316,6 +1316,12 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and preserves overrides
execute: () => Effect.succeed({ output: "override" }),
})
})
const mutation = yield* registry.transform((draft) => {
draft.update("other_lookup", (tool) => {
tool.description += " updated"
})
draft.remove("repaired_lookup")
})
yield* Ref.set(catalog, [tool("demo", "y".repeat(65)), ...healthy, tool("demo", "added"), namespace])
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
@@ -1329,6 +1335,9 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and preserves overrides
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe(
"Override search",
)
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "other_lookup")?.description).toBe(
"lookup updated",
)
yield* Effect.forEach(["demo_search", "other_lookup"], (name) =>
executeTool(registry, {
sessionID: Session.ID.make("ses_mcp_invalid_catalog"),
@@ -1359,12 +1368,19 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and preserves overrides
"demo_search",
"demo_status",
"other_lookup",
"repaired_lookup",
"execute",
])
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe(
"Override search",
)
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "other_lookup")?.description).toBe(
"lookup updated",
)
yield* mutation.dispose
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toContain("repaired_lookup")
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "other_lookup")?.description).toBe(
"lookup",
)
yield* Ref.set(catalog, [tool("demo", "search", "Latest search"), tool("demo", "refreshed")])
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
+80
View File
@@ -694,6 +694,86 @@ describe("fromPromise", () => {
}),
)
it.live("adapts tool updates, executor wrapping, and removal across replay and disposal", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const host = yield* PluginHost.make(plugins)
const progress: Tool.Metadata[] = []
let greeting = "Hello"
const registrations: Array<{ dispose: () => Promise<void> }> = []
yield* host.tool.transform((draft) => {
const text = greeting
draft.add({
name: "hello",
description: "Hello",
options: { namespace: "acme", codemode: false },
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
execute: ({ name }, context) =>
context.progress({ phase: "original" }).pipe(Effect.as({ output: `${text}, ${name}!` })),
})
draft.add({
name: "temporary",
description: "Temporary",
input: Schema.Struct({}),
options: { codemode: false },
execute: () => Effect.succeed({ content: "temporary" }),
})
})
yield* PluginPromise.fromPromise(
define({
id: "promise-tool-mutations",
setup: async (ctx) => {
registrations.push(
await ctx.tool.transform((draft) => {
draft.update("missing", () => {
throw new Error("must not create a tool")
})
draft.update("acme_hello", (tool) => {
const execute = tool.execute
tool.description = "Wrapped"
delete tool.output
tool.execute = async (input, context) => {
const result = await execute(input, context)
return { content: `${result.output} Wrapped.` }
}
})
draft.remove("temporary")
}),
)
greeting = "Hi"
await ctx.tool.reload()
},
}),
).effect(host)
const snapshot = yield* registry.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"])
expect(snapshot.definitions[0]?.description).toBe("Wrapped")
expect(snapshot.definitions[0]?.outputSchema).toBeUndefined()
expect(
yield* snapshot.execute({
sessionID: Session.ID.make("ses_promise_tool_update"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_promise_tool_update"),
call: { type: "tool-call", id: "call_update", name: "acme_hello", input: { name: "world" } },
progress: (update) =>
Effect.sync(() => {
progress.push(update)
}),
}),
).toMatchObject({ content: [{ type: "text", text: "Hi, world! Wrapped." }] })
expect(progress).toEqual([{ phase: "original" }])
const registration = registrations[0]
if (!registration) return yield* Effect.die("Promise tool registration was not captured")
yield* Effect.promise(() => registration.dispose())
yield* Effect.promise(() => registration.dispose())
const restored = yield* registry.snapshot()
expect(restored.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "temporary", "execute"])
expect(restored.definitions[0]?.description).toBe("Hello")
}),
)
it.effect("returns content-only plugin results through Code Mode", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
@@ -73,6 +73,115 @@ const transform = (service: Tool.Interface, tools: Readonly<Record<string, Info>
)
describe("Tool", () => {
it.effect("replays mutations on refreshed sources and restores tools on disposal and scope cleanup", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
let text = "original"
const source = yield* Scope.make()
yield* service
.transform((draft) => {
draft.add({ ...constant(text), name: "echo", options: { namespace: "acme", codemode: false } })
draft.add({ ...make(), name: "hidden" })
})
.pipe(Scope.provide(source))
const original = yield* service.snapshot()
const update = yield* service.transform((draft) => {
draft.update("missing", () => {
throw new Error("must not create a tool")
})
draft.remove("missing")
draft.update("acme_echo", (tool) => {
const execute = tool.execute
tool.description = "Updated"
tool.execute = (input, context) =>
execute(input, context).pipe(
Effect.map((result) => ({ ...result, output: { text: `${result.output.text} updated` } })),
)
})
})
const scope = yield* Scope.make()
yield* service.transform((draft) => draft.remove("hidden")).pipe(Scope.provide(scope))
expect((yield* service.snapshot()).codeModeCatalog).toEqual([])
expect((yield* executeTool(service, call("acme_echo"))).output).toEqual({ text: "original updated" })
text = "refreshed"
const reload = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
const refreshed = yield* service.snapshot()
expect(refreshed.definitions[0]?.description).toBe("Updated")
expect(refreshed.codeModeCatalog).toEqual([])
expect((yield* refreshed.execute(call("acme_echo"))).output).toEqual({ text: "refreshed updated" })
expect((yield* original.execute(call("acme_echo"))).output).toEqual({ text: "original" })
yield* update.dispose
yield* update.dispose
expect((yield* executeTool(service, call("acme_echo"))).output).toEqual({ text: "refreshed" })
yield* Scope.close(scope, Exit.void)
expect((yield* service.snapshot()).codeModeCatalog?.map((tool) => tool.path)).toEqual(["hidden"])
yield* service.transform((draft) =>
draft.update("acme_echo", (tool) => {
tool.description = "Updated again"
}),
)
yield* Scope.close(source, Exit.void)
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)
it.effect("updates schemas and executors without renaming tools and applies removal in order", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* service.transform((draft) => {
draft.add({ ...make(), options: { namespace: "acme.tools", codemode: false } })
draft.add({ ...make(), name: "removed", options: { codemode: false } })
draft.remove("removed")
draft.update("removed", () => {
throw new Error("must not resurrect a tool")
})
draft.remove("acme_tools_echo")
draft.add({ ...make(), options: { namespace: "acme.tools", codemode: false } })
draft.update("acme_tools_echo", (tool) => {
tool.name = "renamed"
tool.options = { namespace: "other", codemode: false }
tool.input = Schema.Struct({ value: Schema.Finite })
tool.output = Schema.Finite
tool.execute = ({ value }) => Effect.succeed({ output: value + 1 })
})
})
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["acme_tools_echo", "execute"])
expect(snapshot.definitions[0]?.inputSchema.properties).toEqual({ value: { type: "number" } })
expect(
(yield* snapshot.execute({
...call("acme_tools_echo"),
call: {
type: "tool-call",
id: "updated",
name: "acme_tools_echo",
input: { value: 2 },
},
})).output,
).toBe(3)
expect(yield* snapshot.execute(call("acme_tools_echo")).pipe(Effect.flip)).toBeInstanceOf(Tool.Error)
}),
)
it.effect("skips invalid updates without dropping the existing definition", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(service, { echo: make() }, { codemode: false })
yield* service.transform((draft) =>
draft.update("echo", (tool) => {
Object.assign(tool, { description: undefined })
}),
)
expect((yield* service.snapshot()).definitions[0]?.description).toBe("Echo text")
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "echo" })
}),
)
it.effect("replays empty sources on reload and keeps advertised snapshots", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
+4 -1
View File
@@ -2,13 +2,16 @@ import { Tool } from "@opencode-ai/schema/tool"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { Effect, JsonSchema } from "effect"
import type { Effect, JsonSchema, Types } from "effect"
import type { Hooks, Transform } from "./registration.js"
export interface ToolDraft {
add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
tool: Tool.Info<Input, Output>,
): void
/** Updates an existing tool; missing IDs are ignored. */
update(id: string, update: (tool: Types.Mutable<Tool.Info>) => void): void
remove(id: string): void
}
export interface ToolHooks {
+22
View File
@@ -301,6 +301,28 @@ export function fromPromise(plugin: Plugin) {
...tool,
execute: (input, context) => executePromiseTool(tool, input, context),
}),
update: (id, update) =>
draft.update(id, (tool) => {
const execute = tool.execute
const value: Info = {
...tool,
execute: (input, context) =>
run(
execute(input, {
...context,
progress: (update) => Effect.promise(() => context.progress(update)),
}),
),
}
update(value)
Object.assign(tool, value, {
output: value.output,
options: value.options,
execute: (input: Parameters<Info["execute"]>[0], context: Tool.Context) =>
executePromiseTool(value, input, context),
})
}),
remove: draft.remove,
}),
),
),
+4 -1
View File
@@ -5,7 +5,7 @@ import { Tool } from "@opencode-ai/schema/tool"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { JsonSchema } from "effect"
import type { JsonSchema, Types } from "effect"
import type { Hooks, Transform } from "./registration.js"
export interface ToolContext extends Omit<Tool.Context, "progress"> {
@@ -26,6 +26,9 @@ interface ToolDraft {
add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
tool: Info<Input, Output>,
): void
/** Updates an existing tool; missing IDs are ignored. */
update(id: string, update: (tool: Types.Mutable<Info>) => void): void
remove(id: string): void
}
interface ToolHooks {
@@ -829,7 +829,7 @@ interface StorageDomain {
Register typed tools with Effect `Schema`. The executor receives decoded input and returns an Effect containing typed
output, display content, or metadata.
The draft is add-only and the transform callback is synchronous: it must not return an Effect or Promise. Load
The draft supports `add`, `update`, and `remove`. The transform callback is synchronous: it must not return an Effect or Promise. Load
external data before registering or reloading. OpenCode replays active transforms in registration order on a fresh
draft; for the same effective tool name, a later valid registration overrides an earlier one.
@@ -857,6 +857,22 @@ effect: (ctx) =>
Call `yield* ctx.tool.reload()` after changing source data captured by the callback. Reload replays active transforms
without changing their order; it does not rerun the plugin effect.
`update` and `remove` target the effective tool name, including its namespace (`acme_greeting` above). Dots in
namespaces and unsupported characters in tool names become `_`. Missing names are ignored; creating a tool requires
`add` with a complete definition. Updates preserve the name and namespace. Assign new schemas or options to replace
them rather than mutating nested values. Invalid updates are logged and leave the previous definition intact.
```ts
yield* ctx.tool.transform((draft) => {
draft.update("acme_greeting", (tool) => {
tool.description = "Greet the user by name"
})
draft.remove("acme_obsolete")
})
```
Updates and removals replay in order with additions, including after MCP catalog refreshes.
`transform` returns a scoped registration. Run `yield* registration.dispose` to remove its transform and rebuild
from the remaining transforms, revealing any earlier definition it overrode. Disposal is idempotent, and closing
the plugin scope also disposes its registrations.
@@ -873,6 +889,8 @@ interface ToolDraft {
add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
tool: Tool.Info<Input, Output>,
): void
update(id: string, update: (tool: Types.Mutable<Tool.Info>) => void): void
remove(id: string): void
}
interface ToolDomain {
@@ -784,7 +784,7 @@ interface StorageScanResult {
### Tools
Register tools with an add-only transform. The callback is synchronous, including in Promise plugins; load external
Register, update, and remove tools with a transform. The callback is synchronous, including in Promise plugins; load external
data before registering or reloading. OpenCode replays active transforms in registration order on a fresh draft.
For the same effective tool name, a later valid registration overrides an earlier one.
@@ -815,6 +815,23 @@ changing their order; it does not rerun plugin setup.
await ctx.tool.reload()
```
Use `update` and `remove` with the effective tool name, including its namespace (`acme_greeting` above). Dots in
namespaces and unsupported characters in tool names become `_`. Missing names are ignored; creating a tool requires
`add` with a complete definition. Updates preserve the name and namespace. Assign new schemas or options to replace
them rather than mutating nested values. Invalid updates are logged and leave the previous definition intact.
```ts
await ctx.tool.transform((draft) => {
draft.update("acme_greeting", (tool) => {
tool.description = "Greet the user by name"
})
draft.remove("acme_obsolete")
})
```
Updates and removals replay in order with additions, including after MCP catalog refreshes. Disposing their
registration removes those changes and rebuilds from the remaining transforms.
Dispose a registration to remove its transform and rebuild from the remaining transforms, revealing any earlier
definition it overrode. Disposal is idempotent, and unloading the plugin also disposes its registrations.
@@ -839,6 +856,8 @@ interface ToolContext {
interface ToolDraft {
add(tool: ToolInfo): void
update(id: string, update: (tool: Types.Mutable<ToolInfo>) => void): void
remove(id: string): void
}
```