mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-01 04:34:32 +00:00
feat(codemode): add inline namespace metadata (#46464)
This commit is contained in:
parent
d68f425c17
commit
d04257eeb4
8 changed files with 133 additions and 42 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
24
packages/codemode/src/namespace.ts
Normal file
24
packages/codemode/src/namespace.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { Tools } from "./tools.js"
|
||||
|
||||
/** A tool namespace with optional model-visible metadata. */
|
||||
export type Namespace<R = never> = {
|
||||
readonly _tag: "CodeModeNamespace"
|
||||
readonly description?: string
|
||||
readonly tools: Tools<R>
|
||||
}
|
||||
|
||||
/** Options for declaring one CodeMode namespace. */
|
||||
export type Options<R = never> = {
|
||||
readonly description?: string
|
||||
readonly tools: Tools<R>
|
||||
}
|
||||
|
||||
export const isNamespace = <R = never>(value: Namespace<R> | Tools<R>): value is Namespace<R> =>
|
||||
Object.hasOwn(value, "_tag") && value._tag === "CodeModeNamespace"
|
||||
|
||||
/** Declares a namespace when descriptions or other namespace metadata are needed. */
|
||||
export const make = <R = never>(options: Options<R>): Namespace<R> => ({
|
||||
_tag: "CodeModeNamespace",
|
||||
...(options.description === undefined ? {} : { description: options.description }),
|
||||
tools: options.tools,
|
||||
})
|
||||
|
|
@ -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<R> = {
|
||||
tool?: Tool<R>
|
||||
namespace?: Namespace<R>
|
||||
readonly children: Map<string, ToolNode<R>>
|
||||
}
|
||||
|
||||
|
|
@ -292,7 +294,10 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
|
|||
current = child
|
||||
}
|
||||
if (isTool<R>(value)) current.tool = value
|
||||
else insert(current, value)
|
||||
else if (isNamespace<R>(value)) {
|
||||
current.namespace = value
|
||||
insert(current, value.tools)
|
||||
} else insert(current, value)
|
||||
}
|
||||
}
|
||||
insert(root, tools)
|
||||
|
|
@ -302,29 +307,33 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
|
|||
const canonicalSegments = (path: ReadonlyArray<string>): ReadonlyArray<string> =>
|
||||
path.flatMap((segment) => segment.split("."))
|
||||
|
||||
type VisibleTool<R> = {
|
||||
readonly path: string
|
||||
readonly tool: Tool<R>
|
||||
readonly namespaces: ReadonlyArray<Namespace<R>>
|
||||
}
|
||||
|
||||
const flattenTools = <R>(
|
||||
node: ToolNode<R>,
|
||||
path: ReadonlyArray<string> = [],
|
||||
): Array<{ path: string; tool: Tool<R> }> => [
|
||||
...(node.tool === undefined ? [] : [{ path: path.join("."), tool: node.tool }]),
|
||||
...Array.from(node.children, ([name, child]) => flattenTools(child, [...path, name])).flat(),
|
||||
]
|
||||
namespaces: ReadonlyArray<Namespace<R>> = [],
|
||||
): Array<VisibleTool<R>> => {
|
||||
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 = <R>(path: string, tool: Tool<R>): ToolDescription => ({
|
||||
path,
|
||||
description: tool.description,
|
||||
signature: `${toolExpression(path)}(input: ${inputTypeScript(tool, true)}): Promise<${outputTypeScript(tool, true)}>`,
|
||||
const describeTool = <R>(visible: VisibleTool<R>): 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 = <R>(tools: Tools<R>) =>
|
||||
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<ToolDescription>
|
||||
|
|
@ -420,12 +429,13 @@ export const searchSignature = (() => {
|
|||
return `search(input: ${inputTypeScript(tool, true)}): ${outputTypeScript(tool, true)}`
|
||||
})()
|
||||
|
||||
const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescription): SearchEntry => ({
|
||||
description,
|
||||
const toSearchEntry = <R>(visible: VisibleTool<R>): 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 = <R>(path: string, tool: Tool<R>, description: ToolDescript
|
|||
.toLowerCase(),
|
||||
})
|
||||
|
||||
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> =>
|
||||
visibleTools(tools).map(({ path, tool, description }) => toSearchEntry(path, tool, description))
|
||||
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> => visibleTools(tools).map(toSearchEntry)
|
||||
|
||||
export const prepare = <R>(tools: Tools<R>): 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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<I extends SchemaType, O extends SchemaType | undefined, R =
|
|||
readonly execute: (input: InputType<I>) => Effect.Effect<ResultType<O>, unknown, R>
|
||||
}
|
||||
|
||||
// Object.hasOwn: an inherited _tag must not classify a namespace as a Tool.
|
||||
export const isTool = <R = never>(value: unknown): value is Tool<R> =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"_tag" in value &&
|
||||
Object.hasOwn(value, "_tag") &&
|
||||
value._tag === "CodeModeTool"
|
||||
export const isTool = <R = never>(value: Tool<R> | Namespace<R> | Tools<R> | undefined): value is Tool<R> =>
|
||||
value !== undefined && Object.hasOwn(value, "_tag") && value._tag === "CodeModeTool"
|
||||
|
||||
/**
|
||||
* Declares one schema-described tool available to a CodeMode program through `tools.*`.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Namespace } from "./namespace.js"
|
||||
import type { Tool } from "./tool.js"
|
||||
|
||||
export type Tools<R = never> = {
|
||||
readonly [name: string]: Tool<R> | Tools<R>
|
||||
readonly [name: string]: Tool<R> | Namespace<R> | Tools<R>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,8 +25,12 @@ const happyPathSpec = async (): Promise<Document> => {
|
|||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
|
||||
const toolAt = (tools: unknown, name: string) =>
|
||||
name.split(".").reduce<unknown>((current, segment) => (isRecord(current) ? current[segment] : undefined), tools)
|
||||
const toolAt = (tools: OpenAPI.Tools, name: string) =>
|
||||
name
|
||||
.split(".")
|
||||
.reduce<
|
||||
Tool.Tool<HttpClient.HttpClient> | OpenAPI.Tools | undefined
|
||||
>((current, segment) => (current !== undefined && !Tool.isTool(current) ? current[segment] : undefined), tools)
|
||||
|
||||
const recordingClient = (respond: (request: HttpClientRequest.HttpClientRequest) => Response) => {
|
||||
const requests: Array<Recorded> = []
|
||||
|
|
@ -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([])
|
||||
|
|
|
|||
|
|
@ -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"]) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue