diff --git a/packages/core/src/codemode/tool.ts b/packages/core/src/codemode/tool.ts index 224d097a2af..e3907a8163b 100644 --- a/packages/core/src/codemode/tool.ts +++ b/packages/core/src/codemode/tool.ts @@ -1,9 +1,9 @@ export * as CodeModeTool from "./tool.js" -import { CodeMode, Tool, toolError } from "@opencode-ai/codemode" +import { CodeMode, Namespace, Tool, toolError } from "@opencode-ai/codemode" import type { Content, Context, Error, Info, Metadata, Result } from "@opencode-ai/schema/tool" import { Effect, Ref, Schema, Semaphore } from "effect" -import { definition, normalizedName } from "../tool/runtime.js" +import { definition, namespace, normalizedName } from "../tool/runtime.js" const ExecuteFile = Schema.Struct({ data: Schema.String, @@ -148,7 +148,8 @@ function runtime( executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect, hooks?: CodeMode.ToolCallHooks, ) { - const tools: Record> = {} + const tools: Record | Namespace.Namespace> = {} + const namespaces = new Map() for (const [name, registration] of registrations) { const child = definition(registration) const path = qualifiedName(registration) @@ -158,14 +159,19 @@ function runtime( output: child.outputSchema ?? Schema.NullOr(Schema.String), execute: (input) => executeTool(name, registration, input), }) + const group = namespace(registration) + if (group?.description !== undefined) namespaces.set(group.name, group.description) } + for (const [name, description] of namespaces) + if (!Object.hasOwn(tools, name)) tools[name] = Namespace.make({ description, tools: {} }) return CodeMode.make({ tools, ...hooks }) } function qualifiedName(registration: Info) { const normalized = normalizedName(registration) - if (registration.options?.namespace === undefined) return normalized - return `${registration.options.namespace}.${normalized}` + const group = namespace(registration) + if (group === undefined) return normalized + return `${group.name}.${normalized}` } // Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact. diff --git a/packages/core/src/tool.ts b/packages/core/src/tool.ts index d1dc8c581fd..e9c4d4dac4b 100644 --- a/packages/core/src/tool.ts +++ b/packages/core/src/tool.ts @@ -15,7 +15,7 @@ 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, effectiveName, execute, normalizedName, normalizeContent } from "./tool/runtime.js" +import { definition, effectiveName, execute, namespace, normalizedName, normalizeContent } from "./tool/runtime.js" import { Wildcard } from "./util/wildcard.js" export class RegistrationError extends Schema.TaggedError()("Tool.RegistrationError", { @@ -191,7 +191,7 @@ const layer = Layer.effect( ({ tool, error }) => Effect.logError("Skipping invalid tool registration", { name: tool.name, - namespace: tool.options?.namespace, + namespace: namespace(tool)?.name, error: error.message, }), { discard: true }, @@ -268,9 +268,12 @@ function schemaMakeError(error: unknown) { } 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 group = namespace(tool) + if (group !== undefined && !group.name.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment))) + return new RegistrationError({ + name: group.name, + message: `Invalid tool namespace: ${JSON.stringify(group.name)}`, + }) 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) diff --git a/packages/core/src/tool/runtime.ts b/packages/core/src/tool/runtime.ts index b56d488db03..d8af70174a8 100644 --- a/packages/core/src/tool/runtime.ts +++ b/packages/core/src/tool/runtime.ts @@ -272,7 +272,14 @@ const stringify = (value: unknown) => { export const normalizedName = (tool: Tool.Info) => tool.name.replace(/[^a-zA-Z0-9_-]/g, "_") -export const effectiveName = (tool: Tool.Info) => - tool.options?.namespace === undefined - ? normalizedName(tool) - : `${tool.options.namespace.replaceAll(".", "_")}_${normalizedName(tool)}` +export const namespace = (tool: Tool.Info) => { + const value = tool.options?.namespace + if (value === undefined) return + return typeof value === "string" ? { name: value } : value +} + +export const effectiveName = (tool: Tool.Info) => { + const group = namespace(tool) + if (group === undefined) return normalizedName(tool) + return `${group.name.replaceAll(".", "_")}_${normalizedName(tool)}` +} diff --git a/packages/core/test/tool-registry.test.ts b/packages/core/test/tool-registry.test.ts index b38826585c1..974cc140263 100644 --- a/packages/core/test/tool-registry.test.ts +++ b/packages/core/test/tool-registry.test.ts @@ -536,6 +536,7 @@ describe("Tool", () => { yield* service.transform((draft) => { draft.add({ ...make(), name: "first", options: { codemode: false } }) draft.add({ ...make(), name: "second", options: { namespace: "invalid..namespace", codemode: false } }) + draft.add({ ...make(), name: "third", options: { namespace: { name: "also..invalid" } } }) draft.add({ ...make(), name: "second", options: { namespace: "invalid__namespace" } }) }) @@ -545,6 +546,40 @@ describe("Tool", () => { }), ) + it.effect("supports described namespaces without changing string namespace behavior", () => + Effect.gen(function* () { + const service = yield* Tool.Service + yield* service.transform((draft) => { + draft.add({ ...make(), name: "plain", options: { namespace: "legacy" } }) + draft.add({ + ...make(), + name: "direct", + options: { namespace: { name: "registry", description: "Package tools" }, codemode: false }, + }) + draft.add({ + ...make(), + name: "search", + description: "Search packages", + options: { namespace: { name: "registry", description: "Package publishing and discovery" } }, + }) + }) + + const snapshot = yield* service.snapshot() + expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["registry_direct", "execute"]) + expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["legacy.plain", "registry.search"]) + const result = yield* snapshot.execute({ + ...call("execute"), + call: { + type: "tool-call", + id: "namespace-search", + name: "execute", + input: { code: 'return search({ query: "publishing discovery" })' }, + }, + }) + expect(result.output).toMatchObject({ output: expect.stringContaining("tools.registry.search") }) + }), + ) + it.effect("logs invalid tool definitions without dropping healthy tools", () => { const output: unknown[] = [] const logger = Logger.map(Logger.formatStructured, (entry) => { diff --git a/packages/schema/src/tool.ts b/packages/schema/src/tool.ts index 7f87dccb9ba..d63d2c9c131 100644 --- a/packages/schema/src/tool.ts +++ b/packages/schema/src/tool.ts @@ -20,10 +20,15 @@ export interface Context { } interface BaseOptions { - readonly namespace?: string + readonly namespace?: string | Namespace readonly permission?: string } +export interface Namespace { + readonly name: string + readonly description?: string +} + export type Options = BaseOptions & ( | { diff --git a/packages/www/src/docs/content/build/plugins/effect.mdx b/packages/www/src/docs/content/build/plugins/effect.mdx index 6b902c3fafd..c0ee2dbf93a 100644 --- a/packages/www/src/docs/content/build/plugins/effect.mdx +++ b/packages/www/src/docs/content/build/plugins/effect.mdx @@ -843,7 +843,10 @@ effect: (ctx) => description: "Create a greeting", input: Schema.Struct({ name: Schema.String }), output: Schema.Struct({ greeting: Schema.String }), - options: { namespace: "acme", codemode: true }, + options: { + namespace: { name: "acme", description: "Customer account tools" }, + codemode: true, + }, execute: ({ name }, context) => Effect.gen(function* () { yield* context.progress({ status: "greeting" }) @@ -854,6 +857,9 @@ effect: (ctx) => }), ``` +Use a namespace object to add model-visible search context for its CodeMode tools. A string such as +`namespace: "acme"` remains supported when no namespace description is needed. + 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. diff --git a/packages/www/src/docs/content/build/plugins/index.mdx b/packages/www/src/docs/content/build/plugins/index.mdx index 7fbf7cce9c2..0ef1b575ebd 100644 --- a/packages/www/src/docs/content/build/plugins/index.mdx +++ b/packages/www/src/docs/content/build/plugins/index.mdx @@ -808,7 +808,10 @@ const registration = await ctx.tool.transform((draft) => { required: ["name"], additionalProperties: false, }, - options: { namespace: "acme", codemode: true }, + options: { + namespace: { name: "acme", description: "Customer account tools" }, + codemode: true, + }, execute: async (input, tool) => { await tool.progress({ status: "greeting" }) return { content: `Hello ${(input as { name: string }).name}!` } @@ -824,6 +827,9 @@ changing their order; it does not rerun plugin setup. await ctx.tool.reload() ``` +Use a namespace object to add model-visible search context for its CodeMode tools. A string such as +`namespace: "acme"` remains supported when no namespace description is needed. + Use `list()` and `get()` to inspect tools currently in the draft. Tools have an `id` containing their effective name, and `get()` returns `undefined` when that ID is not present. Use `update` and `remove` with the effective tool name, including its namespace (`acme_greeting` above). Dots in