From 40cbea3c19253d0b51bb42688e144b9a4b2af3e4 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:33:00 -0500 Subject: [PATCH] refactor(core): use shared state for tool registry (#45414) --- packages/core/src/plugin/host.ts | 10 +- packages/core/src/tool.ts | 237 ++++++++---------- packages/core/src/tool/AGENTS.md | 16 +- packages/core/src/tool/mcp.ts | 33 +-- packages/core/test/lib/tool.ts | 6 +- packages/core/test/mcp.test.ts | 104 +++++++- packages/core/test/plugin/host.ts | 1 + packages/core/test/plugin/plan.test.ts | 1 + packages/core/test/plugin/promise.test.ts | 60 +++++ .../test/session-runner-tool-registry.test.ts | 124 ++++++++- packages/plugin/src/effect/tool.ts | 3 +- packages/plugin/src/promise/adapter.ts | 1 + packages/plugin/src/promise/tool.ts | 1 + .../src/docs/content/build/plugins/effect.mdx | 16 ++ .../src/docs/content/build/plugins/index.mdx | 25 +- 15 files changed, 455 insertions(+), 183 deletions(-) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 7ad0e4bf625..b0db5e7c50c 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -358,14 +358,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p hook: (name, callback) => hooks.register("shell", name, callback), }, tool: { - transform: (callback) => - tools - .transform((draft) => - callback({ - add: (tool) => draft.add(tool), - }), - ) - .pipe(Effect.as({ dispose: Effect.void })), + transform: tools.transform, + reload: tools.reload, hook: (name, callback) => hooks.register("tool", name, callback), }, vcs: { diff --git a/packages/core/src/tool.ts b/packages/core/src/tool.ts index 01aa17d2c4d..88634be3e09 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, Schema, SchemaIssue, Scope, Semaphore } from "effect" +import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import type { Agent } from "./agent.js" import { CodeModeCatalog } from "./codemode/catalog.js" @@ -14,6 +14,7 @@ import { Permission } from "./permission.js" import { PluginHooks } from "./plugin/hooks.js" import { SessionMessage } from "./session/message.js" import { SessionSchema } from "./session/schema.js" +import { State } from "./state.js" import { definition, execute, normalizeContent } from "./tool/runtime.js" import { Wildcard } from "./util/wildcard.js" @@ -22,10 +23,16 @@ export class RegistrationError extends Schema.TaggedError()(" message: Schema.String, }) {} -export interface Interface { - readonly transform: ( - callback: (draft: { readonly add: (tool: Tool.Info) => void }) => void, - ) => Effect.Effect +export interface Draft { + readonly add: (tool: Tool.Info) => void +} + +type Data = { + tools: Map + errors: { tool: Tool.Info; error: RegistrationError }[] +} + +export interface Interface extends State.Transformable { readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect } @@ -79,9 +86,6 @@ const layer = Layer.effect( ] }) - const local = new Map>() - const lock = Semaphore.makeUnsafe(1) - const executeTool = Effect.fn("Tool.execute")(function* ( tool: Tool.Info, name: string, @@ -137,112 +141,84 @@ const layer = Layer.effect( } }) - const transform: Interface["transform"] = Effect.fn("Tool.transform")(function* (callback) { - const tools: Array = [] - yield* Effect.sync(() => callback({ add: (tool) => tools.push(tool) })) - const valid = yield* Effect.filter(normalizedEntries(tools), (entry) => - Effect.gen(function* () { - if (entry.tool.options?.namespace !== undefined) yield* validateNamespace(entry.tool.options.namespace) - yield* validateName(normalizedName(entry.tool)) - if (entry.tool.options?.codemode === false && entry.key === "execute") - return yield* new RegistrationError({ - name: entry.key, - message: 'Tool name "execute" is reserved for CodeMode', - }) - yield* Effect.try({ - try: () => ToolDefinition.make(definition(entry.tool)), - catch: (error) => - new RegistrationError({ - name: entry.key, - message: `Invalid tool definition ${entry.key}: ${schemaMakeError(error)}`, - }), - }) - return true - }).pipe(Effect.catchTag("Tool.RegistrationError", (error) => skipRegistration(entry.tool, error))), - ) - // Reject every ambiguous entry rather than choosing a winner. - const entries = yield* Effect.filter(valid, (entry) => { - if (!valid.some((candidate) => candidate !== entry && candidate.key === entry.key)) return Effect.succeed(true) - return skipRegistration( - entry.tool, - new RegistrationError({ name: entry.key, message: `Duplicate normalized tool name: ${entry.key}` }), - ) - }) - if (entries.length === 0) return - yield* Effect.uninterruptible( - lock.withPermit( - Effect.gen(function* () { - const token = {} - for (const entry of entries) - local.set(entry.key, [...(local.get(entry.key) ?? []), { token, tool: entry.tool }]) - yield* Effect.addFinalizer(() => - lock.withPermit( - Effect.sync(() => { - for (const entry of entries) { - const remaining = local.get(entry.key)?.filter((item) => item.token !== token) ?? [] - if (remaining.length > 0) local.set(entry.key, remaining) - else local.delete(entry.key) - } - }), - ), - ) - }), + const state: State.Interface = State.create({ + name: "tool", + initial: () => ({ + tools: new Map(), + errors: [], + }), + draft: (draft) => ({ + add: (tool) => { + const error = registrationError(tool) + if (error) { + draft.errors.push({ tool, error }) + return + } + draft.tools.set(effectiveName(tool), { ...tool, options: tool.options && { ...tool.options } }) + }, + }), + finalize: () => + Effect.forEach( + state.get().errors, + ({ tool, error }) => + Effect.logError("Skipping invalid tool registration", { + name: tool.name, + namespace: tool.options?.namespace, + error: error.message, + }), + { discard: true }, ), - ) }) return Service.of({ - transform, + transform: state.transform, + reload: state.reload, snapshot: Effect.fn("Tool.snapshot")((permissions) => - lock.withPermit( - Effect.gen(function* () { - const active = new Map() - const rules = permissions ?? [] - for (const [name, entries] of local) { - const tool = entries.at(-1)?.tool - if (!tool) continue - if (whollyDisabled(tool.options?.permission ?? name, rules)) continue - active.set(name, tool) - } - const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false)) - const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false)) - const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action)) - const codemodeEnabled = executeRule?.resource !== "*" || executeRule.effect !== "deny" - const codemodeTool = codemodeEnabled - ? CodeModeTool.create(codemode, (name, tool, input, context) => executeTool(tool, name, input, context)) - : undefined - const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined - return { - ...(codeModeCatalog === undefined ? {} : { codeModeCatalog }), - definitions: [ - ...Array.from(direct) - .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) - .map(([, tool]) => definition(tool)), - ...(codemodeTool ? [definition(codemodeTool)] : []), - ], - execute: (input: { - readonly sessionID: SessionSchema.ID - readonly agent: Agent.ID - readonly messageID: SessionMessage.ID - readonly call: ToolCall - readonly progress?: (update: Tool.Metadata) => Effect.Effect - }) => { - const context: Tool.Context = { - sessionID: input.sessionID, - agent: input.agent, - messageID: input.messageID, - id: Tool.CallID.make(input.call.id), - progress: input.progress ?? (() => Effect.void), - } - if (input.call.name === "execute" && codemodeTool) - return executeTool(codemodeTool, input.call.name, input.call.input, context) - const tool = direct.get(input.call.name) - if (tool) return executeTool(tool, input.call.name, input.call.input, context) - return new Tool.Error({ message: `Unknown tool: ${input.call.name}` }) - }, - } - }), - ), + Effect.sync(() => { + const active = new Map() + const rules = permissions ?? [] + for (const [name, tool] of state.get().tools) { + if (whollyDisabled(tool.options?.permission ?? name, rules)) continue + active.set(name, tool) + } + const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false)) + const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false)) + const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action)) + const codemodeEnabled = executeRule?.resource !== "*" || executeRule.effect !== "deny" + const codemodeTool = codemodeEnabled + ? CodeModeTool.create(codemode, (name, tool, input, context) => executeTool(tool, name, input, context)) + : undefined + const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined + return { + ...(codeModeCatalog === undefined ? {} : { codeModeCatalog }), + definitions: [ + ...Array.from(direct) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([, tool]) => definition(tool)), + ...(codemodeTool ? [definition(codemodeTool)] : []), + ], + execute: (input: { + readonly sessionID: SessionSchema.ID + readonly agent: Agent.ID + readonly messageID: SessionMessage.ID + readonly call: ToolCall + readonly progress?: (update: Tool.Metadata) => Effect.Effect + }) => { + const context: Tool.Context = { + sessionID: input.sessionID, + agent: input.agent, + messageID: input.messageID, + id: Tool.CallID.make(input.call.id), + progress: input.progress ?? (() => Effect.void), + } + if (input.call.name === "execute" && codemodeTool) + return executeTool(codemodeTool, input.call.name, input.call.input, context) + const tool = direct.get(input.call.name) + if (tool) return executeTool(tool, input.call.name, input.call.input, context) + return new Tool.Error({ message: `Unknown tool: ${input.call.name}` }) + }, + } + }), ), }) }), @@ -260,27 +236,22 @@ function schemaMakeError(error: unknown) { return error instanceof Error ? error.message : String(error) } -const skipRegistration = (tool: Tool.Info, error: RegistrationError) => - Effect.logError("Skipping invalid tool registration", { - name: tool.name, - namespace: tool.options?.namespace, - error: error.message, - }).pipe(Effect.as(false)) - -const validateName = (name: string) => - /^[A-Za-z0-9_-]{1,64}$/.test(name) - ? Effect.void - : Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` })) - -const validateNamespace = (namespace: string) => - namespace.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment)) - ? Effect.void - : Effect.fail( - new RegistrationError({ - name: namespace, - message: `Invalid tool namespace: ${JSON.stringify(namespace)}`, - }), - ) +function registrationError(tool: Tool.Info) { + const namespace = tool.options?.namespace + if (namespace !== undefined && !namespace.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment))) + return new RegistrationError({ name: namespace, message: `Invalid tool namespace: ${JSON.stringify(namespace)}` }) + const name = normalizedName(tool) + if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) return new RegistrationError({ name, message: `Invalid tool name: ${name}` }) + const id = effectiveName(tool) + if (tool.options?.codemode === false && id === "execute") + return new RegistrationError({ name: id, message: 'Tool name "execute" is reserved for CodeMode' }) + const result = Result.try({ + try: () => ToolDefinition.make(definition(tool)), + catch: (error) => + new RegistrationError({ name: id, message: `Invalid tool definition ${id}: ${schemaMakeError(error)}` }), + }) + return Result.isFailure(result) ? result.failure : undefined +} const normalizedName = (tool: Tool.Info) => tool.name.replace(/[^a-zA-Z0-9_-]/g, "_") @@ -289,12 +260,6 @@ const effectiveName = (tool: Tool.Info) => ? normalizedName(tool) : `${tool.options.namespace.replaceAll(".", "_")}_${normalizedName(tool)}` -const normalizedEntries = (tools: ReadonlyArray) => - tools.map((tool) => ({ - key: effectiveName(tool), - tool, - })) - export const node = makeLocationNode({ service: Service, layer, diff --git a/packages/core/src/tool/AGENTS.md b/packages/core/src/tool/AGENTS.md index 3b76ddad800..2c9c9263274 100644 --- a/packages/core/src/tool/AGENTS.md +++ b/packages/core/src/tool/AGENTS.md @@ -30,17 +30,19 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe ## Registration -Built-ins, plugins, and MCP install tools through `ToolRegistry.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). +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). -Registrations are scoped: +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 active same-placement registration wins. -- Closing any registration removes only that registration and reveals the next active one. -- Each model request captures the effective tools it advertises; later registration changes affect later requests. +- The latest valid active registration for the same effective name wins. +- 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. + +MCP owns one stable tool transform that reads its latest discovered tools. Tool-list changes update that source and reload the tool state instead of re-registering at the end of the transform order. MCP refresh therefore preserves the precedence of later plugin overrides. Type safety ends at registration. The registry validates model input and declared output at runtime and should not carry producer schema generics through storage or execution. -`ToolRegistry.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location. +`Tool.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location. ## Permissions @@ -56,4 +58,4 @@ Producer capture limits remain local to producers. For example, Bash keeps `AppP ## Current Gaps -- MCP and future Session-scoped registrations still need an explicit canonical registration design. +- Future Session-scoped registrations still need an explicit canonical registration design. diff --git a/packages/core/src/tool/mcp.ts b/packages/core/src/tool/mcp.ts index fed8bff9ac2..9d9282c0d65 100644 --- a/packages/core/src/tool/mcp.ts +++ b/packages/core/src/tool/mcp.ts @@ -2,7 +2,7 @@ export * as McpTool from "./mcp.js" import { ToolFailure } from "@opencode-ai/ai" import { McpEvent } from "@opencode-ai/schema/mcp-event" -import { Context, Effect, Exit, Fiber, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect" +import { Context, Effect, Fiber, type JsonSchema, Layer, Semaphore, Stream } from "effect" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { Bus } from "../bus.js" @@ -30,18 +30,15 @@ export const layer = Layer.effect( const tools = yield* Tool.Service const bus = yield* Bus.Service const permission = yield* Permission.Service - const scope = yield* Scope.Scope const lock = Semaphore.makeUnsafe(1) - let current: Scope.Closeable | undefined + let discovered: MCP.Tool[] = [] - // Register the current tool set under a fresh child scope, then close the previous one so the - // registry never has a gap where MCP tools disappear mid-swap. - const reconcile = lock.withPermit( - Effect.gen(function* () { - const discovered = yield* mcp.tools() - const next = yield* Scope.fork(scope) - yield* tools - .transform((draft) => { + // Register once after initial discovery; only subsequent updates need a debounced reload. + const initial = yield* lock + .withPermit( + Effect.gen(function* () { + discovered = yield* mcp.tools() + yield* tools.transform((draft) => { for (const tool of discovered) { const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema draft.add({ @@ -115,15 +112,19 @@ export const layer = Layer.effect( }) } }) - .pipe(Scope.provide(next)) - if (current) yield* Scope.close(current, Exit.void) - current = next + }), + ) + .pipe(Effect.forkScoped) + const reconcile = lock.withPermit( + Effect.gen(function* () { + discovered = yield* mcp.tools() + yield* tools.reload() }), ) - const initial = yield* reconcile.pipe(Effect.forkScoped) yield* bus.subscribe(McpEvent.ToolsChanged).pipe( - Stream.runForEach(() => reconcile), + // Each read loads the whole catalog, so queued notifications need only one refresh. + Stream.runForEachArray(() => reconcile), Effect.forkScoped({ startImmediately: true }), ) return Service.of({ flush: Effect.asVoid(Fiber.await(initial)) }) diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index 2862253efdb..09f977f0ca2 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -64,10 +64,8 @@ export const registerToolPlugin = ( hook: () => Effect.succeed({ dispose: Effect.void }), }, tool: { - transform: (callback) => - tools - .transform((draft) => callback({ add: (tool) => draft.add(tool) })) - .pipe(Effect.orDie, Effect.as({ dispose: Effect.void })), + transform: tools.transform, + reload: tools.reload, hook: () => Effect.die("registerToolPlugin does not support tool hooks"), }, }) diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 2e14ee9685b..970aeff119e 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -36,6 +36,7 @@ import { Session } from "@opencode-ai/core/session" import { McpTool } from "@opencode-ai/core/tool/mcp" import { Tool } from "@opencode-ai/core/tool" import { DateTime, Deferred, Effect, Exit, Fiber, Layer, PubSub, Ref, Schedule, Schema, Sink, Stream } from "effect" +import { TestClock } from "effect/testing" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner" import { Image } from "@opencode-ai/core/image" @@ -1281,12 +1282,13 @@ test("serializes concurrent MCP lifecycle operations", async () => { ) }) -testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updates alive", () => +testEffect(Layer.empty).live("isolates invalid MCP tools and preserves overrides through catalog updates", () => Effect.gen(function* () { - const tool = (server: string, name: string) => + const tool = (server: string, name: string, description = name) => new MCP.Tool({ server: MCP.ServerName.make(server), name, + description, codemode: false, inputSchema: { type: "object", properties: {} }, }) @@ -1304,6 +1306,16 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updat "other_lookup", "execute", ]) + const override = yield* registry.transform((draft) => { + draft.add({ + name: "search", + options: { namespace: "demo", codemode: false }, + description: "Override search", + input: Schema.Struct({}), + output: Schema.String, + execute: () => Effect.succeed({ output: "override" }), + }) + }) yield* Ref.set(catalog, [tool("demo", "y".repeat(65)), ...healthy, tool("demo", "added"), namespace]) yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" }) @@ -1314,15 +1326,32 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updat "other_lookup", "execute", ]) + expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe( + "Override search", + ) yield* Effect.forEach(["demo_search", "other_lookup"], (name) => executeTool(registry, { sessionID: Session.ID.make("ses_mcp_invalid_catalog"), ...toolIdentity, call: { type: "tool-call", id: `call_${name}`, name, input: {} }, - }).pipe(Effect.tap((result) => Effect.sync(() => expect(result).toMatchObject({ status: "completed" })))), + }).pipe( + Effect.tap((result) => + Effect.sync(() => + expect(result).toMatchObject({ + status: "completed", + output: name === "demo_search" ? "override" : "healthy", + }), + ), + ), + ), ) - yield* Ref.set(catalog, [tool("demo", "status"), ...healthy, tool("demo", "added"), tool("repaired", "lookup")]) + yield* Ref.set(catalog, [ + tool("demo", "status"), + tool("other", "lookup"), + tool("demo", "added"), + tool("repaired", "lookup"), + ]) yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" }) yield* waitForTool(registry, "demo_status") expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([ @@ -1333,6 +1362,33 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updat "repaired_lookup", "execute", ]) + expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe( + "Override search", + ) + + yield* Ref.set(catalog, [tool("demo", "search", "Latest search"), tool("demo", "refreshed")]) + yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" }) + yield* waitForTool(registry, "demo_refreshed") + expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe( + "Override search", + ) + + yield* override.dispose + expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([ + "demo_refreshed", + "demo_search", + "execute", + ]) + expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe( + "Latest search", + ) + expect( + yield* executeTool(registry, { + sessionID: Session.ID.make("ses_mcp_invalid_catalog"), + ...toolIdentity, + call: { type: "tool-call", id: "call_restored_search", name: "demo_search", input: {} }, + }), + ).toMatchObject({ status: "completed", output: "healthy" }) }).pipe( Effect.provide( Layer.fresh( @@ -1361,6 +1417,46 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updat }), ) +testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after initial registration", () => { + let reads = 0 + return Effect.gen(function* () { + const registry = yield* Tool.Service + const registration = yield* McpTool.Service + const bus = yield* Bus.Service + yield* registration.flush + expect(reads).toBe(1) + expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["demo_read_1", "execute"]) + + yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" }) + yield* TestClock.adjust("250 millis") + yield* Effect.forEach(Array.from({ length: 20 }), () => bus.publish(McpEvent.ToolsChanged, { server: "demo" })) + yield* TestClock.adjust("2 seconds") + expect(reads).toBe(3) + expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["demo_read_3", "execute"]) + }).pipe( + Effect.provide( + AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [ + [ + MCP.node, + Layer.mock(MCP.Service, { + tools: () => + Effect.sync(() => [ + new MCP.Tool({ + server: MCP.ServerName.make("demo"), + name: `read_${++reads}`, + codemode: false, + inputSchema: { type: "object", properties: {} }, + }), + ]), + }), + ], + [Permission.node, Layer.mock(Permission.Service, { assert: () => Effect.void })], + [Image.node, imagePassthrough], + ]), + ), + ) +}) + it.effect("advertises MCP output schemas to Code Mode", () => Effect.gen(function* () { const registry = yield* Tool.Service diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 87d09400ac9..44004e598ee 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -120,6 +120,7 @@ export function host(overrides: Overrides = {}): Plugin.Context { }, tool: overrides.tool ?? { transform: () => Effect.die("unused tool.transform"), + reload: () => Effect.die("unused tool.reload"), 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 078d72066a4..bae84afa2b2 100644 --- a/packages/core/test/plugin/plan.test.ts +++ b/packages/core/test/plugin/plan.test.ts @@ -72,6 +72,7 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray Effect.die("unused tool.transform"), + reload: () => Effect.die("unused tool.reload"), 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/promise.test.ts b/packages/core/test/plugin/promise.test.ts index e41a986b52f..875e7e64143 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -634,6 +634,66 @@ describe("fromPromise", () => { }), ) + it.live("reloads and disposes Promise tools while preserving older snapshots", () => + Effect.gen(function* () { + const plugins = yield* Plugin.Service + const registry = yield* Tool.Service + const host = yield* PluginHost.make(plugins) + const source = { description: "Original", replays: 0 } + const registrations: Array<{ reload: () => Promise; dispose: () => Promise }> = [] + yield* PluginPromise.fromPromise( + define({ + id: "promise-tool-lifecycle", + setup: async (ctx) => { + expect(Object.keys(ctx.tool).sort()).toEqual(["hook", "reload", "transform"]) + const registration = await ctx.tool.transform((draft) => { + source.replays++ + const description = source.description + draft.add({ + name: "reloadable", + description, + input: Schema.Struct({}), + output: Schema.String, + options: { codemode: false }, + execute: async () => ({ output: description }), + }) + }) + registrations.push({ reload: ctx.tool.reload, dispose: registration.dispose }) + }, + }), + ).effect(host) + const registration = registrations[0] + if (!registration) return yield* Effect.die("Promise tool registration was not captured") + const original = yield* registry.snapshot() + const execute = (snapshot: Tool.Snapshot) => + snapshot.execute({ + sessionID: Session.ID.make("ses_promise_tool_reload"), + agent: Agent.ID.make("build"), + messageID: SessionMessage.ID.make("msg_promise_tool_reload"), + call: { type: "tool-call", id: "call_promise_tool_reload", name: "reloadable", input: {} }, + }) + + source.description = "Reloaded" + yield* Effect.promise(() => registration.reload()) + const reloaded = yield* registry.snapshot() + expect(source.replays).toBe(2) + expect(reloaded.definitions).toContainEqual( + expect.objectContaining({ name: "reloadable", description: "Reloaded" }), + ) + expect(yield* execute(reloaded)).toMatchObject({ output: "Reloaded" }) + expect(yield* execute(original)).toMatchObject({ output: "Original" }) + + yield* Effect.promise(() => registration.dispose()) + yield* Effect.promise(() => registration.dispose()) + expect((yield* registry.snapshot()).definitions.some((tool) => tool.name === "reloadable")).toBe(false) + expect(yield* execute(original)).toMatchObject({ output: "Original" }) + expect(yield* execute(reloaded)).toMatchObject({ output: "Reloaded" }) + yield* Effect.promise(() => registration.reload()) + expect(source.replays).toBe(2) + expect((yield* registry.snapshot()).definitions.some((tool) => tool.name === "reloadable")).toBe(false) + }), + ) + 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 f8fb24fa46d..b651686bcf3 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -6,11 +6,13 @@ import { Image } from "@opencode-ai/core/image" import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { Session } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" +import { State } from "@opencode-ai/core/state" import { Tool } from "@opencode-ai/core/tool" import type { Info } from "@opencode-ai/schema/tool" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { executeTool, toolDefinitions } from "./lib/tool" import { Cause, Deferred, Effect, Exit, Fiber, Layer, Logger, Schema, SchemaGetter, SchemaIssue, Scope } from "effect" +import { TestClock } from "effect/testing" import { z } from "zod" import { testEffect } from "./lib/effect" @@ -71,6 +73,118 @@ const transform = (service: Tool.Interface, tools: Readonly ) describe("Tool", () => { + it.effect("replays empty sources on reload and keeps advertised snapshots", () => + Effect.gen(function* () { + const service = yield* Tool.Service + let source: Info[] = [] + yield* service.transform((draft) => source.forEach((tool) => draft.add(tool))) + expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"]) + + const tool = { ...constant("first"), name: "echo", options: { codemode: false } } + source = [tool] + const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true })) + yield* TestClock.adjust("500 millis") + yield* Fiber.join(first) + const advertised = yield* service.snapshot() + expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" }) + + tool.execute = constant("second").execute + expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" }) + const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true })) + yield* TestClock.adjust("500 millis") + yield* Fiber.join(second) + expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "second" }) + expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" }) + + source = [] + const removed = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true })) + yield* TestClock.adjust("500 millis") + yield* Fiber.join(removed) + expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"]) + expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" }) + }), + ) + + it.effect("disposes overlays once and replays remaining transforms in order", () => + Effect.gen(function* () { + const service = yield* Tool.Service + const runs: string[] = [] + yield* service.transform((draft) => { + runs.push("base") + draft.add({ ...constant("base"), name: "echo", options: { codemode: false } }) + }) + const scope = yield* Scope.make() + const overlay = yield* service + .transform((draft) => { + runs.push("overlay") + draft.add({ ...constant("overlay"), name: "echo", options: { codemode: false } }) + }) + .pipe(Scope.provide(scope)) + expect(runs).toEqual(["base", "base", "overlay"]) + expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "overlay" }) + + yield* overlay.dispose + expect(runs).toEqual(["base", "base", "overlay", "base"]) + expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "base" }) + yield* overlay.dispose + yield* Scope.close(scope, Exit.void) + expect(runs).toEqual(["base", "base", "overlay", "base"]) + }), + ) + + it.effect("batches tool publication and suppresses terminal teardown replay", () => + Effect.gen(function* () { + const service = yield* Tool.Service + const runs: string[] = [] + const scope = yield* Scope.make() + yield* State.batch( + Effect.gen(function* () { + yield* service.transform((draft) => { + runs.push("base") + draft.add({ ...constant("base"), name: "echo", options: { codemode: false } }) + }) + yield* service.transform((draft) => { + runs.push("overlay") + draft.add({ ...constant("overlay"), name: "echo", options: { codemode: false } }) + }) + expect(runs).toEqual([]) + expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"]) + }).pipe(Scope.provide(scope)), + ) + + expect(runs).toEqual(["base", "overlay"]) + expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "overlay" }) + yield* State.batch(Scope.close(scope, Exit.void), { flush: false }) + expect(runs).toEqual(["base", "overlay"]) + }), + ) + + it.effect("uses the last valid addition on replay and restores earlier transforms on disposal", () => + Effect.gen(function* () { + const service = yield* Tool.Service + yield* transform(service, { echo_tool: constant("base") }, { codemode: false }) + let source = [{ ...constant("overlay"), name: "echo.tool", options: { codemode: false } }] + const registration = yield* service.transform((draft) => source.forEach((tool) => draft.add(tool))) + expect((yield* executeTool(service, call("echo_tool"))).output).toEqual({ text: "overlay" }) + + source = [...source, { ...constant("collision"), name: "echo_tool", options: { codemode: false } }] + const collision = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true })) + yield* TestClock.adjust("500 millis") + yield* Fiber.join(collision) + expect((yield* executeTool(service, call("echo_tool"))).output).toEqual({ text: "collision" }) + + yield* registration.dispose + expect((yield* executeTool(service, call("echo_tool"))).output).toEqual({ text: "base" }) + yield* service.transform((draft) => source.forEach((tool) => draft.add(tool))) + + source = [{ ...constant("invalid"), name: "", options: { codemode: false } }] + const invalid = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true })) + yield* TestClock.adjust("500 millis") + yield* Fiber.join(invalid) + expect((yield* executeTool(service, call("echo_tool"))).output).toEqual({ text: "base" }) + }), + ) + it.effect("logs and skips invalid dotted namespaces", () => { const output: unknown[] = [] const logger = Logger.map(Logger.formatStructured, (entry) => { @@ -92,7 +206,7 @@ describe("Tool", () => { }).pipe(Effect.provide(Logger.layer([logger]))) }) - it.effect("skips invalid, reserved, and colliding names without dropping healthy tools", () => + it.effect("skips invalid and reserved names while letting the last normalized name win", () => Effect.gen(function* () { const service = yield* Tool.Service yield* transform( @@ -101,18 +215,18 @@ describe("Tool", () => { before: make(), "": make(), ["x".repeat(65)]: make(), - "echo.tool": make(), - echo_tool: make(), + "echo.tool": constant("first"), + echo_tool: constant("last"), execute: make(), after: make(), }, { codemode: false }, ) const snapshot = yield* service.snapshot() - expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["after", "before", "execute"]) + expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["after", "before", "echo_tool", "execute"]) expect((yield* snapshot.execute(call("before"))).output).toEqual({ text: "before" }) expect((yield* snapshot.execute(call("after"))).output).toEqual({ text: "after" }) - expect((yield* snapshot.execute(call("echo_tool")).pipe(Effect.flip)).message).toBe("Unknown tool: echo_tool") + expect((yield* snapshot.execute(call("echo_tool"))).output).toEqual({ text: "last" }) expect(snapshot.codeModeCatalog).toEqual([]) }), ) diff --git a/packages/plugin/src/effect/tool.ts b/packages/plugin/src/effect/tool.ts index 2750f4d9eeb..a87250a6339 100644 --- a/packages/plugin/src/effect/tool.ts +++ b/packages/plugin/src/effect/tool.ts @@ -2,7 +2,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 { Effect, JsonSchema } from "effect" import type { Hooks, Transform } from "./registration.js" export interface ToolDraft { @@ -48,5 +48,6 @@ export interface ToolFailures extends Record { export interface ToolDomain { readonly transform: Transform + readonly reload: () => Effect.Effect readonly hook: Hooks } diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index 10bae628069..6ad5bf3fca5 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -291,6 +291,7 @@ export function fromPromise(plugin: Plugin) { scan: (options) => run(host.storage.scan(options)), }, tool: { + reload: () => run(host.tool.reload()), transform: (callback) => register( host.tool.transform((draft) => diff --git a/packages/plugin/src/promise/tool.ts b/packages/plugin/src/promise/tool.ts index f34397c12c8..c1464beb464 100644 --- a/packages/plugin/src/promise/tool.ts +++ b/packages/plugin/src/promise/tool.ts @@ -59,5 +59,6 @@ interface ToolHooks { export interface ToolDomain { readonly transform: Transform + readonly reload: () => Promise readonly hook: Hooks } diff --git a/packages/www/src/docs/content/build/plugins/effect.mdx b/packages/www/src/docs/content/build/plugins/effect.mdx index 57ca2f5f534..2bd64985966 100644 --- a/packages/www/src/docs/content/build/plugins/effect.mdx +++ b/packages/www/src/docs/content/build/plugins/effect.mdx @@ -829,6 +829,10 @@ 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 +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. + ```ts effect: (ctx) => Effect.gen(function* () { @@ -850,6 +854,17 @@ 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. + +`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. + +Each model request captures a tool snapshot. Reload and disposal affect future snapshots, not the definitions or +executors already captured by an existing request. Executors that close over mutable plugin data still observe +that data; capture a value inside the transform when it must remain tied to that definition. + Schemas: [`Tool.Content`](/api#schema-Tool.Content), [`Tool.TextContent`](/api#schema-Tool.TextContent), [`Tool.FileContent`](/api#schema-Tool.FileContent). @@ -862,6 +877,7 @@ interface ToolDraft { interface ToolDomain { readonly transform: Transform + readonly reload: () => Effect.Effect } ``` diff --git a/packages/www/src/docs/content/build/plugins/index.mdx b/packages/www/src/docs/content/build/plugins/index.mdx index 80458209837..184322961c6 100644 --- a/packages/www/src/docs/content/build/plugins/index.mdx +++ b/packages/www/src/docs/content/build/plugins/index.mdx @@ -784,10 +784,12 @@ interface StorageScanResult { ### Tools -Register tools with a transform. +Register tools with an add-only 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. ```ts -await ctx.tool.transform((draft) => { +const registration = await ctx.tool.transform((draft) => { draft.add({ name: "greeting", description: "Create a greeting", @@ -806,6 +808,24 @@ await ctx.tool.transform((draft) => { }) ``` +Call `reload()` after changing source data captured by the callback. Reload replays the active transforms without +changing their order; it does not rerun plugin setup. + +```ts +await ctx.tool.reload() +``` + +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. + +```ts +await registration.dispose() +``` + +Each model request captures a tool snapshot. Reload and disposal affect future snapshots, not the definitions or +executors already captured by an existing request. Executors that close over mutable plugin data still observe +that data; capture a value inside the transform when it must remain tied to that definition. + #### Reference Schemas: [`Tool.Content`](/api#schema-Tool.Content), [`Tool.TextContent`](/api#schema-Tool.TextContent), @@ -814,6 +834,7 @@ Schemas: [`Tool.Content`](/api#schema-Tool.Content), [`Tool.TextContent`](/api#s ```ts interface ToolContext { transform(callback: (draft: ToolDraft) => void): Promise + reload(): Promise } interface ToolDraft {