diff --git a/packages/core/src/mcp/guidance.ts b/packages/core/src/mcp/guidance.ts index 2e7f34b0474e..d5eae0f3a498 100644 --- a/packages/core/src/mcp/guidance.ts +++ b/packages/core/src/mcp/guidance.ts @@ -17,7 +17,7 @@ type Summary = typeof Summary.Type const entries = (servers: ReadonlyArray) => servers.flatMap((server) => [ ` `, - ` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`, + ` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.namespace(server.server))}]\`.`, ...server.instructions.split("\n").map((line) => ` ${line}`), " ", ]) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 007a53ed70ec..e799a22217c9 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -305,27 +305,13 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }), }, tool: { + // The tool domain is scoped registration, not State, so the host adapts the draft to register calls directly. transform: (callback) => - Effect.gen(function* () { - const registrations: Array<{ - readonly name: string - readonly tool: Tool.AnyTool - readonly options?: Tool.RegisterOptions - }> = [] - yield* Effect.sync(() => - callback({ - add: (name, tool, options) => { - registrations.push({ name, tool, ...(options ? { options } : {}) }) - }, - }), - ) - yield* Effect.forEach( - registrations, - (registration) => tools.register({ [registration.name]: registration.tool }, registration.options), - { discard: true }, - ).pipe(Effect.orDie) - return { dispose: Effect.void } - }), + Effect.forEach( + Tool.fromDraft(callback), + ({ name, tool, options }) => tools.register({ [name]: tool }, options), + { discard: true }, + ).pipe(Effect.orDie, Effect.as({ dispose: Effect.void })), hook: (name, callback) => { if (name === "execute.before") { return toolHooks.hook.before((event) => { diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 75c8b300afdf..7a1dcab839de 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -154,7 +154,11 @@ export function fromPromise(plugin: Plugin) { register( host.tool.transform((draft) => callback({ - add: (tool: AnyTool) => draft.add(tool.name, fromPromiseTool(tool), tool.options), + add: (tool: AnyTool) => + draft.add(tool.name, fromPromiseTool(tool), { + ...(tool.namespace !== undefined ? { namespace: tool.namespace } : {}), + ...(tool.codemode !== undefined ? { codemode: tool.codemode } : {}), + }), }), ), ), diff --git a/packages/core/src/tool/AGENTS.md b/packages/core/src/tool/AGENTS.md index 08c9fabefe2d..4e4a1b91e65c 100644 --- a/packages/core/src/tool/AGENTS.md +++ b/packages/core/src/tool/AGENTS.md @@ -29,8 +29,8 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe ## Registration Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. Registrations may provide a -group, which flattens direct model names to `_`, and default into CodeMode (`codemode` defaults true; -`codemode: false` keeps the tool on the provider's native tool list). +`namespace`, which flattens native model names to `_`, and default into CodeMode (`codemode` defaults +true; `codemode: false` keeps the tool on the provider's native tool list). Registrations are scoped: diff --git a/packages/core/src/tool/execute.ts b/packages/core/src/tool/execute.ts index 89672dbfc6c9..56b4eb5e832e 100644 --- a/packages/core/src/tool/execute.ts +++ b/packages/core/src/tool/execute.ts @@ -39,7 +39,7 @@ type CollectedFiles = { interface Registration { readonly tool: AnyTool readonly name: string - readonly group?: string + readonly namespace?: string } export const create = (registrations: ReadonlyMap) => { @@ -56,19 +56,19 @@ export const create = (registrations: ReadonlyMap) => { output: child.outputSchema, run: (input) => invoke(name, registration, input), }) - if (registration.group === undefined) { + if (registration.namespace === undefined) { const path = registration.name if (Object.hasOwn(tools, path)) throw new TypeError(`CodeMode tool namespace conflict: ${path}`) tools[path] = value continue } const path = registration.name - const namespace = registration.group - const group = tools[namespace] - if (group && Tool.isDefinition(group)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}`) - if (group) { - if (Object.hasOwn(group, path)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}.${path}`) - group[path] = value + const namespace = registration.namespace + const branch = tools[namespace] + if (branch && Tool.isDefinition(branch)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}`) + if (branch) { + if (Object.hasOwn(branch, path)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}.${path}`) + branch[path] = value continue } const entries: Record> = {} diff --git a/packages/core/src/tool/mcp.ts b/packages/core/src/tool/mcp.ts index 8aad746ed627..3562fbc8c371 100644 --- a/packages/core/src/tool/mcp.ts +++ b/packages/core/src/tool/mcp.ts @@ -13,10 +13,10 @@ import { Tools } from "./tools" import { ToolRegistry } from "./registry" /** - * Registry group and permission action names for MCP tools. + * Registry namespace and permission action names for MCP tools. */ -export const group = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_") -export const name = (server: string, tool: string) => `${group(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}` +export const namespace = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_") +export const name = (server: string, tool: string) => `${namespace(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}` export const layer = Layer.effectDiscard( Effect.gen(function* () { @@ -32,11 +32,11 @@ export const layer = Layer.effectDiscard( // registry never has a gap where MCP tools disappear mid-swap. const reconcile = lock.withPermit( Effect.gen(function* () { - const groups = new Map>() + const byServer = new Map>() for (const tool of yield* mcp.tools()) { - const group = groups.get(tool.server) ?? {} + const record = byServer.get(tool.server) ?? {} const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema - group[tool.name] = Tool.withPermission( + record[tool.name] = Tool.withPermission( Tool.make({ description: tool.description ?? "", jsonSchema: { @@ -102,16 +102,12 @@ export const layer = Layer.effectDiscard( }), name(tool.server, tool.name), ) - groups.set(tool.server, group) + byServer.set(tool.server, record) } const next = yield* Scope.fork(scope) - yield* Effect.forEach( - groups, - ([group, record]) => tools.register(record, { group }), - { - discard: true, - }, - ).pipe(Scope.provide(next), Effect.orDie) + yield* Effect.forEach(byServer, ([server, record]) => tools.register(record, { namespace: server }), { + discard: true, + }).pipe(Scope.provide(next), Effect.orDie) if (current) yield* Scope.close(current, Exit.void) current = next }), diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index b1087c782284..b058738016e5 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -54,7 +54,7 @@ const registryLayer = Layer.effect( type Registration = { readonly tool: AnyTool readonly name: string - readonly group?: string + readonly namespace?: string readonly codemode: boolean } const local = new Map>() @@ -129,7 +129,7 @@ const registryLayer = Layer.effect( return Service.of({ register: Effect.fn("ToolRegistry.register")(function* (tools, options) { - const entries = registrationEntries(tools, options?.group) + const entries = registrationEntries(tools, options?.namespace) if (entries.length === 0) return const codemode = options?.codemode ?? true const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute") @@ -148,7 +148,7 @@ const registryLayer = Layer.effect( registration: { tool: entry.tool, name: entry.name, - group: entry.group, + namespace: entry.namespace, codemode, }, }, diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index 10d68c7fa384..0de44d0b7fa2 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -46,24 +46,11 @@ export const registerToolPlugin = (plugin: { const context = host({ tool: { transform: (callback) => - Effect.gen(function* () { - const registrations: Array<{ - readonly name: string - readonly tool: Tool.AnyTool - readonly options?: Tool.RegisterOptions - }> = [] - callback({ - add: (name, tool, options) => { - registrations.push({ name, tool, ...(options ? { options } : {}) }) - }, - }) - yield* Effect.forEach( - registrations, - (registration) => tools.register({ [registration.name]: registration.tool }, registration.options), - { discard: true }, - ).pipe(Effect.orDie) - return { dispose: Effect.void } - }), + Effect.forEach( + Tool.fromDraft(callback), + ({ name, tool, options }) => tools.register({ [name]: tool }, options), + { discard: true }, + ).pipe(Effect.orDie, Effect.as({ dispose: Effect.void })), hook: () => Effect.die("registerToolPlugin does not support tool hooks"), }, }) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 6d9e0aac3dc5..49b2735147b6 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -274,7 +274,7 @@ describe("PluginV2", () => { }), ) - it.effect("groups tool names and routes codemode registrations through execute", () => + it.effect("namespaces tool names and routes codemode registrations through execute", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service const registry = yield* ToolRegistry.Service @@ -286,13 +286,13 @@ describe("PluginV2", () => { execute: () => Effect.succeed({ ok: true }), }) const plugin = EffectPlugin.define({ - id: "grouped-tools", + id: "namespaced-tools", effect: (ctx) => ctx.tool .transform((draft) => { draft.add("plain", tool("Plain"), { codemode: false }) - draft.add("look/up", tool("Lookup"), { group: "context 7", codemode: false }) - draft.add("search", tool("Search"), { group: "context 7" }) + draft.add("look/up", tool("Lookup"), { namespace: "context 7", codemode: false }) + draft.add("search", tool("Search"), { namespace: "context 7" }) }) .pipe(Effect.orDie), }) @@ -307,6 +307,41 @@ describe("PluginV2", () => { }), ) + it.effect("accepts flat Effect draft declarations with namespace", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const registry = yield* ToolRegistry.Service + const plugin = EffectPlugin.define({ + id: "flat-tools", + effect: (ctx) => + ctx.tool + .transform((draft) => { + draft.add({ + name: "send", + namespace: "slack", + description: "Send a Slack message", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ sent: Schema.Boolean }), + execute: () => Effect.succeed({ sent: true }), + }) + draft.add({ + name: "edit", + codemode: false, + description: "Edit a file", + input: Schema.Struct({}), + output: Schema.Struct({ ok: Schema.Boolean }), + execute: () => Effect.succeed({ ok: true }), + }) + }) + .pipe(Effect.orDie), + }) + + yield* plugins.activate([versioned(plugin)]) + + expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toEqual(["edit", "execute"]) + }), + ) + it.effect("fires before/after tool hooks with mutable events around settlement", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index afe72f6b59f7..57d15fb5ed19 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -135,7 +135,7 @@ describe("fromPromise", () => { await ctx.tool.transform((tools) => { tools.add({ name: "hello", - options: { codemode: false }, + codemode: false, description: "Hello", input: Schema.Struct({ name: Schema.String }), output: Schema.String, diff --git a/packages/docs/build/plugins.mdx b/packages/docs/build/plugins.mdx index ad9cff8a3e9e..7ada606cd1ba 100644 --- a/packages/docs/build/plugins.mdx +++ b/packages/docs/build/plugins.mdx @@ -312,12 +312,13 @@ export default Plugin.define({ }) ``` -Unsupported characters in tool and group names are normalized to underscores. +Unsupported characters in tool and namespace names are normalized to underscores. The resulting exposed key must begin with a letter and contain at most 64 -letters, digits, underscores, or hyphens. Set `options` on the declaration to -configure registration with `{ group, codemode }`: +letters, digits, underscores, or hyphens. Set registration fields on the +declaration or options with `{ namespace, codemode }`: -- `group` prefixes and groups the exposed tool name. +- `namespace` prefixes the exposed tool name (and becomes the CodeMode path + segment). - `codemode` defaults to `true` and makes the tool available through the `execute` CodeMode tool. Set `codemode: false` to expose it directly to the provider. diff --git a/packages/plugin/src/v2/effect/tool.ts b/packages/plugin/src/v2/effect/tool.ts index ce53b78bd386..a86c8df6ef0b 100644 --- a/packages/plugin/src/v2/effect/tool.ts +++ b/packages/plugin/src/v2/effect/tool.ts @@ -123,6 +123,17 @@ export function make(config: Config | DynamicConfig): AnyTool { return makeTyped(config) } +/** + * Split a flat tool declaration into its registration name, opaque Tool value, + * and registration options. Narrowing on `jsonSchema` selects the matching + * constructor, so no cast is needed to build the Tool from the flat union. + */ +export function fromFlat(flat: FlatDefinition | FlatDynamicDefinition) { + const { name, namespace, codemode, ...config } = flat + const tool = "jsonSchema" in config ? makeDynamic(config) : makeTyped(config) + return { name, tool, options: { namespace, codemode } satisfies RegisterOptions } +} + function makeTyped< Input extends SchemaType, Output extends SchemaType, @@ -212,14 +223,14 @@ export const validateName = (name: string) => ? Effect.void : Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` })) -export const registrationEntries = (tools: Readonly>, group?: string) => +export const registrationEntries = (tools: Readonly>, namespace?: string) => Object.entries(tools).map(([name, tool]) => { const normalized = name.replace(/[^a-zA-Z0-9_-]/g, "_") - const parent = group?.replace(/[^a-zA-Z0-9_-]/g, "_") + const parent = namespace?.replace(/[^a-zA-Z0-9_-]/g, "_") return { key: parent === undefined ? normalized : `${parent}_${normalized}`, name: normalized, - group: parent, + namespace: parent, tool, } }) @@ -271,15 +282,68 @@ export interface ToolExecuteAfterEvent { } export interface RegisterOptions { - readonly group?: string + /** Dotted CodeMode path prefix, e.g. "slack.admin". */ + readonly namespace?: string /** Defaults to true. False exposes the tool directly to the provider. */ readonly codemode?: boolean } +export type FlatDefinition< + Input extends SchemaType, + Output extends SchemaType, + Structured extends SchemaType = Output, +> = { + readonly name: string + readonly namespace?: string + readonly codemode?: boolean + readonly description: string + readonly input: Input + readonly output: Output + readonly structured?: Structured + readonly toStructuredOutput?: Config["toStructuredOutput"] + readonly execute: Config["execute"] + readonly toModelOutput?: Config["toModelOutput"] +} + +export type FlatDynamicDefinition = { + readonly name: string + readonly namespace?: string + readonly codemode?: boolean + readonly description: string + readonly jsonSchema: JsonSchema.JsonSchema + readonly outputSchema?: JsonSchema.JsonSchema + readonly execute: DynamicConfig["execute"] +} + export interface ToolDraft { + add< + Input extends SchemaType, + Output extends SchemaType, + Structured extends SchemaType = Output, + >(tool: FlatDefinition): void + add(tool: FlatDynamicDefinition): void add(name: string, tool: AnyTool, options?: RegisterOptions): void } +export type Registration = { + readonly name: string + readonly tool: AnyTool + readonly options?: RegisterOptions +} + +/** Run a draft callback and collect the tools it declared, in registration order. */ +export function fromDraft(callback: (draft: ToolDraft) => void) { + const registrations: Array = [] + const add = ( + nameOrTool: string | FlatDefinition | FlatDynamicDefinition, + tool?: AnyTool, + options?: RegisterOptions, + ) => + registrations.push(typeof nameOrTool === "string" ? { name: nameOrTool, tool: tool!, options } : fromFlat(nameOrTool)) + callback({ add }) + return registrations +} + export interface ToolHooks { readonly "execute.before": ToolExecuteBeforeEvent readonly "execute.after": ToolExecuteAfterEvent diff --git a/packages/plugin/src/v2/promise/tool.ts b/packages/plugin/src/v2/promise/tool.ts index 77d936f5800f..62756719cc5c 100644 --- a/packages/plugin/src/v2/promise/tool.ts +++ b/packages/plugin/src/v2/promise/tool.ts @@ -16,7 +16,8 @@ export type Definition< Structured extends SchemaType = Output, > = { readonly name: string - readonly options?: RegisterOptions + readonly namespace?: string + readonly codemode?: boolean readonly description: string readonly input: Input readonly output: Output @@ -37,7 +38,8 @@ export type Definition< export type DynamicDefinition = { readonly name: string - readonly options?: RegisterOptions + readonly namespace?: string + readonly codemode?: boolean readonly description: string readonly jsonSchema: JsonSchema.JsonSchema readonly outputSchema?: JsonSchema.JsonSchema @@ -67,12 +69,6 @@ export interface ToolExecuteAfterEvent { outputPaths?: ReadonlyArray } -export interface RegisterOptions { - readonly group?: string - /** Defaults to true. False exposes the tool directly to the provider. */ - readonly codemode?: boolean -} - export interface ToolDraft { add< Input extends SchemaType,