diff --git a/packages/core/src/codemode/catalog.ts b/packages/core/src/codemode/catalog.ts index eafc1568949e..9076d7375727 100644 --- a/packages/core/src/codemode/catalog.ts +++ b/packages/core/src/codemode/catalog.ts @@ -6,6 +6,7 @@ export const Entry = Schema.Struct({ path: Schema.String, description: Schema.String, signature: Schema.String, + pinned: Schema.optionalKey(Schema.Literal(true)), }) export type Entry = typeof Entry.Type @@ -31,8 +32,8 @@ 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, -// considering shorter listings first until the inline budget is exhausted. +// Keep every namespace searchable and every pinned listing visible, then select additional +// listings one per namespace per round until the remaining 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)] .sort(([left], [right]) => { @@ -45,44 +46,46 @@ export function summarize(entries: ReadonlyArray, budget = INLINE_BUDGET) .map((entry) => { const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? "" const description = - firstLine.length > DESCRIPTION_LIMIT - ? firstLine.slice(0, DESCRIPTION_LIMIT - 3) + "..." - : firstLine + firstLine.length > DESCRIPTION_LIMIT ? firstLine.slice(0, DESCRIPTION_LIMIT - 3) + "..." : firstLine const suffix = description.length === 0 ? "" : ` // ${description}` - return { path: entry.path, line: ` - ${entry.signature}${suffix}` } + return { path: entry.path, line: ` - ${entry.signature}${suffix}`, pinned: entry.pinned === true } }) .toSorted((left, right) => { if (left.path < right.path) return -1 if (left.path > right.path) return 1 return 0 }) + const pinned = listings.filter((listing) => listing.pinned) return { name, listings, - selectionOrder: rankListings(listings), - selectedListings: new Set(), + selectionOrder: rankListings(listings.filter((listing) => !listing.pinned)), + selectedListings: new Set(pinned), + pinnedCost: pinned.reduce((total, listing) => total + listingCost(listing), 0), } }) const active = new Set(namespaces) - let remaining = budget + let remaining = Math.max(0, budget - namespaces.reduce((total, namespace) => total + namespace.pinnedCost, 0)) while (active.size > 0) { for (const namespace of active) { - const candidate = namespace.selectionOrder[namespace.selectedListings.size] + const candidate = namespace.selectionOrder.shift() if (!candidate || candidate.cost > remaining) { active.delete(namespace) continue } namespace.selectedListings.add(candidate.listing) remaining -= candidate.cost - if (namespace.selectedListings.size === namespace.selectionOrder.length) active.delete(namespace) + if (namespace.selectionOrder.length === 0) active.delete(namespace) } } const namespaceSummaries = namespaces.map((namespace) => ({ name: namespace.name, count: namespace.listings.length, - entries: namespace.listings.filter((listing) => namespace.selectedListings.has(listing)), + entries: namespace.listings + .filter((listing) => namespace.selectedListings.has(listing)) + .map((listing) => ({ path: listing.path, line: listing.line })), })) return { total: entries.length, @@ -91,9 +94,13 @@ export function summarize(entries: ReadonlyArray, budget = INLINE_BUDGET) } } +function listingCost(listing: typeof Listing.Type) { + return Math.round(listing.line.length / CHARACTERS_PER_TOKEN) +} + function rankListings(listings: ReadonlyArray) { return listings - .map((listing) => ({ listing, cost: Math.round(listing.line.length / CHARACTERS_PER_TOKEN) })) + .map((listing) => ({ listing, cost: listingCost(listing) })) .toSorted((left, right) => { if (left.cost !== right.cost) return left.cost - right.cost if (left.listing.path < right.listing.path) return -1 diff --git a/packages/core/src/tool/execute.ts b/packages/core/src/tool/execute.ts index c1b297156d4f..65f61654b38c 100644 --- a/packages/core/src/tool/execute.ts +++ b/packages/core/src/tool/execute.ts @@ -129,7 +129,14 @@ export const create = (registrations: ReadonlyMap) => { } export const catalog = (registrations: ReadonlyMap) => { - return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).catalog() + const pinned = new Set( + Array.from(registrations.values()).flatMap((registration) => + registration.pinned === true ? [registrationPath(registration)] : [], + ), + ) + return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))) + .catalog() + .map((entry) => (pinned.has(entry.path) ? { ...entry, pinned: true as const } : entry)) } function runtime( @@ -140,8 +147,7 @@ function runtime( const tools: Record> = {} for (const [name, registration] of registrations) { const child = toLLMDefinition(name, registration.tool) - const path = - registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}` + const path = registrationPath(registration) tools[path] = Tool.make({ description: child.description, input: child.inputSchema, @@ -152,6 +158,10 @@ function runtime( return CodeMode.make({ tools, ...hooks }) } +function registrationPath(registration: Registration) { + return registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}` +} + // Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact. function displayInput(input: unknown): Record | undefined { if (input === null || input === undefined) return diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index c5e3d1014b57..63cc13cac17f 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -245,6 +245,13 @@ const registryLayer = Layer.effect( }), ) const codemode = options?.codemode ?? true + if (!codemode && options?.pinned === true) + return yield* Effect.fail( + new Tool.RegistrationError({ + name: entries[0]?.key ?? "", + message: "Pinned tools must use Code Mode", + }), + ) const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute") if (reserved) return yield* Effect.fail( diff --git a/packages/core/test/codemode.test.ts b/packages/core/test/codemode.test.ts index 9d9f8ccfd3c2..3ff888369dcf 100644 --- a/packages/core/test/codemode.test.ts +++ b/packages/core/test/codemode.test.ts @@ -10,14 +10,17 @@ describe("CodeMode", () => { Effect.gen(function* () { const codeMode = yield* CodeMode.Service yield* codeMode.register( - Tool.registrationEntries({ - echo: Tool.make({ - description: "Echo text", - input: Schema.Struct({ text: Schema.String }), - output: Schema.String, - execute: ({ text }) => Effect.succeed({ output: text }), - }), - }), + Tool.registrationEntries( + { + echo: Tool.make({ + description: "Echo text", + input: Schema.Struct({ text: Schema.String }), + output: Schema.String, + execute: ({ text }) => Effect.succeed({ output: text }), + }), + }, + { pinned: true }, + ), ) const materialized = yield* codeMode.materialize() @@ -27,6 +30,46 @@ describe("CodeMode", () => { path: "echo", description: "Echo text", signature: "tools.echo(input: {\n text: string,\n}): Promise", + pinned: true, + }, + ]) + }).pipe(Effect.scoped, Effect.provide(AppNodeBuilder.build(CodeMode.node))), + ) + + it.effect("omits denied pinned registrations from the catalog", () => + Effect.gen(function* () { + const codeMode = yield* CodeMode.Service + yield* codeMode.register( + Tool.registrationEntries({ + visible: Tool.make({ + description: "Visible tool", + input: Schema.Struct({}), + output: Schema.String, + execute: () => Effect.succeed({ output: "visible" }), + }), + }), + ) + yield* codeMode.register( + Tool.registrationEntries( + { + hidden: Tool.make({ + description: "Hidden tool", + input: Schema.Struct({}), + output: Schema.String, + execute: () => Effect.succeed({ output: "hidden" }), + }), + }, + { permission: "hidden", pinned: true }, + ), + ) + + const materialized = yield* codeMode.materialize([{ action: "hidden", resource: "*", effect: "deny" }]) + expect(materialized.tool).toBeDefined() + expect(materialized.catalog).toEqual([ + { + path: "visible", + description: "Visible tool", + signature: "tools.visible(input: {}): Promise", }, ]) }).pipe(Effect.scoped, Effect.provide(AppNodeBuilder.build(CodeMode.node))), diff --git a/packages/core/test/codemode/catalog.test.ts b/packages/core/test/codemode/catalog.test.ts index c880c5d53f21..d04e0890280c 100644 --- a/packages/core/test/codemode/catalog.test.ts +++ b/packages/core/test/codemode/catalog.test.ts @@ -57,6 +57,23 @@ describe("CodeModeCatalog.summarize", () => { expect(description).toHaveLength(120) expect(description).toEndWith("...") }) + + test("keeps pinned signatures when no unpinned listing fits", () => { + const pinned = { ...entry("files.read", "Read a file"), pinned: true as const } + const catalog = CodeModeCatalog.summarize([entry("files.write", "Write a file"), pinned], 0) + + expect(catalog).toEqual({ + total: 2, + shown: 1, + namespaces: [ + { + name: "files", + count: 2, + entries: [{ path: pinned.path, line: ` - ${pinned.signature} // Read a file` }], + }, + ], + }) + }) }) describe("CodeModeInstructions.render", () => { diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index b46f7192457d..f9915a980a21 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -121,6 +121,17 @@ describe("ToolRegistry", () => { }), ) + it.effect("rejects pinned tools outside Code Mode", () => + Effect.gen(function* () { + const service = yield* ToolRegistry.Service + const error = yield* service.register({ echo: make() }, { codemode: false, pinned: true }).pipe(Effect.flip) + + expect(error).toBeInstanceOf(Tool.RegistrationError) + expect(error.message).toBe("Pinned tools must use Code Mode") + expect((yield* service.snapshot()).definitions).toEqual([]) + }), + ) + it.effect("canonicalizes effective definitions and keeps Code Mode last", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service diff --git a/packages/docs/build/plugins.mdx b/packages/docs/build/plugins.mdx index e358a3ccae9f..a7ef78ea977f 100644 --- a/packages/docs/build/plugins.mdx +++ b/packages/docs/build/plugins.mdx @@ -326,12 +326,15 @@ Unsupported characters in tool names are normalized to underscores. Namespace segments must begin with a letter, contain at most 64 letters, digits, underscores, or hyphens, and are joined with dots. Pass the optional third argument to `tools.add` to configure the registration with -`{ namespace, codemode }`: +`{ namespace, codemode, pinned }`: - `namespace` prefixes and groups the exposed tool name. - `codemode` defaults to `true` and makes the tool available through the `execute` CodeMode tool. Set `codemode: false` to expose it directly to the provider. +- `pinned: true` keeps the tool's complete signature visible when the Code Mode + catalog is compact. Pinned tools must use Code Mode and cannot set + `codemode: false`. The executor receives a second context argument containing `sessionID`, `agent`, `messageID`, `callID`, and `progress`. A tool with `output` diff --git a/packages/plugin/src/v2/effect/internal/tool.ts b/packages/plugin/src/v2/effect/internal/tool.ts index abc92c6a7d22..f6646e7245a3 100644 --- a/packages/plugin/src/v2/effect/internal/tool.ts +++ b/packages/plugin/src/v2/effect/internal/tool.ts @@ -129,6 +129,8 @@ export interface RegisterOptions { readonly namespace?: string /** Defaults to true. False exposes the tool directly to the provider. */ readonly codemode?: boolean + /** Keeps the complete signature visible when the Code Mode catalog is compact. */ + readonly pinned?: boolean /** Permission action used for whole-tool visibility filtering. */ readonly permission?: string } @@ -138,6 +140,7 @@ export interface Registration { readonly name: string readonly namespace?: string readonly permission: string + readonly pinned?: boolean } export const validateName = (name: string) => @@ -159,6 +162,7 @@ export const registrationEntries = ( namespace: options?.namespace, tool, permission: options?.permission ?? key, + ...(options?.pinned === true ? { pinned: true } : {}), } }) diff --git a/packages/plugin/test/tool.test.ts b/packages/plugin/test/tool.test.ts index 19017cab20e1..869c53a7bbca 100644 --- a/packages/plugin/test/tool.test.ts +++ b/packages/plugin/test/tool.test.ts @@ -125,3 +125,16 @@ test("raw JSON schemas are render-only and omitted output means model-only", asy }) expect(await Effect.runPromise(Tool.decodeInput(tool.input, { value: 1 }))).toEqual({ value: 1 }) }) + +test("registration metadata omits disabled pinning", () => { + const tool = Tool.make({ + description: "Echo text", + input: Schema.Struct({ text: Schema.String }), + output: Schema.String, + execute: ({ text }) => Effect.succeed({ output: text }), + }) + const registration = Tool.registrationEntries({ echo: tool }) + + expect(Tool.registrationEntries({ echo: tool }, { pinned: false })).toEqual(registration) + expect(Tool.registrationEntries({ echo: tool }, { pinned: true })).toEqual([{ ...registration[0], pinned: true }]) +})