mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-07 08:54:49 +00:00
feat(core): render namespace catalog descriptions
This commit is contained in:
parent
3ac848636d
commit
14a9e1fabb
8 changed files with 121 additions and 28 deletions
|
|
@ -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) }))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue