feat(core): register tool namespaces (#46487)

This commit is contained in:
Aiden Cline 2026-08-31 21:29:19 -05:00 committed by GitHub
parent a40a87276a
commit 6a99898ef7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 438 additions and 115 deletions

View file

@ -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<Tool>
readonly namespaces?: ReadonlyMap<string, Namespace>
}
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<Entry>, 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<Entry>, 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<Entry>, 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<Entry>, 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<typeof Listing.Type>) {
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<typeof Listing.Type>) {
return 0
})
}
function cost(text: string) {
return Math.round(text.length / CHARACTERS_PER_TOKEN)
}

View file

@ -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<CodeModeCatalog.Entry>): 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,

View file

@ -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<typeof ExecuteFile.Type>
}
type ToolNode = {
tool?: Tool.Tool<never>
namespace?: ToolNamespace
readonly children: Map<string, ToolNode>
}
type Tools = {
[name: string]: Tool.Tool<never> | Namespace.Namespace<never> | Tools
}
export type Inventory = {
readonly tools: ReadonlyMap<string, Info>
readonly namespaces?: ReadonlyMap<string, ToolNamespace>
}
// 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<string, Info>,
inventory: Inventory,
executeTool: (name: string, tool: Info, input: unknown, context: Context) => Effect.Effect<Result, Error>,
) => {
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<string, Info>) => {
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<string, Info>,
inventory: Inventory,
executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
) {
const tools: Record<string, Tool.Tool<never>> = {}
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<typeof tools>({ 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<string, Tool.Tool<never>>()
const tools = renderChildren(root, [], callables)
for (const [path, tool] of callables) tools[path] = tool
return tools
}
function renderChildren(node: ToolNode, path: ReadonlyArray<string>, callables: Map<string, Tool.Tool<never>>): 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<string>,
callables: Map<string, Tool.Tool<never>>,
): 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<string>, 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

View file

@ -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<RegistrationError>()("
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<Tool.Info>) => void) => void
readonly remove: (id: string) => void
@ -33,7 +34,8 @@ export interface Draft {
type Data = {
tools: Map<string, Tool.Info & { readonly id: string }>
errors: { tool: Tool.Info; error: RegistrationError }[]
namespaces: Map<string, Tool.Namespace>
errors: { kind: "tool" | "namespace"; name: string; namespace?: string; error: RegistrationError }[]
}
export interface Interface extends State.Transformable<Draft> {
@ -42,7 +44,7 @@ export interface Interface extends State.Transformable<Draft> {
export interface Snapshot {
readonly definitions: ReadonlyArray<ToolDefinition>
readonly codeModeCatalog?: ReadonlyArray<CodeModeCatalog.Entry>
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<Snapshot["execute"]>[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,

View file

@ -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 `<namespace>_<tool>`, 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.

View file

@ -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<string>",
pinned: true,
},
])
expect(snapshot.codeModeCatalog).toStrictEqual({
tools: [
{
path: "echo",
description: "Echo text",
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
pinned: true,
},
],
namespaces: new Map(),
})
}).pipe(
Effect.scoped,
Effect.provide(

View file

@ -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<string>`,
@ -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<CodeModeCatalog.Entry>, budget?: number) =>
CodeModeInstructions.render(CodeModeCatalog.summarize(entries, budget))
const render = (tools: ReadonlyArray<CodeModeCatalog.Tool>, budget?: number) =>
CodeModeInstructions.render(CodeModeCatalog.summarize({ tools }, budget === undefined ? {} : { budget }))
const update = (
previous: ReadonlyArray<CodeModeCatalog.Entry>,
current: ReadonlyArray<CodeModeCatalog.Entry>,
previous: ReadonlyArray<CodeModeCatalog.Tool>,
current: ReadonlyArray<CodeModeCatalog.Tool>,
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)

View file

@ -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<string>",
}
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<unknown>",
@ -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))

View file

@ -35,7 +35,7 @@ export function waitForCodeModeTool(
): Effect.Effect<Tool.Snapshot, Error> {
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}`))
}

View file

@ -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"),

View file

@ -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({

View file

@ -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<string>",
},
],
codeModeCatalog: {
tools: [
{
path: "captured.lookup",
description: "Captured Code Mode catalog",
signature: "tools.captured.lookup(input: {}): Promise<string>",
},
],
},
definitions: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })],
execute: () => Effect.die(new Error("unused")),
}),

View file

@ -17,7 +17,7 @@ const context = {
}
const createCodeMode = (tools: ReadonlyMap<string, Info>) =>
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(

View file

@ -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)

View file

@ -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<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
tool: Tool.Info<Input, Output>,
): void

View file

@ -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,

View file

@ -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<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
tool: Info<Input, Output>,
): void

View file

@ -19,6 +19,11 @@ export interface Context {
readonly progress: (update: Metadata) => Effect.Effect<void>
}
export interface Namespace {
readonly name: string
readonly description: string
}
interface BaseOptions {
readonly namespace?: string
readonly permission?: string

View file

@ -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<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
tool: Tool.Info<Input, Output>,
): void

View file

@ -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<ToolInfo>) => void): void
remove(id: string): void