diff --git a/packages/core/src/tool.ts b/packages/core/src/tool.ts index 88634be3e09..2f6294ceb10 100644 --- a/packages/core/src/tool.ts +++ b/packages/core/src/tool.ts @@ -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()(" export interface Draft { readonly add: (tool: Tool.Info) => void + readonly update: (id: string, update: (tool: Types.Mutable) => 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( diff --git a/packages/core/src/tool/AGENTS.md b/packages/core/src/tool/AGENTS.md index 2c9c9263274..e10969412af 100644 --- a/packages/core/src/tool/AGENTS.md +++ b/packages/core/src/tool/AGENTS.md @@ -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 `_`, 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 `_`, 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. diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 970aeff119e..d4236ea42ca 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -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" }) diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 875e7e64143..756fada4264 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -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 }> = [] + 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 diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index b651686bcf3..696e86e0e6f 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -73,6 +73,115 @@ const transform = (service: Tool.Interface, tools: Readonly ) 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 diff --git a/packages/plugin/src/effect/tool.ts b/packages/plugin/src/effect/tool.ts index a87250a6339..3411955e798 100644 --- a/packages/plugin/src/effect/tool.ts +++ b/packages/plugin/src/effect/tool.ts @@ -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, Output extends Tool.ValueSchema | undefined>( tool: Tool.Info, ): void + /** Updates an existing tool; missing IDs are ignored. */ + update(id: string, update: (tool: Types.Mutable) => void): void + remove(id: string): void } export interface ToolHooks { diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index 6ad5bf3fca5..f88d8f68506 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -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[0], context: Tool.Context) => + executePromiseTool(value, input, context), + }) + }), + remove: draft.remove, }), ), ), diff --git a/packages/plugin/src/promise/tool.ts b/packages/plugin/src/promise/tool.ts index c1464beb464..764feab9ded 100644 --- a/packages/plugin/src/promise/tool.ts +++ b/packages/plugin/src/promise/tool.ts @@ -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 { @@ -26,6 +26,9 @@ interface ToolDraft { add, Output extends Tool.ValueSchema | undefined>( tool: Info, ): void + /** Updates an existing tool; missing IDs are ignored. */ + update(id: string, update: (tool: Types.Mutable) => void): void + remove(id: string): void } interface ToolHooks { diff --git a/packages/www/src/docs/content/build/plugins/effect.mdx b/packages/www/src/docs/content/build/plugins/effect.mdx index 2bd64985966..7e3eac46dec 100644 --- a/packages/www/src/docs/content/build/plugins/effect.mdx +++ b/packages/www/src/docs/content/build/plugins/effect.mdx @@ -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, Output extends Tool.ValueSchema | undefined>( tool: Tool.Info, ): void + update(id: string, update: (tool: Types.Mutable) => void): void + remove(id: string): void } interface ToolDomain { diff --git a/packages/www/src/docs/content/build/plugins/index.mdx b/packages/www/src/docs/content/build/plugins/index.mdx index 184322961c6..e9ff114e83f 100644 --- a/packages/www/src/docs/content/build/plugins/index.mdx +++ b/packages/www/src/docs/content/build/plugins/index.mdx @@ -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) => void): void + remove(id: string): void } ```