feat(core): render namespace catalog descriptions

This commit is contained in:
Aiden Cline 2026-08-31 20:18:08 -05:00
parent 3ac848636d
commit 14a9e1fabb
8 changed files with 121 additions and 28 deletions

View file

@ -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<string, Namespace>
}
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 {
export function summarize(entries: ReadonlyArray<Entry>, 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<Entry>, 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<Entry>, 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<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 +
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<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)),
}))
@ -103,6 +128,17 @@ export function summarize(entries: ReadonlyArray<Entry>, 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<typeof Listing.Type>) {
return listings
.map((listing) => ({ listing, cost: Math.round(listing.line.length / CHARACTERS_PER_TOKEN) }))

View file

@ -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<CodeModeCatalog.Entry>): Instructions.List => {
const catalog = entries === undefined ? Instructions.removed : CodeModeCatalog.summarize(entries)
export const make = (
entries?: ReadonlyArray<CodeModeCatalog.Entry>,
namespaces?: ReadonlyMap<string, Namespace>,
): Instructions.List => {
const catalog = entries === undefined ? Instructions.removed : CodeModeCatalog.summarize(entries, { namespaces })
return Instructions.make({
key,
codec,

View file

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

View file

@ -45,6 +45,7 @@ export interface Interface extends State.Transformable<Draft> {
export interface Snapshot {
readonly definitions: ReadonlyArray<ToolDefinition>
readonly codeModeCatalog?: ReadonlyArray<CodeModeCatalog.Entry>
readonly codeModeNamespaces?: ReadonlyMap<string, Tool.Namespace>
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))

View file

@ -16,20 +16,23 @@ const lookup = entry(
)
const render = (entries: ReadonlyArray<CodeModeCatalog.Entry>, budget?: number) =>
CodeModeInstructions.render(CodeModeCatalog.summarize(entries, budget))
CodeModeInstructions.render(CodeModeCatalog.summarize(entries, budget === undefined ? {} : { budget }))
const update = (
previous: ReadonlyArray<CodeModeCatalog.Entry>,
current: ReadonlyArray<CodeModeCatalog.Entry>,
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)

View file

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

View file

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

View file

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