From 14a9e1fabb7ee9c64335d4c6d4ea48fc7cd48f35 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 31 Aug 2026 20:18:08 -0500 Subject: [PATCH] feat(core): render namespace catalog descriptions --- packages/core/src/codemode/catalog.ts | 44 ++++++++++++-- packages/core/src/codemode/instructions.ts | 26 +++++---- packages/core/src/session/context.ts | 2 +- packages/core/src/tool.ts | 3 +- packages/core/test/codemode/catalog.test.ts | 58 ++++++++++++++++--- .../core/test/codemode/instructions.test.ts | 12 +++- .../src/docs/content/build/plugins/effect.mdx | 2 +- .../src/docs/content/build/plugins/index.mdx | 2 +- 8 files changed, 121 insertions(+), 28 deletions(-) diff --git a/packages/core/src/codemode/catalog.ts b/packages/core/src/codemode/catalog.ts index a7ae19175a2..c106ff50553 100644 --- a/packages/core/src/codemode/catalog.ts +++ b/packages/core/src/codemode/catalog.ts @@ -1,6 +1,7 @@ export * as CodeModeCatalog from "./catalog.js" import { Schema } from "effect" +import type { Namespace } from "@opencode-ai/schema/tool" export const Entry = Schema.Struct({ path: Schema.String, @@ -15,8 +16,9 @@ const Listing = Schema.Struct({ 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,17 +26,23 @@ 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 + readonly namespaces?: ReadonlyMap +} + 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 { +export function summarize(entries: ReadonlyArray, options: Options = {}): Summary { + const budget = options.budget ?? INLINE_BUDGET const namespaces = [...Map.groupBy(entries, (entry) => entry.path.split(".", 1)[0] ?? entry.path)] .sort(([left], [right]) => { if (left < right) return -1 @@ -42,6 +50,7 @@ export function summarize(entries: ReadonlyArray, budget = INLINE_BUDGET) return 0 }) .map(([name, namespaceEntries]) => { + const description = options.namespaces?.get(name)?.description const listings = namespaceEntries .map((entry) => { const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? "" @@ -64,6 +73,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,8 +82,22 @@ 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 + + Math.round( + namespaceLine({ + name: namespace.name, + ...(namespace.description === undefined ? {} : { description: namespace.description }), + count: namespace.listings.length, + entries: [], + }).length / CHARACTERS_PER_TOKEN, + ), + 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) @@ -93,6 +117,7 @@ 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)), })) @@ -103,6 +128,17 @@ export function summarize(entries: ReadonlyArray, budget = INLINE_BUDGET) } } +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) })) diff --git a/packages/core/src/codemode/instructions.ts b/packages/core/src/codemode/instructions.ts index 15884fa237c..487295b71ea 100644 --- a/packages/core/src/codemode/instructions.ts +++ b/packages/core/src/codemode/instructions.ts @@ -4,6 +4,7 @@ import { searchSignature, toolExpression } from "@opencode-ai/codemode" import { Effect, Schema } from "effect" import { Instructions } from "../instructions/index.js" import { CodeModeCatalog } from "./catalog.js" +import type { Namespace } from "@opencode-ai/schema/tool" // prettier-ignore const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}. @@ -23,14 +24,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 +41,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 +129,11 @@ ${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 = ( + entries?: ReadonlyArray, + namespaces?: ReadonlyMap, +): Instructions.List => { + const catalog = entries === undefined ? Instructions.removed : CodeModeCatalog.summarize(entries, { namespaces }) return Instructions.make({ key, codec, diff --git a/packages/core/src/session/context.ts b/packages/core/src/session/context.ts index 18e535f83da..4bcbb338f4c 100644 --- a/packages/core/src/session/context.ts +++ b/packages/core/src/session/context.ts @@ -146,7 +146,7 @@ const layer = Layer.effect( agent: { ...agent, info: agent.info }, instructions: Instructions.combine([ loaded.builtins, - CodeModeInstructions.make(loaded.tools.codeModeCatalog), + CodeModeInstructions.make(loaded.tools.codeModeCatalog, loaded.tools.codeModeNamespaces), loaded.discovery, loaded.skills, loaded.references, diff --git a/packages/core/src/tool.ts b/packages/core/src/tool.ts index 064e9297869..62856a458a7 100644 --- a/packages/core/src/tool.ts +++ b/packages/core/src/tool.ts @@ -45,6 +45,7 @@ export interface Interface extends State.Transformable { export interface Snapshot { readonly definitions: ReadonlyArray readonly codeModeCatalog?: ReadonlyArray + readonly codeModeNamespaces?: ReadonlyMap readonly execute: (input: { readonly sessionID: SessionSchema.ID readonly agent: Agent.ID @@ -234,7 +235,7 @@ const layer = Layer.effect( : undefined const codeModeCatalog = codeModeEnabled ? CodeModeTool.catalog(codeModeInventory) : undefined return { - ...(codeModeCatalog === undefined ? {} : { codeModeCatalog }), + ...(codeModeCatalog === undefined ? {} : { codeModeCatalog, codeModeNamespaces: namespaces }), definitions: [ ...Array.from(direct) .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) diff --git a/packages/core/test/codemode/catalog.test.ts b/packages/core/test/codemode/catalog.test.ts index 308c15b1627..c2396c252ef 100644 --- a/packages/core/test/codemode/catalog.test.ts +++ b/packages/core/test/codemode/catalog.test.ts @@ -16,20 +16,23 @@ const lookup = entry( ) const render = (entries: ReadonlyArray, budget?: number) => - CodeModeInstructions.render(CodeModeCatalog.summarize(entries, budget)) + CodeModeInstructions.render(CodeModeCatalog.summarize(entries, budget === undefined ? {} : { budget })) const update = ( previous: ReadonlyArray, current: ReadonlyArray, budget?: number, ) => - CodeModeInstructions.update(CodeModeCatalog.summarize(previous, budget), CodeModeCatalog.summarize(current, budget)) + CodeModeInstructions.update( + CodeModeCatalog.summarize(previous, budget === undefined ? {} : { budget }), + CodeModeCatalog.summarize(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, + { budget: 0 }, ) expect(catalog).toEqual({ total: 10_000, @@ -41,7 +44,7 @@ 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, + { 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,7 @@ 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([...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,9 +66,17 @@ 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([pinned, unpinned], { budget: namespaceCost + pinCost + unpinnedCost }).shown, + ).toBe(2) + expect( + CodeModeCatalog.summarize([pinned, unpinned], { budget: namespaceCost + pinCost + unpinnedCost - 1 }).shown, + ).toBe(1) }) test("retains only the rendered portion of inline descriptions", () => { @@ -79,6 +90,20 @@ describe("CodeModeCatalog.summarize", () => { 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([tool], { budget: namespaceCost + listingCost }).shown).toBe(1) + const catalog = CodeModeCatalog.summarize([tool], { budget: namespaceCost + listingCost, namespaces }) + 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", () => { @@ -118,7 +143,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`) @@ -170,6 +199,19 @@ describe("CodeModeInstructions.update", () => { ) }) + test("restates namespace descriptions when they change", () => { + const previous = CodeModeCatalog.summarize([echo], { + namespaces: new Map([["notes", { name: "notes", description: "Old description" }]]), + }) + const current = CodeModeCatalog.summarize([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..1a6fd379c22 100644 --- a/packages/core/test/codemode/instructions.test.ts +++ b/packages/core/test/codemode/instructions.test.ts @@ -93,22 +93,30 @@ 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, snapshot.codeModeNamespaces)) }), ) 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, snapshot.codeModeNamespaces), + 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/www/src/docs/content/build/plugins/effect.mdx b/packages/www/src/docs/content/build/plugins/effect.mdx index 4cb5ce6d655..7a15e36dbea 100644 --- a/packages/www/src/docs/content/build/plugins/effect.mdx +++ b/packages/www/src/docs/content/build/plugins/effect.mdx @@ -858,7 +858,7 @@ effect: (ctx) => }), ``` -Register a namespace once to add model-visible search context for every CodeMode tool assigned to it. Tools continue +Register a namespace once to show its description in the CodeMode catalog and match it in tool search. Tools continue to reference the namespace by its string name. Namespace registration is optional when no description is needed. Call `yield* ctx.tool.reload()` after changing source data captured by the callback. Reload replays active transforms diff --git a/packages/www/src/docs/content/build/plugins/index.mdx b/packages/www/src/docs/content/build/plugins/index.mdx index 4956f4dd66e..4cecd27f289 100644 --- a/packages/www/src/docs/content/build/plugins/index.mdx +++ b/packages/www/src/docs/content/build/plugins/index.mdx @@ -821,7 +821,7 @@ const registration = await ctx.tool.transform((draft) => { }) ``` -Register a namespace once to add model-visible search context for every CodeMode tool assigned to it. Tools continue +Register a namespace once to show its description in the CodeMode catalog and match it in tool search. Tools continue to reference the namespace by its string name. Namespace registration is optional when no description is needed. Call `reload()` after changing source data captured by the callback. Reload replays the active transforms without