refactor(core): nest code mode catalog (#46541)

This commit is contained in:
Aiden Cline 2026-09-01 00:01:31 -05:00 committed by GitHub
parent 02440f6715
commit 000b42d204
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 152 additions and 57 deletions

View file

@ -1,19 +1,25 @@
export * as CodeModeCatalog from "./catalog.js"
import type { Namespace } from "@opencode-ai/schema/tool"
import { Schema } from "effect"
export const Tool = Schema.Struct({
path: Schema.String,
type: Schema.Literal("tool"),
name: Schema.String,
description: Schema.String,
signature: Schema.String,
pinned: Schema.optionalKey(Schema.Boolean),
})
export type Tool = typeof Tool.Type
export type Namespace = {
readonly type: "namespace"
readonly name: string
readonly description?: string
readonly tools: ReadonlyArray<Tool | Namespace>
}
export type Inventory = {
readonly tools: ReadonlyArray<Tool>
readonly namespaces?: ReadonlyMap<string, Namespace>
readonly tools: ReadonlyArray<Tool | Namespace>
}
const Listing = Schema.Struct({
@ -47,14 +53,15 @@ const INLINE_BUDGET = 2_000
// considering shorter listings first until the inline budget is exhausted.
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)]
const flattened = flatten(inventory.tools)
const namespaces = [...Map.groupBy(flattened.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 description = flattened.namespaces.get(name)?.description
const listings = namespaceEntries
.map((entry) => {
const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? ""
@ -126,12 +133,35 @@ export function summarize(inventory: Inventory, options: Options = {}): Summary
entries: namespace.listings.filter((listing) => namespace.selectedListings.has(listing)),
}))
return {
total: inventory.tools.length,
total: flattened.tools.length,
shown: namespaceSummaries.reduce((total, namespace) => total + namespace.entries.length, 0),
namespaces: namespaceSummaries,
}
}
function flatten(entries: ReadonlyArray<Tool | Namespace>, path: ReadonlyArray<string> = []) {
const tools: Array<Omit<Tool, "name"> & { readonly path: string }> = []
const namespaces = new Map<string, Namespace>()
for (const entry of entries) {
if (entry.type === "tool") {
tools.push({
type: "tool",
path: [...path, entry.name].join("."),
description: entry.description,
signature: entry.signature,
...(entry.pinned === undefined ? {} : { pinned: entry.pinned }),
})
continue
}
const next = [...path, entry.name]
namespaces.set(next.join("."), entry)
const nested = flatten(entry.tools, next)
tools.push(...nested.tools)
for (const [name, namespace] of nested.namespaces) namespaces.set(name, namespace)
}
return { tools, namespaces }
}
export function namespaceLine(namespace: typeof NamespaceSummary.Type) {
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
const label =

View file

@ -40,12 +40,14 @@ type CollectedFiles = {
readonly files: Array<typeof ExecuteFile.Type>
}
type ToolNode = {
tool?: Tool.Tool<never>
type Node<T> = {
tool?: T
namespace?: ToolNamespace
readonly children: Map<string, ToolNode>
readonly children: Map<string, Node<T>>
}
type ToolNode = Node<Tool.Tool<never>>
type Tools = {
[name: string]: Tool.Tool<never> | Namespace.Namespace<never> | Tools
}
@ -162,14 +164,43 @@ export const catalog = (inventory: Inventory) => {
.filter((registration) => registration.options?.pinned === true)
.map(qualifiedName),
)
const root: CatalogNode = { children: new Map() }
for (const namespace of inventory.namespaces?.values() ?? []) getNode(root, namespace.name).namespace = namespace
for (const tool of runtime(inventory, () => Effect.fail(toolError("Execute context is unavailable"))).catalog())
getNode(root, tool.path).tool = {
type: "tool",
name: tool.path.split(".").at(-1) ?? tool.path,
description: tool.description,
signature: tool.signature,
pinned: pinned.has(tool.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 }),
tools: renderCatalog(root),
} satisfies CodeModeCatalog.Inventory
}
type CatalogNode = Node<CodeModeCatalog.Tool>
function renderCatalog(root: CatalogNode): ReadonlyArray<CodeModeCatalog.Tool | CodeModeCatalog.Namespace> {
return Array.from(root.children)
.toSorted(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.flatMap(([name, node]) => {
const tools = renderCatalog(node)
const namespace =
node.namespace === undefined && tools.length === 0
? undefined
: {
type: "namespace" as const,
name,
...(node.namespace?.description === undefined ? {} : { description: node.namespace.description }),
tools,
}
if (node.tool === undefined) return namespace === undefined ? [] : [namespace]
if (namespace === undefined) return [node.tool]
return [node.tool, namespace]
})
}
function runtime(
inventory: Inventory,
executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect<unknown, unknown>,
@ -191,9 +222,9 @@ function runtime(
return CodeMode.make<typeof tools>({ tools, ...hooks })
}
function getNode(root: ToolNode, path: string) {
function getNode<T>(root: Node<T>, path: string) {
return path.split(".").reduce((parent, name) => {
const child: ToolNode = parent.children.get(name) ?? { children: new Map() }
const child: Node<T> = parent.children.get(name) ?? { children: new Map() }
parent.children.set(name, child)
return child
}, root)

View file

@ -10,7 +10,8 @@ describe("CodeMode", () => {
it.effect("owns registrations, execute, and catalog materialization", () =>
Effect.gen(function* () {
const tools = yield* Tool.Service
yield* tools.transform((draft) =>
yield* tools.transform((draft) => {
draft.namespace({ name: "empty", description: "No tools registered yet" })
draft.add({
name: "echo",
description: "Echo text",
@ -18,21 +19,27 @@ describe("CodeMode", () => {
output: Schema.String,
options: { pinned: true },
execute: ({ text }) => Effect.succeed({ output: text }),
}),
)
})
})
const snapshot = yield* tools.snapshot()
expect(snapshot.definitions.some((tool) => tool.name === "execute")).toBe(true)
expect(snapshot.codeModeCatalog).toStrictEqual({
tools: [
{
path: "echo",
type: "tool",
name: "echo",
description: "Echo text",
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
pinned: true,
},
{
type: "namespace",
name: "empty",
description: "No tools registered yet",
tools: [],
},
],
namespaces: new Map(),
})
}).pipe(
Effect.scoped,

View file

@ -3,7 +3,8 @@ 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.Tool => ({
path,
type: "tool",
name: path,
description,
signature: signature ?? `tools.${path}(input: {\n q: string,\n}): Promise<string>`,
pinned,
@ -15,12 +16,12 @@ const lookup = entry(
"tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>",
)
const render = (tools: ReadonlyArray<CodeModeCatalog.Tool>, budget?: number) =>
const render = (tools: CodeModeCatalog.Inventory["tools"], budget?: number) =>
CodeModeInstructions.render(CodeModeCatalog.summarize({ tools }, budget === undefined ? {} : { budget }))
const update = (
previous: ReadonlyArray<CodeModeCatalog.Tool>,
current: ReadonlyArray<CodeModeCatalog.Tool>,
previous: CodeModeCatalog.Inventory["tools"],
current: CodeModeCatalog.Inventory["tools"],
budget?: number,
) =>
CodeModeInstructions.update(
@ -103,10 +104,10 @@ describe("CodeModeCatalog.summarize", () => {
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 }]])
const namespace = { type: "namespace" as const, name: "alpha", description, tools: [tool] }
expect(CodeModeCatalog.summarize({ tools: [tool] }, { budget: namespaceCost + listingCost }).shown).toBe(1)
const catalog = CodeModeCatalog.summarize({ tools: [tool], namespaces }, { budget: namespaceCost + listingCost })
const catalog = CodeModeCatalog.summarize({ tools: [namespace] }, { 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}`)
@ -209,12 +210,10 @@ 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" }]]),
tools: [{ type: "namespace", name: "notes", description: "Old description", tools: [echo] }],
})
const current = CodeModeCatalog.summarize({
tools: [echo],
namespaces: new Map([["notes", { name: "notes", description: "New description" }]]),
tools: [{ type: "namespace", name: "notes", description: "New description", tools: [echo] }],
})
const text = CodeModeInstructions.update(previous, current)
expect(text).toContain("This catalog supersedes the previous Code Mode tool catalog.")

View file

@ -10,13 +10,15 @@ import { it } from "../lib/effect"
import { readInitial, readUpdate } from "../lib/instructions"
const echo: CodeModeCatalog.Tool = {
path: "notes.echo",
type: "tool",
name: "notes.echo",
description: "Echo text",
signature: "tools.notes.echo(input: {\n text: string,\n}): Promise<string>",
}
const lookup: CodeModeCatalog.Tool = {
path: "orders.lookup",
type: "tool",
name: "orders.lookup",
description: "Look up an order",
signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise<unknown>",
}

View file

@ -1,4 +1,5 @@
import { Agent } from "@opencode-ai/core/agent"
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
import type { Permission } from "@opencode-ai/core/permission"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
@ -16,6 +17,9 @@ export const toolIdentity = {
export const toolDefinitions = (registry: Tool.Interface, permissions?: Permission.Ruleset) =>
registry.snapshot(permissions).pipe(Effect.map((toolSet) => toolSet.definitions))
export const codeModeListings = (catalog: CodeModeCatalog.Inventory) =>
CodeModeCatalog.summarize(catalog, { budget: Infinity }).namespaces.flatMap((namespace) => namespace.entries)
export function waitForTool(registry: Tool.Interface, name: string, remaining = 1000): Effect.Effect<void, Error> {
return Effect.gen(function* () {
if ((yield* toolDefinitions(registry)).some((tool) => tool.name === name)) return
@ -35,7 +39,8 @@ export function waitForCodeModeTool(
): Effect.Effect<Tool.Snapshot, Error> {
return Effect.gen(function* () {
const toolSet = yield* registry.snapshot()
if (toolSet.codeModeCatalog?.tools.some((tool) => tool.path === path)) return toolSet
if (toolSet.codeModeCatalog && codeModeListings(toolSet.codeModeCatalog).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

@ -46,7 +46,7 @@ import { imagePassthrough } from "./lib/image"
import { location } from "./fixture/location"
import { tmpdirScoped } from "./fixture/tmpdir"
import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment"
import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
import { codeModeListings, executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
let assertion: Deferred.Deferred<Permission.AssertInput> | undefined
let decision: Effect.Effect<void, Permission.Error> = Effect.void
@ -1764,7 +1764,7 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
"direct_media",
"execute",
])
expect(toolSet.codeModeCatalog?.tools.find((tool) => tool.path === "demo.search")?.signature).toContain(
expect(codeModeListings(toolSet.codeModeCatalog!).find((tool) => tool.path === "demo.search")?.line).toContain(
"ok: boolean",
)
expect(execute?.description).not.toContain("tools.demo.search")
@ -1784,7 +1784,7 @@ 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?.tools.find((tool) => tool.path === "demo.search")?.signature).not.toContain(
expect(codeModeListings(toolSet.codeModeCatalog!).find((tool) => tool.path === "demo.search")?.line).not.toContain(
"sessionID",
)
@ -1830,7 +1830,7 @@ it.effect("returns content-only MCP results through Code Mode", () =>
yield* registration.flush
const toolSet = yield* registry.snapshot()
expect(toolSet.codeModeCatalog?.tools.some((tool) => tool.path === "demo.status")).toBe(true)
expect(codeModeListings(toolSet.codeModeCatalog!).some((tool) => tool.path === "demo.status")).toBe(true)
const execution = yield* toolSet.execute({
sessionID: Session.ID.make("ses_mcp_content_only"),
@ -1916,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?.tools.some((tool) => tool.path === "demo.search")).toBe(true)
expect(codeModeListings(toolSet.codeModeCatalog!).some((tool) => tool.path === "demo.search")).toBe(true)
const fiber = yield* toolSet
.execute({
@ -1960,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?.tools.some((tool) => tool.path === "demo.search")).toBe(true)
expect(codeModeListings(toolSet.codeModeCatalog!).some((tool) => tool.path === "demo.search")).toBe(true)
const execution = yield* toolSet.execute({
sessionID: Session.ID.make("ses_mcp_blocked"),

View file

@ -18,6 +18,7 @@ import { Tool } from "@opencode-ai/core/tool"
import { Provider } from "@opencode-ai/core/provider"
import { Project } from "@opencode-ai/core/project"
import { Workspace } from "@opencode-ai/core/workspace"
import { codeModeListings } from "../lib/tool"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { define } from "@opencode-ai/plugin/promise/plugin"
import type { Info } from "@opencode-ai/plugin/promise/tool"
@ -981,7 +982,7 @@ describe("fromPromise", () => {
const snapshot = yield* registry.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["acme.hello"])
expect(codeModeListings(snapshot.codeModeCatalog!).map((tool) => tool.path)).toEqual(["acme.hello"])
expect(original.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"])
expect(
yield* snapshot.execute({

View file

@ -118,7 +118,8 @@ const tools = Layer.mock(Tool.Service, {
codeModeCatalog: {
tools: [
{
path: "captured.lookup",
type: "tool",
name: "captured.lookup",
description: "Captured Code Mode catalog",
signature: "tools.captured.lookup(input: {}): Promise<string>",
},

View file

@ -14,7 +14,7 @@ import { State } from "@opencode-ai/core/state"
import { Tool } from "@opencode-ai/core/tool"
import type { Info } from "@opencode-ai/schema/tool"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { executeTool, toolDefinitions } from "./lib/tool"
import { codeModeListings, executeTool, toolDefinitions } from "./lib/tool"
import { Deferred, Effect, Exit, Fiber, Layer, Logger, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { TestClock } from "effect/testing"
import { z } from "zod"
@ -247,7 +247,9 @@ 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?.tools.map((tool) => tool.path)).toEqual(["hidden"])
expect(codeModeListings((yield* service.snapshot()).codeModeCatalog!).map((tool) => tool.path)).toEqual([
"hidden",
])
yield* service.transform((draft) =>
draft.update("acme_echo", (tool) => {
@ -502,7 +504,7 @@ describe("Tool", () => {
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual([
expect(codeModeListings(snapshot.codeModeCatalog!).map((tool) => tool.path)).toEqual([
"-lookup",
"123",
"123._private.-tools.2d_get_scene",
@ -542,7 +544,9 @@ describe("Tool", () => {
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["first", "execute"])
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["invalid__namespace.second"])
expect(codeModeListings(snapshot.codeModeCatalog!).map((tool) => tool.path)).toEqual([
"invalid__namespace.second",
])
}),
)
@ -560,17 +564,32 @@ describe("Tool", () => {
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([
expect(codeModeListings(snapshot.codeModeCatalog!).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" }],
]),
)
expect(snapshot.codeModeCatalog?.tools).toEqual([
{
type: "namespace",
name: "legacy",
tools: [expect.objectContaining({ type: "tool", name: "plain" })],
},
{
type: "namespace",
name: "registry",
description: "Package publishing and discovery",
tools: [
expect.objectContaining({ type: "tool", name: "search" }),
{
type: "namespace",
name: "search",
description: "Pricing operations",
tools: [expect.objectContaining({ type: "tool", name: "sales" })],
},
],
},
])
const result = yield* snapshot.execute({
...call("execute"),
call: {
@ -615,7 +634,7 @@ describe("Tool", () => {
})
const snapshot = yield* service.snapshot()
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["pricing", "pricing.sales"])
expect(codeModeListings(snapshot.codeModeCatalog!).map((tool) => tool.path)).toEqual(["pricing", "pricing.sales"])
const result = yield* snapshot.execute({
...call("execute"),
call: {
@ -664,7 +683,7 @@ describe("Tool", () => {
])
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["codemode"])
expect(codeModeListings(snapshot.codeModeCatalog!).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])))
})
@ -729,7 +748,7 @@ describe("Tool", () => {
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(snapshot.codeModeCatalog?.tools[0]?.signature).toContain("tools.echo")
expect(codeModeListings(snapshot.codeModeCatalog!)[0]?.line).toContain("tools.echo")
}),
)
@ -1192,7 +1211,7 @@ describe("Tool", () => {
}).pipe(Scope.provide(scope))
const toolSet = yield* service.snapshot()
const execute = toolSet.definitions.find((tool) => tool.name === "execute")
expect(toolSet.codeModeCatalog?.tools[0]?.signature).toContain("tools.echo")
expect(codeModeListings(toolSet.codeModeCatalog!)[0]?.line).toContain("tools.echo")
expect(execute?.description).toContain("confined Code Mode runtime")
expect(execute?.description).not.toContain("Echo text")
yield* Scope.close(scope, Exit.void)