mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 03:51:21 -04:00
feat(plugin): add tool updates and removal (#45436)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user