From d04257eeb45a0595bc4568d43e37f3bdeac4a52c Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:01:14 -0500 Subject: [PATCH] feat(codemode): add inline namespace metadata (#46464) --- packages/codemode/README.md | 23 +++++++-- packages/codemode/src/index.ts | 1 + packages/codemode/src/namespace.ts | 24 +++++++++ packages/codemode/src/tool-runtime.ts | 59 +++++++++++++---------- packages/codemode/src/tool.ts | 11 ++--- packages/codemode/src/tools.ts | 3 +- packages/codemode/test/openapi.test.ts | 10 ++-- packages/codemode/test/tool-paths.test.ts | 44 ++++++++++++++++- 8 files changed, 133 insertions(+), 42 deletions(-) create mode 100644 packages/codemode/src/namespace.ts diff --git a/packages/codemode/README.md b/packages/codemode/README.md index ff4eb298aa1..f6f47f35dbc 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -26,7 +26,7 @@ Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source locat ## Quick Start ```ts -import { CodeMode, Tool } from "@opencode-ai/codemode" +import { CodeMode, Namespace, Tool } from "@opencode-ai/codemode" import { Effect, Schema } from "effect" const lookupOrder = Tool.make({ @@ -60,9 +60,22 @@ only shape the model-visible signature. Without `output`, the signature uses `Pr Descriptions and schemas are model-visible contracts. Authorization belongs in `execute`. -Dots in tool names create namespaces: `{ "issues.list": tool }` and `{ issues: { list: tool } }` both expose -`tools.issues.list(...)`. Other characters use bracket notation, such as -`tools.context7["resolve-library-id"](...)`. +Nested records are the shorthand for ordinary namespaces. Use `Namespace.make` when a namespace needs a description: + +```ts +const runtime = CodeMode.make({ + tools: { + orders: Namespace.make({ + description: "Purchases, fulfillment, and shipment tracking", + tools: { lookup: lookupOrder }, + }), + }, +}) +``` + +Namespace descriptions are optional and participate in search matching for every descendant tool. Names still come +from record keys, so the wrapper does not repeat `orders`. Dots in keys create nested paths; other characters use +bracket notation, such as `tools.context7["resolve-library-id"](...)`. ### `CodeMode.execute` and `CodeMode.make` @@ -150,7 +163,7 @@ and `CodeMode.toolExpression(path)` supply the exact callable forms. The synchronous `search(...)` built-in is always available. It supports exact-path lookup, namespace-scoped search, empty-query browsing, and pagination, and returns callable paths with full signatures. Search counts toward -`maxToolCalls`. +`maxToolCalls`. Search also matches descriptions from enclosing `Namespace` values. ## Execution Limits diff --git a/packages/codemode/src/index.ts b/packages/codemode/src/index.ts index fe43be2290d..2528c7acf6f 100644 --- a/packages/codemode/src/index.ts +++ b/packages/codemode/src/index.ts @@ -1,4 +1,5 @@ export * as CodeMode from "./codemode.js" +export * as Namespace from "./namespace.js" export * as Tool from "./tool.js" export * as OpenAPI from "./openapi/index.js" export { searchSignature, toolExpression } from "./codemode.js" diff --git a/packages/codemode/src/namespace.ts b/packages/codemode/src/namespace.ts new file mode 100644 index 00000000000..1446540ef24 --- /dev/null +++ b/packages/codemode/src/namespace.ts @@ -0,0 +1,24 @@ +import type { Tools } from "./tools.js" + +/** A tool namespace with optional model-visible metadata. */ +export type Namespace = { + readonly _tag: "CodeModeNamespace" + readonly description?: string + readonly tools: Tools +} + +/** Options for declaring one CodeMode namespace. */ +export type Options = { + readonly description?: string + readonly tools: Tools +} + +export const isNamespace = (value: Namespace | Tools): value is Namespace => + Object.hasOwn(value, "_tag") && value._tag === "CodeModeNamespace" + +/** Declares a namespace when descriptions or other namespace metadata are needed. */ +export const make = (options: Options): Namespace => ({ + _tag: "CodeModeNamespace", + ...(options.description === undefined ? {} : { description: options.description }), + tools: options.tools, +}) diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index d3ff5e6e8d5..6976e0d1588 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -8,6 +8,7 @@ import { inputTypeScript, outputTypeScript, } from "./tool-schema.js" +import { isNamespace, type Namespace } from "./namespace.js" import { isTool, type Tool } from "./tool.js" import type { Tools } from "./tools.js" import { @@ -277,6 +278,7 @@ export const copyOut = (value: unknown, mode: CopyOutMode): unknown => { // Dots in tool names are namespace separators; the last tool for a canonical path wins. type ToolNode = { tool?: Tool + namespace?: Namespace readonly children: Map> } @@ -292,7 +294,10 @@ const toolTrie = (tools: Tools): ToolNode => { current = child } if (isTool(value)) current.tool = value - else insert(current, value) + else if (isNamespace(value)) { + current.namespace = value + insert(current, value.tools) + } else insert(current, value) } } insert(root, tools) @@ -302,29 +307,33 @@ const toolTrie = (tools: Tools): ToolNode => { const canonicalSegments = (path: ReadonlyArray): ReadonlyArray => path.flatMap((segment) => segment.split(".")) +type VisibleTool = { + readonly path: string + readonly tool: Tool + readonly namespaces: ReadonlyArray> +} + const flattenTools = ( node: ToolNode, path: ReadonlyArray = [], -): Array<{ path: string; tool: Tool }> => [ - ...(node.tool === undefined ? [] : [{ path: path.join("."), tool: node.tool }]), - ...Array.from(node.children, ([name, child]) => flattenTools(child, [...path, name])).flat(), -] + namespaces: ReadonlyArray> = [], +): Array> => { + const next = node.namespace === undefined ? namespaces : [...namespaces, node.namespace] + return [ + ...(node.tool === undefined ? [] : [{ path: path.join("."), tool: node.tool, namespaces: next }]), + ...Array.from(node.children).flatMap(([name, child]) => flattenTools(child, [...path, name], next)), + ] +} -const describeTool = (path: string, tool: Tool): ToolDescription => ({ - path, - description: tool.description, - signature: `${toolExpression(path)}(input: ${inputTypeScript(tool, true)}): Promise<${outputTypeScript(tool, true)}>`, +const describeTool = (visible: VisibleTool): ToolDescription => ({ + path: visible.path, + description: visible.tool.description, + signature: `${toolExpression(visible.path)}(input: ${inputTypeScript(visible.tool, true)}): Promise<${outputTypeScript(visible.tool, true)}>`, }) // Discovery bytes are durable instructions, so order only after canonical-path collisions settle. const visibleTools = (tools: Tools) => - flattenTools(toolTrie(tools)) - .sort((left, right) => compareText(left.path, right.path)) - .map(({ path, tool }) => ({ - path, - tool, - description: describeTool(path, tool), - })) + flattenTools(toolTrie(tools)).sort((left, right) => compareText(left.path, right.path)) export type DiscoveryPlan = { readonly catalog: ReadonlyArray @@ -420,12 +429,13 @@ export const searchSignature = (() => { return `search(input: ${inputTypeScript(tool, true)}): ${outputTypeScript(tool, true)}` })() -const toSearchEntry = (path: string, tool: Tool, description: ToolDescription): SearchEntry => ({ - description, +const toSearchEntry = (visible: VisibleTool): SearchEntry => ({ + description: describeTool(visible), searchText: [ - path, - tool.description, - ...inputProperties(tool).flatMap(({ name, description: property }) => + visible.path, + visible.tool.description, + ...visible.namespaces.flatMap((namespace) => (namespace.description === undefined ? [] : [namespace.description])), + ...inputProperties(visible.tool).flatMap(({ name, description: property }) => property === undefined ? [name] : [name, property], ), ] @@ -433,14 +443,13 @@ const toSearchEntry = (path: string, tool: Tool, description: ToolDescript .toLowerCase(), }) -export const searchIndex = (tools: Tools): ReadonlyArray => - visibleTools(tools).map(({ path, tool, description }) => toSearchEntry(path, tool, description)) +export const searchIndex = (tools: Tools): ReadonlyArray => visibleTools(tools).map(toSearchEntry) export const prepare = (tools: Tools): DiscoveryPlan => { const visible = visibleTools(tools) return { - catalog: visible.map(({ description }) => description), - searchIndex: visible.map(({ path, tool, description }) => toSearchEntry(path, tool, description)), + catalog: visible.map(describeTool), + searchIndex: visible.map(toSearchEntry), } } diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts index 93b5a23598c..bfa4ba6babb 100644 --- a/packages/codemode/src/tool.ts +++ b/packages/codemode/src/tool.ts @@ -1,4 +1,6 @@ import { Effect, Schema } from "effect" +import type { Namespace } from "./namespace.js" +import type { Tools } from "./tools.js" /** * JSON Schema subset for model-visible signatures. CodeMode does not validate values against @@ -50,13 +52,8 @@ export type Options) => Effect.Effect, unknown, R> } -// Object.hasOwn: an inherited _tag must not classify a namespace as a Tool. -export const isTool = (value: unknown): value is Tool => - typeof value === "object" && - value !== null && - "_tag" in value && - Object.hasOwn(value, "_tag") && - value._tag === "CodeModeTool" +export const isTool = (value: Tool | Namespace | Tools | undefined): value is Tool => + value !== undefined && Object.hasOwn(value, "_tag") && value._tag === "CodeModeTool" /** * Declares one schema-described tool available to a CodeMode program through `tools.*`. diff --git a/packages/codemode/src/tools.ts b/packages/codemode/src/tools.ts index 8c9759fb338..f2f0d2083dd 100644 --- a/packages/codemode/src/tools.ts +++ b/packages/codemode/src/tools.ts @@ -1,5 +1,6 @@ +import type { Namespace } from "./namespace.js" import type { Tool } from "./tool.js" export type Tools = { - readonly [name: string]: Tool | Tools + readonly [name: string]: Tool | Namespace | Tools } diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index da705977bfa..93888f670a9 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -25,8 +25,12 @@ const happyPathSpec = async (): Promise => { const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) -const toolAt = (tools: unknown, name: string) => - name.split(".").reduce((current, segment) => (isRecord(current) ? current[segment] : undefined), tools) +const toolAt = (tools: OpenAPI.Tools, name: string) => + name + .split(".") + .reduce< + Tool.Tool | OpenAPI.Tools | undefined + >((current, segment) => (current !== undefined && !Tool.isTool(current) ? current[segment] : undefined), tools) const recordingClient = (respond: (request: HttpClientRequest.HttpClientRequest) => Response) => { const requests: Array = [] @@ -948,7 +952,7 @@ describe("OpenAPI.fromSpec", () => { expect(spec.security).toStrictEqual([]) expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([]) const health = toolAt(result.tools, "v2.health.get") - const healthInput = isRecord(health) ? health.input : undefined + const healthInput = Tool.isTool(health) && isRecord(health.input) ? health.input : undefined expect(healthInput).toMatchObject({ type: "object", properties: {} }) const input = isRecord(healthInput) ? healthInput : {} expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([]) diff --git a/packages/codemode/test/tool-paths.test.ts b/packages/codemode/test/tool-paths.test.ts index b90b7194442..f4665aae050 100644 --- a/packages/codemode/test/tool-paths.test.ts +++ b/packages/codemode/test/tool-paths.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema } from "effect" -import { CodeMode, Tool } from "../src/index.js" +import { CodeMode, Namespace, Tool } from "../src/index.js" const echo = (description: string, result: string) => Tool.make({ @@ -177,6 +177,48 @@ describe("blocked member names on tool paths", () => { }) }) +describe("namespace metadata", () => { + const tools = { + api: Namespace.make({ + description: "Manage the workspace", + tools: { + users: Namespace.make({ + description: "Directory and account administration", + tools: { list: echo("List users", "users") }, + }), + status: echo("Read service status", "ok"), + }, + }), + plain: { read: echo("Read plain data", "plain") }, + } + const runtime = CodeMode.make({ tools }) + + test("the wrapper does not add a segment to callable paths", async () => { + expect(runtime.catalog().map((tool) => tool.path)).toEqual(["api.status", "api.users.list", "plain.read"]) + expect(await value(runtime, `return await tools.api.users.list({})`)).toBe("users") + }) + + test("search matches descriptions from every enclosing namespace", async () => { + const workspace = await value(runtime, `return search({ query: "workspace" })`) + expect((workspace as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([ + "tools.api.status", + "tools.api.users.list", + ]) + + const directory = await value(runtime, `return search({ query: "account administration" })`) + expect((directory as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([ + "tools.api.users.list", + ]) + }) + + test("a namespace description is optional", async () => { + const optional = CodeMode.make({ + tools: { api: Namespace.make({ tools: { read: echo("Read data", "read") } }) }, + }) + expect(await value(optional, `return await tools.api.read({})`)).toBe("read") + }) +}) + describe("empty segments", () => { test("tool names with empty segments are rejected at make", () => { for (const name of ["", "a..b", "trail.", ".lead"]) {