diff --git a/packages/core/src/codemode/catalog.ts b/packages/core/src/codemode/catalog.ts index a7ae19175a2..f8df413f90c 100644 --- a/packages/core/src/codemode/catalog.ts +++ b/packages/core/src/codemode/catalog.ts @@ -1,22 +1,29 @@ export * as CodeModeCatalog from "./catalog.js" +import type { Namespace } from "@opencode-ai/schema/tool" import { Schema } from "effect" -export const Entry = Schema.Struct({ +export const Tool = Schema.Struct({ path: Schema.String, description: Schema.String, signature: Schema.String, pinned: Schema.optionalKey(Schema.Boolean), }) -export type Entry = typeof Entry.Type +export type Tool = typeof Tool.Type + +export type Inventory = { + readonly tools: ReadonlyArray + readonly namespaces?: ReadonlyMap +} const Listing = Schema.Struct({ path: Schema.String, line: Schema.String, }) -const Namespace = Schema.Struct({ +const NamespaceSummary = Schema.Struct({ name: Schema.String, + description: Schema.optionalKey(Schema.String), count: Schema.Number, entries: Schema.Array(Listing), }) @@ -24,24 +31,30 @@ const Namespace = Schema.Struct({ export const Summary = Schema.Struct({ total: Schema.Number, shown: Schema.Number, - namespaces: Schema.Array(Namespace), + namespaces: Schema.Array(NamespaceSummary), }) export type Summary = typeof Summary.Type +export type Options = { + readonly budget?: number +} + const DESCRIPTION_LIMIT = 120 const CHARACTERS_PER_TOKEN = 4 const INLINE_BUDGET = 2_000 -// Keep every namespace searchable, then select full listings one per namespace per round, +// Keep every namespace visible, then select full listings one per namespace per round, // considering shorter listings first until the inline budget is exhausted. -export function summarize(entries: ReadonlyArray, budget = INLINE_BUDGET): Summary { - const namespaces = [...Map.groupBy(entries, (entry) => entry.path.split(".", 1)[0] ?? entry.path)] +export function summarize(inventory: Inventory, options: Options = {}): Summary { + const budget = options.budget ?? INLINE_BUDGET + const namespaces = [...Map.groupBy(inventory.tools, (tool) => tool.path.split(".", 1)[0] ?? tool.path)] .sort(([left], [right]) => { if (left < right) return -1 if (left > right) return 1 return 0 }) .map(([name, namespaceEntries]) => { + const description = inventory.namespaces?.get(name)?.description const listings = namespaceEntries .map((entry) => { const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? "" @@ -64,6 +77,7 @@ export function summarize(entries: ReadonlyArray, budget = INLINE_BUDGET) ) return { name, + ...(description === undefined ? {} : { description }), listings, selectionOrder: ranked.filter((candidate) => !pinned.has(candidate.listing)), selectedListings: pinned, @@ -72,11 +86,25 @@ export function summarize(entries: ReadonlyArray, budget = INLINE_BUDGET) }) const active = new Set(namespaces) + // TODO: Bound namespace discovery once large namespace inventories and descriptions can no longer stay inline. let remaining = budget - + namespaces.reduce( + (total, namespace) => + total + + cost( + namespaceLine({ + name: namespace.name, + ...(namespace.description === undefined ? {} : { description: namespace.description }), + count: namespace.listings.length, + entries: [], + }), + ), + 0, + ) - namespaces .flatMap((namespace) => namespace.listings.filter((listing) => namespace.selectedListings.has(listing))) - .reduce((total, listing) => total + Math.round(listing.line.length / CHARACTERS_PER_TOKEN), 0) + .reduce((total, listing) => total + cost(listing.line), 0) while (active.size > 0) { for (const namespace of active) { const candidate = namespace.selectionOrder[namespace.selectionIndex] @@ -93,19 +121,31 @@ export function summarize(entries: ReadonlyArray, budget = INLINE_BUDGET) const namespaceSummaries = namespaces.map((namespace) => ({ name: namespace.name, + ...(namespace.description === undefined ? {} : { description: namespace.description }), count: namespace.listings.length, entries: namespace.listings.filter((listing) => namespace.selectedListings.has(listing)), })) return { - total: entries.length, + total: inventory.tools.length, shown: namespaceSummaries.reduce((total, namespace) => total + namespace.entries.length, 0), namespaces: namespaceSummaries, } } +export function namespaceLine(namespace: typeof NamespaceSummary.Type) { + const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools` + const label = + namespace.entries.length === namespace.count + ? count + : namespace.entries.length === 0 + ? `${count}, none shown` + : `${count}, ${namespace.entries.length} shown` + return `- ${namespace.name} (${label})${namespace.description === undefined ? "" : ` // ${namespace.description}`}` +} + function rankListings(listings: ReadonlyArray) { return listings - .map((listing) => ({ listing, cost: Math.round(listing.line.length / CHARACTERS_PER_TOKEN) })) + .map((listing) => ({ listing, cost: cost(listing.line) })) .toSorted((left, right) => { if (left.cost !== right.cost) return left.cost - right.cost if (left.listing.path < right.listing.path) return -1 @@ -113,3 +153,7 @@ function rankListings(listings: ReadonlyArray) { return 0 }) } + +function cost(text: string) { + return Math.round(text.length / CHARACTERS_PER_TOKEN) +} diff --git a/packages/core/src/codemode/instructions.ts b/packages/core/src/codemode/instructions.ts index 15884fa237c..95a28cd4ab8 100644 --- a/packages/core/src/codemode/instructions.ts +++ b/packages/core/src/codemode/instructions.ts @@ -23,14 +23,7 @@ export function render(catalog: CodeModeCatalog.Summary) { return "No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool." const tools = catalog.namespaces.flatMap((namespace) => { - const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools` - const label = - namespace.entries.length === namespace.count - ? count - : namespace.entries.length === 0 - ? `${count}, none shown` - : `${count}, ${namespace.entries.length} shown` - return [`- ${namespace.name} (${label})`, ...namespace.entries.map((entry) => entry.line)] + return [CodeModeCatalog.namespaceLine(namespace), ...namespace.entries.map((entry) => entry.line)] }) return `${prompt(catalog.shown < catalog.total)} @@ -47,6 +40,15 @@ ${render(current)}` const currentComplete = current.shown === current.total if (previousComplete !== currentComplete) return replacement + const descriptions = Instructions.diffByKey( + previous.namespaces.filter((namespace) => namespace.description !== undefined), + current.namespaces.filter((namespace) => namespace.description !== undefined), + (namespace) => namespace.name, + (before, after) => before.description !== after.description, + ) + if (descriptions.added.length > 0 || descriptions.removed.length > 0 || descriptions.changed.length > 0) + return replacement + const diff = Instructions.diffByKey( previous.namespaces.flatMap((namespace) => namespace.entries), current.namespaces.flatMap((namespace) => namespace.entries), @@ -126,8 +128,8 @@ ${render(current)}` const key = Instructions.Key.make("core/codemode") const codec = Schema.toCodecJson(CodeModeCatalog.Summary) -export const make = (entries?: ReadonlyArray): Instructions.List => { - const catalog = entries === undefined ? Instructions.removed : CodeModeCatalog.summarize(entries) +export const make = (inventory?: CodeModeCatalog.Inventory): Instructions.List => { + const catalog = inventory === undefined ? Instructions.removed : CodeModeCatalog.summarize(inventory) return Instructions.make({ key, codec, diff --git a/packages/core/src/codemode/tool.ts b/packages/core/src/codemode/tool.ts index 224d097a2af..f025a7d2e5f 100644 --- a/packages/core/src/codemode/tool.ts +++ b/packages/core/src/codemode/tool.ts @@ -1,9 +1,18 @@ 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" +import { CodeModeCatalog } from "./catalog.js" const ExecuteFile = Schema.Struct({ data: Schema.String, @@ -31,6 +40,21 @@ type CollectedFiles = { readonly files: Array } +type ToolNode = { + tool?: Tool.Tool + namespace?: ToolNamespace + readonly children: Map +} + +type Tools = { + [name: string]: Tool.Tool | Namespace.Namespace | Tools +} + +export type Inventory = { + readonly tools: ReadonlyMap + readonly namespaces?: ReadonlyMap +} + // Invariant model-facing guidance; the changing tool catalog is delivered through Instructions. const description = [ "Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.", @@ -42,7 +66,7 @@ const description = [ ].join("\n") export const create = ( - registrations: ReadonlyMap, + inventory: Inventory, executeTool: (name: string, tool: Info, input: unknown, context: Context) => Effect.Effect, ) => { return { @@ -61,7 +85,7 @@ export const create = ( Ref.updateAndGet(calls, update).pipe(Effect.flatMap((toolCalls) => context.progress({ toolCalls }))), ) const result = yield* runtime( - registrations, + inventory, (name, tool, input) => Effect.gen(function* () { const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1) @@ -132,36 +156,95 @@ export const create = ( } satisfies Info } -export const catalog = (registrations: ReadonlyMap) => { +export const catalog = (inventory: Inventory) => { const pinned = new Set( - Array.from(registrations.values()) + Array.from(inventory.tools.values()) .filter((registration) => registration.options?.pinned === true) .map(qualifiedName), ) - return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))) - .catalog() - .map((entry) => ({ ...entry, pinned: pinned.has(entry.path) })) + return { + tools: runtime(inventory, () => Effect.fail(toolError("Execute context is unavailable"))) + .catalog() + .map((tool) => ({ ...tool, pinned: pinned.has(tool.path) })), + ...(inventory.namespaces === undefined ? {} : { namespaces: inventory.namespaces }), + } satisfies CodeModeCatalog.Inventory } function runtime( - registrations: ReadonlyMap, + inventory: Inventory, executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect, hooks?: CodeMode.ToolCallHooks, ) { - const tools: Record> = {} - for (const [name, registration] of registrations) { + // A path may carry namespace metadata, a callable tool, child tools, or all three. + const root: ToolNode = { children: new Map() } + for (const namespace of inventory.namespaces?.values() ?? []) getNode(root, namespace.name).namespace = namespace + for (const [name, registration] of inventory.tools) { const child = definition(registration) - const path = qualifiedName(registration) - tools[path] = Tool.make({ + getNode(root, qualifiedName(registration)).tool = Tool.make({ description: child.description, input: child.inputSchema, output: child.outputSchema ?? Schema.NullOr(Schema.String), execute: (input) => executeTool(name, registration, input), }) } + const tools = renderTools(root) return CodeMode.make({ tools, ...hooks }) } +function getNode(root: ToolNode, path: string) { + return path.split(".").reduce((parent, name) => { + const child: ToolNode = parent.children.get(name) ?? { children: new Map() } + parent.children.set(name, child) + return child + }, root) +} + +function renderTools(root: ToolNode) { + const callables = new Map>() + const tools = renderChildren(root, [], callables) + for (const [path, tool] of callables) tools[path] = tool + return tools +} + +function renderChildren(node: ToolNode, path: ReadonlyArray, callables: Map>): Tools { + return Object.fromEntries( + Array.from(node.children).flatMap(([name, child]) => { + const next = [...path, name] + // A record cannot hold both a top-level tool and namespace under the same key. + if (path.length === 0 && child.tool !== undefined && (child.namespace !== undefined || child.children.size > 0)) { + const tools: Tools = {} + flattenTools(child, next, tools) + return Object.entries(tools) + } + return [[name, renderEntry(child, next, callables)]] + }), + ) +} + +function renderEntry( + node: ToolNode, + path: ReadonlyArray, + callables: Map>, +): Tools[string] { + const tools = renderChildren(node, path, callables) + // CodeMode merges this dotted tool path with the nested namespace entry. + if (node.tool !== undefined && (node.namespace !== undefined || node.children.size > 0)) + callables.set(path.join("."), node.tool) + if (node.namespace !== undefined) + return Namespace.make({ + description: node.namespace.description, + tools, + }) + if (node.tool === undefined) return tools + if (node.children.size === 0) return node.tool + return tools +} + +function flattenTools(node: ToolNode, path: ReadonlyArray, tools: Tools) { + if (node.tool !== undefined) tools[path.join(".")] = node.tool + for (const [name, child] of node.children) flattenTools(child, [...path, name], tools) +} + function qualifiedName(registration: Info) { const normalized = normalizedName(registration) if (registration.options?.namespace === undefined) return normalized diff --git a/packages/core/src/tool.ts b/packages/core/src/tool.ts index d1dc8c581fd..95529df026e 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 { @@ -42,7 +44,7 @@ export interface Interface extends State.Transformable { export interface Snapshot { readonly definitions: ReadonlyArray - readonly codeModeCatalog?: ReadonlyArray + readonly codeModeCatalog?: CodeModeCatalog.Inventory readonly execute: (input: { readonly sessionID: SessionSchema.ID readonly agent: Agent.ID @@ -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 }, @@ -210,23 +221,25 @@ const layer = Layer.effect( 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 codemodeEnabled = !whollyDisabled("execute", rules) - const codemodeTool = codemodeEnabled - ? CodeModeTool.create(codemode, (name, tool, input, context) => + const codeModeTools = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false)) + const namespaces = state.get().namespaces + const codeModeInventory = { tools: codeModeTools, namespaces } + const codeModeEnabled = !whollyDisabled("execute", rules) + const codeModeTool = codeModeEnabled + ? CodeModeTool.create(codeModeInventory, (name, tool, input, context) => beforeExecute(name, input, context).pipe( Effect.flatMap((event) => executeTool(tool, name, event.input, context)), ), ) : undefined - const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined + const codeModeCatalog = codeModeEnabled ? CodeModeTool.catalog(codeModeInventory) : 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)] : []), + ...(codeModeTool ? [definition(codeModeTool)] : []), ], execute: Effect.fnUntraced(function* (input: Parameters[0]) { const context: Tool.Context = { @@ -239,11 +252,11 @@ const layer = Layer.effect( const event = yield* beforeExecute(input.call.name, input.call.input, context) const requested = input.definitions?.get(event.tool) // Preserve session context removal and alias resolution, now after the repair hook. - if (!requested && input.definitions && (direct.has(event.tool) || codemodeTool?.name === event.tool)) + if (!requested && input.definitions && (direct.has(event.tool) || codeModeTool?.name === event.tool)) return yield* new Tool.Error({ message: `Tool is not available for this request: ${event.tool}` }) const name = requested?.name ?? event.tool - if (name === "execute" && codemodeTool) - return yield* executeTool(codemodeTool, name, event.input, context) + if (name === "execute" && codeModeTool) + return yield* executeTool(codeModeTool, name, event.input, context) const tool = direct.get(name) if (tool) return yield* executeTool(tool, name, event.input, context) return yield* new Tool.Error({ message: `Unknown tool: ${name}` }) @@ -269,8 +282,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 +299,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/codemode.test.ts b/packages/core/test/codemode.test.ts index 9a6d9fd9568..cbaa4e33d3f 100644 --- a/packages/core/test/codemode.test.ts +++ b/packages/core/test/codemode.test.ts @@ -23,14 +23,17 @@ describe("CodeMode", () => { const snapshot = yield* tools.snapshot() expect(snapshot.definitions.some((tool) => tool.name === "execute")).toBe(true) - expect(snapshot.codeModeCatalog).toStrictEqual([ - { - path: "echo", - description: "Echo text", - signature: "tools.echo(input: {\n text: string,\n}): Promise", - pinned: true, - }, - ]) + expect(snapshot.codeModeCatalog).toStrictEqual({ + tools: [ + { + path: "echo", + description: "Echo text", + signature: "tools.echo(input: {\n text: string,\n}): Promise", + pinned: true, + }, + ], + namespaces: new Map(), + }) }).pipe( Effect.scoped, Effect.provide( diff --git a/packages/core/test/codemode/catalog.test.ts b/packages/core/test/codemode/catalog.test.ts index 378476580d0..a4ec34c28d1 100644 --- a/packages/core/test/codemode/catalog.test.ts +++ b/packages/core/test/codemode/catalog.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog" import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions" -const entry = (path: string, description: string, signature?: string, pinned = false): CodeModeCatalog.Entry => ({ +const entry = (path: string, description: string, signature?: string, pinned = false): CodeModeCatalog.Tool => ({ path, description, signature: signature ?? `tools.${path}(input: {\n q: string,\n}): Promise`, @@ -15,21 +15,24 @@ const lookup = entry( "tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>", ) -const render = (entries: ReadonlyArray, budget?: number) => - CodeModeInstructions.render(CodeModeCatalog.summarize(entries, budget)) +const render = (tools: ReadonlyArray, budget?: number) => + CodeModeInstructions.render(CodeModeCatalog.summarize({ tools }, budget === undefined ? {} : { budget })) const update = ( - previous: ReadonlyArray, - current: ReadonlyArray, + previous: ReadonlyArray, + current: ReadonlyArray, budget?: number, ) => - CodeModeInstructions.update(CodeModeCatalog.summarize(previous, budget), CodeModeCatalog.summarize(current, budget)) + CodeModeInstructions.update( + CodeModeCatalog.summarize({ tools: previous }, budget === undefined ? {} : { budget }), + CodeModeCatalog.summarize({ tools: current }, budget === undefined ? {} : { budget }), + ) describe("CodeModeCatalog.summarize", () => { test("retains namespace inventory without retaining tools outside the inline budget", () => { const catalog = CodeModeCatalog.summarize( - Array.from({ length: 10_000 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`)), - 0, + { tools: Array.from({ length: 10_000 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`)) }, + { budget: 0 }, ) expect(catalog).toEqual({ total: 10_000, @@ -40,8 +43,8 @@ describe("CodeModeCatalog.summarize", () => { test("retains every namespace when no full tool listing fits", () => { const catalog = CodeModeCatalog.summarize( - [entry("alpha.one", "One"), entry("beta.two", "Two"), entry("gamma.three", "Three")], - 0, + { tools: [entry("alpha.one", "One"), entry("beta.two", "Two"), entry("gamma.three", "Three")] }, + { budget: 0 }, ) expect(catalog.namespaces.map((namespace) => namespace.name)).toEqual(["alpha", "beta", "gamma"]) expect(catalog.namespaces.every((namespace) => namespace.entries.length === 0)).toBe(true) @@ -49,7 +52,10 @@ describe("CodeModeCatalog.summarize", () => { test("always retains pinned tools beyond the inline budget", () => { const pinned = [entry("alpha.first", "First", undefined, true), entry("beta.second", "Second", undefined, true)] - const catalog = CodeModeCatalog.summarize([...pinned, entry("alpha.unpinned", "Unpinned")], 0) + const catalog = CodeModeCatalog.summarize( + { tools: [...pinned, entry("alpha.unpinned", "Unpinned")] }, + { budget: 0 }, + ) expect(catalog.shown).toBe(2) expect(catalog.namespaces.flatMap((namespace) => namespace.entries.map((item) => item.path))).toEqual([ @@ -63,22 +69,48 @@ describe("CodeModeCatalog.summarize", () => { const unpinned = entry("beta.unpinned", "Unpinned") const pinCost = Math.round(` - ${pinned.signature} // Pinned`.length / 4) const unpinnedCost = Math.round(` - ${unpinned.signature} // Unpinned`.length / 4) + const namespaceCost = [ + { name: "alpha", count: 1, entries: [] }, + { name: "beta", count: 1, entries: [] }, + ].reduce((total, namespace) => total + Math.round(CodeModeCatalog.namespaceLine(namespace).length / 4), 0) - expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost).shown).toBe(2) - expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost - 1).shown).toBe(1) + expect( + CodeModeCatalog.summarize({ tools: [pinned, unpinned] }, { budget: namespaceCost + pinCost + unpinnedCost }) + .shown, + ).toBe(2) + expect( + CodeModeCatalog.summarize({ tools: [pinned, unpinned] }, { budget: namespaceCost + pinCost + unpinnedCost - 1 }) + .shown, + ).toBe(1) }) test("retains only the rendered portion of inline descriptions", () => { - const catalog = CodeModeCatalog.summarize([entry("alpha.one", `Summary\n${"detail".repeat(10_000)}`)]) + const catalog = CodeModeCatalog.summarize({ + tools: [entry("alpha.one", `Summary\n${"detail".repeat(10_000)}`)], + }) expect(catalog.namespaces[0]?.entries[0]?.line).toEndWith("// Summary") }) test("limits inline descriptions to 120 characters", () => { - const catalog = CodeModeCatalog.summarize([entry("alpha.one", "x".repeat(121))]) + const catalog = CodeModeCatalog.summarize({ tools: [entry("alpha.one", "x".repeat(121))] }) const description = catalog.namespaces[0]?.entries[0]?.line.split(" // ")[1] expect(description).toHaveLength(120) expect(description).toEndWith("...") }) + + test("always retains namespace descriptions and charges them before tool listings", () => { + const tool = entry("alpha.one", "One") + const listingCost = Math.round(` - ${tool.signature} // One`.length / 4) + const namespaceCost = Math.round(CodeModeCatalog.namespaceLine({ name: "alpha", count: 1, entries: [] }).length / 4) + const description = "A namespace description that stays visible beyond the available tool budget" + const namespaces = new Map([["alpha", { name: "alpha", description }]]) + + expect(CodeModeCatalog.summarize({ tools: [tool] }, { budget: namespaceCost + listingCost }).shown).toBe(1) + const catalog = CodeModeCatalog.summarize({ tools: [tool], namespaces }, { budget: namespaceCost + listingCost }) + expect(catalog.shown).toBe(0) + expect(catalog.namespaces[0]?.description).toBe(description) + expect(CodeModeInstructions.render(catalog)).toContain(`- alpha (1 tool, none shown) // ${description}`) + }) }) describe("CodeModeInstructions.render", () => { @@ -119,7 +151,11 @@ describe("CodeModeInstructions.render", () => { ) // Round 1 places alpha.cheap and beta.cheap; in round 2 alpha.expensive does not fit, // which marks only alpha done - it must NOT prevent other namespaces from inlining. - const instructions = render([cheapAlpha, expensive, cheapBeta], 40) + const namespaceCost = [ + { name: "alpha", count: 2, entries: [] }, + { name: "beta", count: 1, entries: [] }, + ].reduce((total, namespace) => total + Math.round(CodeModeCatalog.namespaceLine(namespace).length / 4), 0) + const instructions = render([cheapAlpha, expensive, cheapBeta], 40 + namespaceCost) expect(instructions).toContain("## Search") expect(instructions).toContain("- alpha (2 tools, 1 shown)") expect(instructions).toContain(` - ${cheapAlpha.signature} // Cheap`) @@ -171,6 +207,21 @@ describe("CodeModeInstructions.update", () => { ) }) + test("restates namespace descriptions when they change", () => { + const previous = CodeModeCatalog.summarize({ + tools: [echo], + namespaces: new Map([["notes", { name: "notes", description: "Old description" }]]), + }) + const current = CodeModeCatalog.summarize({ + tools: [echo], + namespaces: new Map([["notes", { name: "notes", description: "New description" }]]), + }) + const text = CodeModeInstructions.update(previous, current) + expect(text).toContain("This catalog supersedes the previous Code Mode tool catalog.") + expect(text).toContain("- notes (1 tool) // New description") + expect(text).not.toContain("Old description") + }) + test("restates the full catalog when the rendering mode crosses full and compact", () => { const wide = Array.from({ length: 40 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`)) const text = update([echo], [echo, ...wide], 30) diff --git a/packages/core/test/codemode/instructions.test.ts b/packages/core/test/codemode/instructions.test.ts index 5d9e72150cc..7defdb69ace 100644 --- a/packages/core/test/codemode/instructions.test.ts +++ b/packages/core/test/codemode/instructions.test.ts @@ -9,13 +9,13 @@ import { Effect, Schema } from "effect" import { it } from "../lib/effect" import { readInitial, readUpdate } from "../lib/instructions" -const echo: CodeModeCatalog.Entry = { +const echo: CodeModeCatalog.Tool = { path: "notes.echo", description: "Echo text", signature: "tools.notes.echo(input: {\n text: string,\n}): Promise", } -const lookup: CodeModeCatalog.Entry = { +const lookup: CodeModeCatalog.Tool = { path: "orders.lookup", description: "Look up an order", signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise", @@ -24,16 +24,16 @@ const lookup: CodeModeCatalog.Entry = { describe("CodeModeInstructions", () => { it.effect("instructs the model not to call execute while the catalog is empty", () => Effect.gen(function* () { - const initialized = yield* readInitial(CodeModeInstructions.make([])) + const initialized = yield* readInitial(CodeModeInstructions.make({ tools: [] })) expect(initialized.text).toBe( "No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool.", ) - const added = yield* readUpdate(CodeModeInstructions.make([echo]), initialized) + const added = yield* readUpdate(CodeModeInstructions.make({ tools: [echo] }), initialized) expect(added.text).toContain("New tools are available in addition to those previously listed:") expect(added.text).toContain(echo.signature) - expect(yield* readUpdate(CodeModeInstructions.make([]), { values: added.values })).toMatchObject({ + expect(yield* readUpdate(CodeModeInstructions.make({ tools: [] }), { values: added.values })).toMatchObject({ text: "The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.\n\n" + "No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool.", @@ -43,7 +43,7 @@ describe("CodeModeInstructions", () => { it.effect("renders the initial catalog, semantic deltas, and removal", () => Effect.gen(function* () { - const initialized = yield* readInitial(CodeModeInstructions.make([echo])) + const initialized = yield* readInitial(CodeModeInstructions.make({ tools: [echo] })) expect(initialized.text).toContain( "This catalog is the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.", ) @@ -51,13 +51,13 @@ describe("CodeModeInstructions", () => { expect(initialized.text).not.toContain("## Search") expect(initialized.text).toContain(` - ${echo.signature} // Echo text`) - const added = yield* readUpdate(CodeModeInstructions.make([echo, lookup]), initialized) + const added = yield* readUpdate(CodeModeInstructions.make({ tools: [echo, lookup] }), initialized) expect(added.text).toContain("The Code Mode tool catalog has changed.") expect(added.text).toContain("New tools are available in addition to those previously listed:") expect(added.text).toContain(` - ${lookup.signature} // Look up an order`) expect(added.text).not.toContain("## Available tools") - const removed = yield* readUpdate(CodeModeInstructions.make([echo]), { values: added.values }) + const removed = yield* readUpdate(CodeModeInstructions.make({ tools: [echo] }), { values: added.values }) expect(removed.text).toBe( "The Code Mode tool catalog has changed.\n\n" + "The following tools are no longer available and must not be called: tools.orders.lookup.", @@ -93,22 +93,27 @@ describe("CodeModeInstructions", () => { const initialized = yield* Effect.scoped( Effect.gen(function* () { yield* tools.transform((draft) => { + draft.namespace({ name: "tools", description: "Project utilities" }) draft.add({ ...zeta, options: { namespace: "tools" } }) draft.add({ ...alpha, options: { namespace: "tools" } }) }) - return yield* readInitial(CodeModeInstructions.make((yield* tools.snapshot()).codeModeCatalog)) + const snapshot = yield* tools.snapshot() + return yield* readInitial(CodeModeInstructions.make(snapshot.codeModeCatalog)) }), ) const reordered = yield* Effect.scoped( Effect.gen(function* () { yield* tools.transform((draft) => { + draft.namespace({ name: "tools", description: "Project utilities" }) draft.add({ ...alpha, options: { namespace: "tools" } }) draft.add({ ...zeta, options: { namespace: "tools" } }) }) - return yield* readUpdate(CodeModeInstructions.make((yield* tools.snapshot()).codeModeCatalog), initialized) + const snapshot = yield* tools.snapshot() + return yield* readUpdate(CodeModeInstructions.make(snapshot.codeModeCatalog), initialized) }), ) + expect(initialized.text).toContain("- tools (2 tools) // Project utilities") expect(reordered.changed).toBe(false) expect(reordered.text).toBe("") }).pipe(Effect.provide(layer)) diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index b2e85d033cf..21f69fd9022 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -35,7 +35,7 @@ export function waitForCodeModeTool( ): Effect.Effect { return Effect.gen(function* () { const toolSet = yield* registry.snapshot() - if (toolSet.codeModeCatalog?.some((tool) => tool.path === path)) return toolSet + if (toolSet.codeModeCatalog?.tools.some((tool) => tool.path === path)) return toolSet if (remaining === 0) { return yield* Effect.fail(new Error(`Timed out waiting for Code Mode tool: ${path}`)) } diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index bd4fad07fb3..441e7a14347 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -1764,7 +1764,9 @@ it.effect("advertises MCP output schemas to Code Mode", () => "direct_media", "execute", ]) - expect(toolSet.codeModeCatalog?.find((tool) => tool.path === "demo.search")?.signature).toContain("ok: boolean") + expect(toolSet.codeModeCatalog?.tools.find((tool) => tool.path === "demo.search")?.signature).toContain( + "ok: boolean", + ) expect(execute?.description).not.toContain("tools.demo.search") }), ) @@ -1782,7 +1784,9 @@ it.effect("forwards the invoking session through direct and Code Mode MCP tools" expect(toolSet.definitions.find((tool) => tool.name === "direct_lookup")?.inputSchema).not.toHaveProperty( "properties.sessionID", ) - expect(toolSet.codeModeCatalog?.find((tool) => tool.path === "demo.search")?.signature).not.toContain("sessionID") + expect(toolSet.codeModeCatalog?.tools.find((tool) => tool.path === "demo.search")?.signature).not.toContain( + "sessionID", + ) const directSessionID = Session.ID.make("ses_mcp_direct") yield* toolSet.execute({ @@ -1826,7 +1830,7 @@ it.effect("returns content-only MCP results through Code Mode", () => yield* registration.flush const toolSet = yield* registry.snapshot() - expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.status")).toBe(true) + expect(toolSet.codeModeCatalog?.tools.some((tool) => tool.path === "demo.status")).toBe(true) const execution = yield* toolSet.execute({ sessionID: Session.ID.make("ses_mcp_content_only"), @@ -1912,7 +1916,7 @@ it.effect("waits for permission before calling an MCP tool", () => const registration = yield* McpTool.Service yield* registration.flush const toolSet = yield* registry.snapshot() - expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.search")).toBe(true) + expect(toolSet.codeModeCatalog?.tools.some((tool) => tool.path === "demo.search")).toBe(true) const fiber = yield* toolSet .execute({ @@ -1956,7 +1960,7 @@ it.effect("does not call MCP when permission is blocked", () => const registration = yield* McpTool.Service yield* registration.flush const toolSet = yield* registry.snapshot() - expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.search")).toBe(true) + expect(toolSet.codeModeCatalog?.tools.some((tool) => tool.path === "demo.search")).toBe(true) const execution = yield* toolSet.execute({ sessionID: Session.ID.make("ses_mcp_blocked"), diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 1fcabda2e09..5ff12b0ac62 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -963,7 +963,7 @@ describe("fromPromise", () => { }) const original = yield* registry.snapshot() expect(original.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"]) - expect(original.codeModeCatalog).toEqual([]) + expect(original.codeModeCatalog?.tools).toEqual([]) yield* PluginPromise.fromPromise( define({ @@ -981,7 +981,7 @@ describe("fromPromise", () => { const snapshot = yield* registry.snapshot() expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"]) - expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["acme.hello"]) + expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["acme.hello"]) expect(original.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"]) expect( yield* snapshot.execute({ diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index 818f1e5fc32..daa10c2875d 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -115,13 +115,15 @@ const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void }) const tools = Layer.mock(Tool.Service, { snapshot: () => Effect.succeed({ - codeModeCatalog: [ - { - path: "captured.lookup", - description: "Captured Code Mode catalog", - signature: "tools.captured.lookup(input: {}): Promise", - }, - ], + codeModeCatalog: { + tools: [ + { + path: "captured.lookup", + description: "Captured Code Mode catalog", + signature: "tools.captured.lookup(input: {}): Promise", + }, + ], + }, definitions: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })], execute: () => Effect.die(new Error("unused")), }), diff --git a/packages/core/test/tool-execute.test.ts b/packages/core/test/tool-execute.test.ts index e205eb1f047..503e3553a68 100644 --- a/packages/core/test/tool-execute.test.ts +++ b/packages/core/test/tool-execute.test.ts @@ -17,7 +17,7 @@ const context = { } const createCodeMode = (tools: ReadonlyMap) => - CodeModeTool.create(tools, (_, tool, input, context) => execute(tool, input, context)) + CodeModeTool.create({ tools }, (_, tool, input, context) => execute(tool, input, context)) test("execute describes invariant Code Mode behavior", () => { expect(createCodeMode(new Map()).description).toBe( diff --git a/packages/core/test/tool-registry.test.ts b/packages/core/test/tool-registry.test.ts index b38826585c1..dbcf052832e 100644 --- a/packages/core/test/tool-registry.test.ts +++ b/packages/core/test/tool-registry.test.ts @@ -230,7 +230,7 @@ describe("Tool", () => { }) const scope = yield* Scope.make() yield* service.transform((draft) => draft.remove("hidden")).pipe(Scope.provide(scope)) - expect((yield* service.snapshot()).codeModeCatalog).toEqual([]) + expect((yield* service.snapshot()).codeModeCatalog?.tools).toEqual([]) expect((yield* executeTool(service, call("acme_echo"))).output).toEqual({ text: "original updated" }) text = "refreshed" @@ -239,7 +239,7 @@ describe("Tool", () => { yield* Fiber.join(reload) const refreshed = yield* service.snapshot() expect(refreshed.definitions[0]?.description).toBe("Updated") - expect(refreshed.codeModeCatalog).toEqual([]) + expect(refreshed.codeModeCatalog?.tools).toEqual([]) expect((yield* refreshed.execute(call("acme_echo"))).output).toEqual({ text: "refreshed updated" }) expect((yield* original.execute(call("acme_echo"))).output).toEqual({ text: "original" }) @@ -247,7 +247,7 @@ describe("Tool", () => { 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"]) + expect((yield* service.snapshot()).codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["hidden"]) yield* service.transform((draft) => draft.update("acme_echo", (tool) => { @@ -440,7 +440,7 @@ describe("Tool", () => { ]) const snapshot = yield* service.snapshot() expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"]) - expect(snapshot.codeModeCatalog).toEqual([]) + expect(snapshot.codeModeCatalog?.tools).toEqual([]) }).pipe(Effect.provide(Logger.layer([logger]))) }) @@ -465,7 +465,7 @@ describe("Tool", () => { 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"))).output).toEqual({ text: "last" }) - expect(snapshot.codeModeCatalog).toEqual([]) + expect(snapshot.codeModeCatalog?.tools).toEqual([]) }), ) @@ -502,7 +502,7 @@ describe("Tool", () => { const snapshot = yield* service.snapshot() expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"]) - expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual([ + expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual([ "-lookup", "123", "123._private.-tools.2d_get_scene", @@ -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" } }) @@ -541,7 +542,95 @@ describe("Tool", () => { const snapshot = yield* service.snapshot() expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["first", "execute"]) - expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["invalid__namespace.second"]) + expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["invalid__namespace.second"]) + }), + ) + + it.effect("keeps namespace descriptions beside catalog tools", () => + Effect.gen(function* () { + const service = yield* Tool.Service + yield* service.transform((draft) => { + draft.namespace({ name: "registry", description: "Package publishing and discovery" }) + draft.namespace({ name: "registry.search", description: "Pricing operations" }) + 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" } }) + draft.add({ ...make(), name: "sales", description: "Read sales", options: { namespace: "registry.search" } }) + }) + + const snapshot = yield* service.snapshot() + expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["registry_direct", "execute"]) + expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual([ + "legacy.plain", + "registry.search", + "registry.search.sales", + ]) + expect(snapshot.codeModeCatalog?.namespaces).toEqual( + new Map([ + ["registry", { name: "registry", description: "Package publishing and discovery" }], + ["registry.search", { name: "registry.search", description: "Pricing operations" }], + ]), + ) + const result = yield* snapshot.execute({ + ...call("execute"), + call: { + type: "tool-call", + id: "namespace-search", + name: "execute", + input: { code: 'return search({ query: "pricing operations" })' }, + }, + }) + expect(result.output).toMatchObject({ output: expect.stringContaining("tools.registry.search") }) + const callable = yield* snapshot.execute({ + ...call("execute"), + call: { + type: "tool-call", + id: "callable-namespace", + name: "execute", + input: { + code: `return await Promise.all([ + tools.registry.search({ text: "search" }), + tools.registry.search.sales({ text: "sales" }), + ])`, + }, + }, + }) + expect(callable.output).toMatchObject({ + output: expect.stringContaining('"text": "sales"'), + toolCalls: [ + { tool: "registry.search", status: "completed" }, + { tool: "registry.search.sales", status: "completed" }, + ], + }) + }), + ) + + it.effect("preserves a top-level tool that also has child tools", () => + Effect.gen(function* () { + const service = yield* Tool.Service + yield* service.transform((draft) => { + draft.namespace({ name: "pricing", description: "Pricing operations" }) + draft.add({ ...make(), name: "pricing" }) + draft.add({ ...make(), name: "sales", options: { namespace: "pricing" } }) + }) + + const snapshot = yield* service.snapshot() + expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["pricing", "pricing.sales"]) + const result = yield* snapshot.execute({ + ...call("execute"), + call: { + type: "tool-call", + id: "top-level-callable", + name: "execute", + input: { + code: `return await Promise.all([ + tools.pricing({ text: "pricing" }), + tools.pricing.sales({ text: "sales" }), + ])`, + }, + }, + }) + expect(result.output).toMatchObject({ output: expect.stringContaining('"text": "sales"') }) }), ) @@ -575,7 +664,7 @@ describe("Tool", () => { ]) const snapshot = yield* service.snapshot() expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"]) - expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["codemode"]) + expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["codemode"]) expect((yield* snapshot.execute(call("phone_type")).pipe(Effect.flip)).message).toBe("Unknown tool: phone_type") }).pipe(Effect.provide(Logger.layer([logger]))) }) @@ -640,7 +729,7 @@ describe("Tool", () => { const snapshot = yield* service.snapshot() expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"]) - expect(snapshot.codeModeCatalog?.[0]?.signature).toContain("tools.echo") + expect(snapshot.codeModeCatalog?.tools[0]?.signature).toContain("tools.echo") }), ) @@ -650,7 +739,7 @@ describe("Tool", () => { const available = yield* service.snapshot() expect(available.definitions.map((tool) => tool.name)).toEqual(["execute"]) - expect(available.codeModeCatalog).toEqual([]) + expect(available.codeModeCatalog?.tools).toEqual([]) const denied = yield* service.snapshot([{ action: "execute", resource: "*", effect: "deny" }]) expect(denied.definitions).toEqual([]) @@ -1103,7 +1192,7 @@ describe("Tool", () => { }).pipe(Scope.provide(scope)) const toolSet = yield* service.snapshot() const execute = toolSet.definitions.find((tool) => tool.name === "execute") - expect(toolSet.codeModeCatalog?.[0]?.signature).toContain("tools.echo") + expect(toolSet.codeModeCatalog?.tools[0]?.signature).toContain("tools.echo") expect(execute?.description).toContain("confined Code Mode runtime") expect(execute?.description).not.toContain("Echo text") yield* Scope.close(scope, Exit.void) 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/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/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..1aadc93a463 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", @@ -889,6 +893,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: { name: string; description: string }): 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..a4105cdd095 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", @@ -868,6 +872,7 @@ interface ToolContext { interface ToolDraft { list(): readonly (ToolInfo & { readonly id: string })[] get(id: string): (ToolInfo & { readonly id: string }) | undefined + namespace(namespace: { name: string; description: string }): void add(tool: ToolInfo): void update(id: string, update: (tool: Types.Mutable) => void): void remove(id: string): void