diff --git a/packages/core/src/codemode/tool.ts b/packages/core/src/codemode/tool.ts index 224d097a2af..9808b2ed4c5 100644 --- a/packages/core/src/codemode/tool.ts +++ b/packages/core/src/codemode/tool.ts @@ -1,7 +1,15 @@ export * as CodeModeTool from "./tool.js" -import { CodeMode, Tool, toolError } from "@opencode-ai/codemode" -import type { Content, Context, Error, Info, Metadata, Result } from "@opencode-ai/schema/tool" +import { CodeMode, Namespace, Tool, toolError } from "@opencode-ai/codemode" +import type { + Content, + Context, + Error, + Info, + Metadata, + Namespace as ToolNamespace, + Result, +} from "@opencode-ai/schema/tool" import { Effect, Ref, Schema, Semaphore } from "effect" import { definition, normalizedName } from "../tool/runtime.js" @@ -44,6 +52,7 @@ const description = [ export const create = ( registrations: ReadonlyMap, executeTool: (name: string, tool: Info, input: unknown, context: Context) => Effect.Effect, + namespaces: ReadonlyMap = new Map(), ) => { return { name: "execute", @@ -62,6 +71,7 @@ export const create = ( ) const result = yield* runtime( registrations, + namespaces, (name, tool, input) => Effect.gen(function* () { const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1) @@ -132,23 +142,27 @@ export const create = ( } satisfies Info } -export const catalog = (registrations: ReadonlyMap) => { +export const catalog = ( + registrations: ReadonlyMap, + namespaces: ReadonlyMap = new Map(), +) => { const pinned = new Set( Array.from(registrations.values()) .filter((registration) => registration.options?.pinned === true) .map(qualifiedName), ) - return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))) + return runtime(registrations, namespaces, () => Effect.fail(toolError("Execute context is unavailable"))) .catalog() .map((entry) => ({ ...entry, pinned: pinned.has(entry.path) })) } function runtime( registrations: ReadonlyMap, + namespaces: ReadonlyMap, executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect, hooks?: CodeMode.ToolCallHooks, ) { - const tools: Record> = {} + const tools: Record | Namespace.Namespace> = {} for (const [name, registration] of registrations) { const child = definition(registration) const path = qualifiedName(registration) @@ -159,6 +173,9 @@ function runtime( execute: (input) => executeTool(name, registration, input), }) } + for (const namespace of namespaces.values()) + if (!Object.hasOwn(tools, namespace.name)) + tools[namespace.name] = Namespace.make({ description: namespace.description, tools: {} }) return CodeMode.make({ tools, ...hooks }) } diff --git a/packages/core/src/tool.ts b/packages/core/src/tool.ts index d1dc8c581fd..84084f1fbcd 100644 --- a/packages/core/src/tool.ts +++ b/packages/core/src/tool.ts @@ -1,6 +1,6 @@ export * as Tool from "./tool.js" export { CallID, Content, Error, FileContent, TextContent } from "@opencode-ai/schema/tool" -export type { Context, Metadata, Options, Result } from "@opencode-ai/schema/tool" +export type { Context, Metadata, Namespace, Options, Result } from "@opencode-ai/schema/tool" import { ToolDefinition, type ToolCall } from "@opencode-ai/ai" import { Tool } from "@opencode-ai/schema/tool" @@ -26,6 +26,7 @@ export class RegistrationError extends Schema.TaggedError()(" export interface Draft { readonly list: () => readonly (Tool.Info & { readonly id: string })[] readonly get: (id: string) => (Tool.Info & { readonly id: string }) | undefined + readonly namespace: (namespace: Tool.Namespace) => void readonly add: (tool: Tool.Info) => void readonly update: (id: string, update: (tool: Types.Mutable) => void) => void readonly remove: (id: string) => void @@ -33,7 +34,8 @@ export interface Draft { type Data = { tools: Map - errors: { tool: Tool.Info; error: RegistrationError }[] + namespaces: Map + errors: { kind: "tool" | "namespace"; name: string; namespace?: string; error: RegistrationError }[] } export interface Interface extends State.Transformable { @@ -151,15 +153,24 @@ const layer = Layer.effect( name: "tool", initial: () => ({ tools: new Map(), + namespaces: new Map(), errors: [], }), draft: (draft) => ({ list: () => Array.from(draft.tools.values()), get: (id) => draft.tools.get(id), + namespace: (namespace) => { + const error = namespaceError(namespace.name) + if (error) { + draft.errors.push({ kind: "namespace", name: namespace.name, namespace: namespace.name, error }) + return + } + draft.namespaces.set(namespace.name, { ...namespace }) + }, add: (tool) => { const error = registrationError(tool) if (error) { - draft.errors.push({ tool, error }) + draft.errors.push({ kind: "tool", name: tool.name, namespace: tool.options?.namespace, error }) return } const id = effectiveName(tool) @@ -176,7 +187,7 @@ const layer = Layer.effect( tool.options = { ...tool.options, namespace: current.options?.namespace } const error = registrationError(tool) if (error) { - draft.errors.push({ tool, error }) + draft.errors.push({ kind: "tool", name: tool.name, namespace: tool.options?.namespace, error }) return } draft.tools.set(id, tool) @@ -188,10 +199,10 @@ const layer = Layer.effect( finalize: () => Effect.forEach( state.get().errors, - ({ tool, error }) => - Effect.logError("Skipping invalid tool registration", { - name: tool.name, - namespace: tool.options?.namespace, + ({ kind, name, namespace, error }) => + Effect.logError(`Skipping invalid ${kind} registration`, { + name, + namespace, error: error.message, }), { discard: true }, @@ -213,13 +224,16 @@ const layer = Layer.effect( const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false)) const codemodeEnabled = !whollyDisabled("execute", rules) const codemodeTool = codemodeEnabled - ? CodeModeTool.create(codemode, (name, tool, input, context) => - beforeExecute(name, input, context).pipe( - Effect.flatMap((event) => executeTool(tool, name, event.input, context)), - ), + ? CodeModeTool.create( + codemode, + (name, tool, input, context) => + beforeExecute(name, input, context).pipe( + Effect.flatMap((event) => executeTool(tool, name, event.input, context)), + ), + state.get().namespaces, ) : undefined - const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined + const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode, state.get().namespaces) : undefined return { ...(codeModeCatalog === undefined ? {} : { codeModeCatalog }), definitions: [ @@ -269,8 +283,10 @@ 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)}` }) + if (namespace !== undefined) { + const error = namespaceError(namespace) + if (error) return error + } 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) @@ -284,6 +300,11 @@ function registrationError(tool: Tool.Info) { return Result.isFailure(result) ? result.failure : undefined } +function namespaceError(name: string) { + if (name.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment))) return + return new RegistrationError({ name, message: `Invalid tool namespace: ${JSON.stringify(name)}` }) +} + export const node = makeLocationNode({ service: Service, layer, diff --git a/packages/core/src/tool/AGENTS.md b/packages/core/src/tool/AGENTS.md index d307b95efc8..5b26235e0a4 100644 --- a/packages/core/src/tool/AGENTS.md +++ b/packages/core/src/tool/AGENTS.md @@ -32,6 +32,8 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe 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). +Namespace descriptions are registered once through `draft.namespace(...)`. Tool options continue to reference the namespace by string name; an unregistered namespace remains valid and simply has no namespace description. + 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. diff --git a/packages/core/test/tool-registry.test.ts b/packages/core/test/tool-registry.test.ts index b38826585c1..1a965e73858 100644 --- a/packages/core/test/tool-registry.test.ts +++ b/packages/core/test/tool-registry.test.ts @@ -534,6 +534,7 @@ describe("Tool", () => { Effect.gen(function* () { const service = yield* Tool.Service yield* service.transform((draft) => { + draft.namespace({ name: "invalid..namespace", description: "Invalid" }) draft.add({ ...make(), name: "first", options: { codemode: false } }) draft.add({ ...make(), name: "second", options: { namespace: "invalid..namespace", codemode: false } }) draft.add({ ...make(), name: "second", options: { namespace: "invalid__namespace" } }) @@ -545,6 +546,32 @@ describe("Tool", () => { }), ) + it.effect("registers namespace descriptions separately from namespaced tools", () => + Effect.gen(function* () { + const service = yield* Tool.Service + yield* service.transform((draft) => { + draft.namespace({ name: "registry", description: "Package publishing and discovery" }) + draft.add({ ...make(), name: "plain", options: { namespace: "legacy" } }) + draft.add({ ...make(), name: "direct", options: { namespace: "registry", codemode: false } }) + draft.add({ ...make(), name: "search", description: "Search packages", options: { namespace: "registry" } }) + }) + + 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/plugin/src/effect/index.ts b/packages/plugin/src/effect/index.ts index 2ac5a13e1e8..32987b2a5ac 100644 --- a/packages/plugin/src/effect/index.ts +++ b/packages/plugin/src/effect/index.ts @@ -14,5 +14,6 @@ export { Provider } from "@opencode-ai/schema/provider" export { Reference } from "@opencode-ai/schema/reference" export { Rpc } from "@opencode-ai/schema/rpc" export { Skill } from "@opencode-ai/schema/skill" +export { Tool } from "@opencode-ai/schema/tool" export { Vcs } from "@opencode-ai/schema/vcs" export { WebSearch } from "@opencode-ai/schema/websearch" diff --git a/packages/plugin/src/effect/tool.ts b/packages/plugin/src/effect/tool.ts index 9f934ed6ccf..d448fed7389 100644 --- a/packages/plugin/src/effect/tool.ts +++ b/packages/plugin/src/effect/tool.ts @@ -8,6 +8,7 @@ import type { Hooks, Transform } from "./registration.js" export interface ToolDraft { list(): readonly (Tool.Info & { readonly id: string })[] get(id: string): (Tool.Info & { readonly id: string }) | undefined + namespace(namespace: Tool.Namespace): void add, Output extends Tool.ValueSchema | undefined>( tool: Tool.Info, ): void diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index 0eaa50270d9..346e121ddf0 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -465,6 +465,7 @@ export function fromPromise(plugin: Plugin) { const tool = draft.get(id) return tool ? { ...tool, execute: promiseExecutor(tool.execute) } : undefined }, + namespace: draft.namespace, add: (tool: Info) => draft.add({ ...tool, diff --git a/packages/plugin/src/promise/index.ts b/packages/plugin/src/promise/index.ts index 37dbf1e13be..f784d66e756 100644 --- a/packages/plugin/src/promise/index.ts +++ b/packages/plugin/src/promise/index.ts @@ -15,5 +15,6 @@ export { Provider } from "@opencode-ai/schema/provider" export { Reference } from "@opencode-ai/schema/reference" export { Rpc } from "@opencode-ai/schema/rpc" export { Skill } from "@opencode-ai/schema/skill" +export { Tool } from "@opencode-ai/schema/tool" export { Vcs } from "@opencode-ai/schema/vcs" export { WebSearch } from "@opencode-ai/schema/websearch" diff --git a/packages/plugin/src/promise/tool.ts b/packages/plugin/src/promise/tool.ts index 0dbd9000568..12484a6708c 100644 --- a/packages/plugin/src/promise/tool.ts +++ b/packages/plugin/src/promise/tool.ts @@ -25,6 +25,7 @@ export type Info< interface ToolDraft { list(): readonly (Info & { readonly id: string })[] get(id: string): (Info & { readonly id: string }) | undefined + namespace(namespace: Tool.Namespace): void add, Output extends Tool.ValueSchema | undefined>( tool: Info, ): void diff --git a/packages/plugin/test/contract-identity.test.ts b/packages/plugin/test/contract-identity.test.ts index 1b232ab68a8..80ff2a6e39f 100644 --- a/packages/plugin/test/contract-identity.test.ts +++ b/packages/plugin/test/contract-identity.test.ts @@ -13,6 +13,7 @@ import { Provider } from "@opencode-ai/schema/provider" import { Reference } from "@opencode-ai/schema/reference" import { Rpc } from "@opencode-ai/schema/rpc" import { Skill } from "@opencode-ai/schema/skill" +import { Tool } from "@opencode-ai/schema/tool" import { Vcs } from "@opencode-ai/schema/vcs" import { WebSearch } from "@opencode-ai/schema/websearch" @@ -39,6 +40,7 @@ test.each([ expect(entrypoint.Reference).toBe(Reference) expect(entrypoint.Rpc).toBe(Rpc) expect(entrypoint.Skill).toBe(Skill) + expect(entrypoint.Tool).toBe(Tool) expect(entrypoint.Vcs).toBe(Vcs) expect(entrypoint.WebSearch).toBe(WebSearch) expect(Object.keys(entrypoint).sort()).toEqual([ @@ -56,6 +58,7 @@ test.each([ "Reference", "Rpc", "Skill", + "Tool", "Vcs", "WebSearch", ]) diff --git a/packages/schema/src/tool.ts b/packages/schema/src/tool.ts index 7f87dccb9ba..06dec1c0c57 100644 --- a/packages/schema/src/tool.ts +++ b/packages/schema/src/tool.ts @@ -19,6 +19,11 @@ export interface Context { readonly progress: (update: Metadata) => Effect.Effect } +export interface Namespace { + readonly name: string + readonly description: string +} + interface BaseOptions { readonly namespace?: string readonly permission?: string diff --git a/packages/www/src/docs/content/build/plugins/effect.mdx b/packages/www/src/docs/content/build/plugins/effect.mdx index 6b902c3fafd..0315148f408 100644 --- a/packages/www/src/docs/content/build/plugins/effect.mdx +++ b/packages/www/src/docs/content/build/plugins/effect.mdx @@ -838,6 +838,10 @@ effect: (ctx) => Effect.gen(function* () { const tool = ctx.tool yield* tool.transform((draft) => { + draft.namespace({ + name: "acme", + description: "Customer account tools", + }) draft.add({ name: "greeting", description: "Create a greeting", @@ -854,6 +858,9 @@ effect: (ctx) => }), ``` +Register a namespace once to add model-visible search context for every CodeMode tool assigned to it. Tools continue +to reference the namespace by its string name. Namespace registration is optional when no 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. @@ -889,6 +896,7 @@ Schemas: [`Tool.Content`](/api#schema-Tool.Content), [`Tool.TextContent`](/api#s interface ToolDraft { list(): readonly (Tool.Info & { readonly id: string })[] get(id: string): (Tool.Info & { readonly id: string }) | undefined + namespace(namespace: Tool.Namespace): void add, Output extends Tool.ValueSchema | undefined>( tool: Tool.Info, ): void diff --git a/packages/www/src/docs/content/build/plugins/index.mdx b/packages/www/src/docs/content/build/plugins/index.mdx index 7fbf7cce9c2..4e3def3b251 100644 --- a/packages/www/src/docs/content/build/plugins/index.mdx +++ b/packages/www/src/docs/content/build/plugins/index.mdx @@ -799,6 +799,10 @@ For the same effective tool name, a later valid registration overrides an earlie ```ts const registration = await ctx.tool.transform((draft) => { + draft.namespace({ + name: "acme", + description: "Customer account tools", + }) draft.add({ name: "greeting", description: "Create a greeting", @@ -817,6 +821,9 @@ const registration = await ctx.tool.transform((draft) => { }) ``` +Register a namespace once to add model-visible search context for every CodeMode tool assigned to it. Tools continue +to reference the namespace by its string name. Namespace registration is optional when no description is needed. + 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. @@ -868,6 +875,7 @@ interface ToolContext { interface ToolDraft { list(): readonly (ToolInfo & { readonly id: string })[] get(id: string): (ToolInfo & { readonly id: string }) | undefined + namespace(namespace: Tool.Namespace): void add(tool: ToolInfo): void update(id: string, update: (tool: Types.Mutable) => void): void remove(id: string): void