diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 923da8d441d..b4383190373 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -72,7 +72,6 @@ Dots in tool names create namespaces: `{ "issues.list": tool }` and `{ issues: { const runtime = CodeMode.make({ tools, limits: { timeoutMs: 30_000 } }) runtime.catalog() // structured tool descriptions -runtime.instructions() // model-facing syntax and tool guide runtime.execute(source) // Effect ``` @@ -145,13 +144,13 @@ safe refusal to the model; its optional cause remains private. ## Discovery -Generated instructions contain a tool catalog with a default budget of 2,000 estimated tokens. Configure it with -`discovery: { catalogBudget }`. Every namespace remains visible, and the instructions say whether the catalog is -complete or partial. +`runtime.catalog()` returns structured descriptors — exact path, description, and generated TypeScript signature — for +every visible tool. Hosts render their own model-facing instructions from these descriptors; `CodeMode.searchSignature` +and `CodeMode.toolExpression(path)` supply the exact callable forms. -The synchronous `search(...)` built-in is always available and advertised when the catalog is partial. It supports -exact-path lookup, namespace-scoped search, empty-query browsing, and pagination, and returns callable paths with full -signatures. Search counts toward `maxToolCalls`. +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`. ## Execution Limits diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index 1f99c01e741..786b8b4f4ea 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -5,6 +5,8 @@ import type { Tools } from "./tools.js" /** A tool call admitted during an execution. */ export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js" +/** Signature-construction helpers for host-owned catalog instructions. */ +export { searchSignature, toolExpression } from "./tool-runtime.js" /** Resource budgets enforced independently during each CodeMode program execution. */ export type ExecutionLimits = { @@ -22,12 +24,6 @@ export type ExecutionLimits = { readonly maxOutputBytes?: number } -/** Controls how much of the tool catalog is inlined in agent instructions. */ -export type DiscoveryOptions = { - /** Approximate token budget (chars/4, default 2000) for full catalog entries. */ - readonly catalogBudget?: number -} - export type ResolvedExecutionLimits = { readonly timeoutMs: number | undefined readonly maxToolCalls: number | undefined @@ -52,10 +48,7 @@ export type ExecuteOptions = {}> = { export type DataValue = Schema.Json /** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */ -export type Options = {}> = Omit, "code"> & { - /** Progressive-disclosure configuration for the agent-facing tool catalog. */ - readonly discovery?: DiscoveryOptions -} +export type Options = {}> = Omit, "code"> /** Schema for a host tool input containing CodeMode source. */ export const Input = Schema.Struct({ code: Schema.String }) @@ -116,7 +109,6 @@ export type Result = typeof Result.Type /** Reusable confined runtime over explicit tools. */ export type Runtime = { readonly catalog: () => ReadonlyArray - readonly instructions: () => string readonly execute: (code: string) => Effect.Effect } @@ -147,11 +139,10 @@ export const make = = {}>( ): Runtime> => { const tools = (options.tools ?? {}) as Tools> const limits = resolveExecutionLimits(options.limits) - const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget) + const prepared = ToolRuntime.prepare(tools) return { catalog: () => prepared.catalog, - instructions: () => prepared.instructions, execute: (code) => executeWithLimits({ ...options, code }, limits, prepared.searchIndex), } } diff --git a/packages/codemode/src/index.ts b/packages/codemode/src/index.ts index 92019825004..fe43be2290d 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 Tool from "./tool.js" export * as OpenAPI from "./openapi/index.js" +export { searchSignature, toolExpression } from "./codemode.js" export { ToolError, toolError } from "./tool-error.js" diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 053bbee6225..e39c30e38a9 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -20,7 +20,6 @@ import { CodeModeURLSearchParams, } from "./values.js" -const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4)) const compareText = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0) export type Services = ServicesOf @@ -70,7 +69,6 @@ export type ToolDescription = { export type SafeObject = Record -const defaultCatalogBudget = 2_000 const defaultSearchLimit = 10 const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) @@ -90,7 +88,7 @@ const SearchOutput = Schema.Struct({ remaining: NonNegativeInt, next: Schema.NullOr(Schema.Struct({ offset: NonNegativeInt })), }) -const toolExpression = (path: string) => +export const toolExpression = (path: string) => "tools" + path .split(".") @@ -339,7 +337,6 @@ const visibleTools = (tools: Tools) => export type DiscoveryPlan = { readonly catalog: ReadonlyArray - readonly instructions: string readonly searchIndex: ReadonlyArray } @@ -423,17 +420,12 @@ const makeSearchTool = (searchIndex: ReadonlyArray): Tool => ({ }), }) -const searchSignature = (() => { +/** Exact callable signature of the built-in `search` function, for host-owned instructions. */ +export const searchSignature = (() => { const tool = makeSearchTool([]) return `search(input: ${inputTypeScript(tool, true)}): ${outputTypeScript(tool, true)}` })() -const catalogLine = (tool: ToolDescription) => { - const line = tool.description.split("\n", 1)[0]!.trim() - const description = line.length > 120 ? line.slice(0, 119) + "..." : line - return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}` -} - const toSearchEntry = (path: string, tool: Tool, description: ToolDescription): SearchEntry => ({ description, namespace: path.split(".", 1)[0]!, @@ -451,146 +443,10 @@ const toSearchEntry = (path: string, tool: Tool, description: ToolDescript export const searchIndex = (tools: Tools): ReadonlyArray => visibleTools(tools).map(({ path, tool, description }) => toSearchEntry(path, tool, description)) -// Budget signatures round-robin so every namespace remains visible. -export const prepare = (tools: Tools, catalogBudget = defaultCatalogBudget): DiscoveryPlan => { - if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) { - throw new RangeError("discovery.catalogBudget must be a non-negative safe integer") - } +export const prepare = (tools: Tools): DiscoveryPlan => { const visible = visibleTools(tools) - const described = visible.map(({ description }) => description) - - const namespaces = new Map>() - for (const tool of described) { - const [namespace = tool.path] = tool.path.split(".") - const group = namespaces.get(namespace) ?? [] - group.push(tool) - namespaces.set(namespace, group) - } - const ordered = [...namespaces].sort(([left], [right]) => compareText(left, right)) - - const selections = ordered.map(([namespace, group]) => ({ - namespace, - picked: new Set(), - queue: [...group].sort( - (left, right) => - estimateTokens(catalogLine(left)) - estimateTokens(catalogLine(right)) || compareText(left.path, right.path), - ), - })) - let used = 0 - let active = selections.filter((selection) => selection.queue.length > 0) - while (active.length > 0) { - const stillActive: typeof active = [] - for (const selection of active) { - const tool = selection.queue[0]! - const cost = estimateTokens(catalogLine(tool)) - if (used + cost > catalogBudget) continue - selection.queue.shift() - selection.picked.add(tool) - used += cost - if (selection.queue.length > 0) stillActive.push(selection) - } - active = stillActive - } - const shown = new Map>( - selections.map(({ namespace, picked }) => [namespace, picked]), - ) - const totalShown = selections.reduce((total, { picked }) => total + picked.size, 0) - const complete = totalShown === described.length - - const empty = described.length === 0 - - const intro = [ - empty - ? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime." - : complete - ? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the tools listed below; surrounding agent tools are not available." - : "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the tools listed or searchable below; surrounding agent tools are not available.", - ...(empty - ? [] - : ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]), - ] - - const workflow = empty - ? [] - : [ - "", - "## Workflow", - "", - ...(complete - ? [ - "1. Pick a tool from the list under `## Available tools` - each line is the exact call signature; use it as-is rather than guessing segments.", - "2. Call it using the exact signature shown: `const result = await tools..(input)`; bracket notation and quotes are part of the path.", - "3. Return only the fields you need from structured results; narrow unknown results before reading fields, and avoid returning large raw payloads.", - ] - : [ - '1. If needed, discover tools with the built-in search function: `return search({ query: "" })`.', - "2. In the next execution, copy a returned path exactly, call it, and return only the needed fields.", - ]), - ] - - const rules = empty - ? [] - : [ - "", - "## Rules", - "", - complete - ? "- Only tools listed here are available; surrounding agent tools are not implicitly exposed." - : "- Only tools listed here or returned by the built-in `search` function are available; surrounding agent tools are not implicitly exposed.", - "- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.", - "- A result typed `Promise` may be structured data or text. Before reading fields, check that it is a non-null object and not an array; otherwise handle the returned text or primitive directly.", - '- Run independent calls in parallel: `await Promise.all(items.map((item) => tools..(item)))`, or use `tools.["tool-name"](item)` when the listed signature uses bracket notation.', - "- Execution ends when the program returns; pending promises are interrupted, so await every call whose completion matters.", - "- `Object.keys(tools)` lists namespaces; `Object.keys(tools.)` lists its tools; `for...in` works on both.", - ...(complete - ? [] - : [ - '- Browse one namespace: `search({ query: "", namespace: "" })`.', - "- If search returns `next`, repeat the same search with `offset: next.offset`.", - ]), - ] - - const language = [ - "", - "## Language", - "", - "Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.", - "Modules/imports, classes, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use tools for external operations. Use await with try/catch.", - "Prefer explicit `return`; otherwise only the final top-level expression becomes the result.", - "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.", - ] - - const toolSection: Array = [""] - if (empty) { - toolSection.push("## Available tools", "", "No tools are currently available.") - } else { - toolSection.push( - complete - ? "## Available tools (COMPLETE list - every tool is shown below with its full call signature)" - : `## Available tools (PARTIAL - ${totalShown} of ${described.length} shown; find the rest with search(...))`, - "", - ) - for (const [namespace, group] of ordered) { - const picked = shown.get(namespace)! - const count = `${group.length} tool${group.length === 1 ? "" : "s"}` - const label = - picked.size === group.length - ? count - : picked.size === 0 - ? `${count}, none shown` - : `${count}, ${picked.size} shown` - toolSection.push(`- ${namespace} (${label})`) - for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool)) - } - if (!complete) { - toolSection.push("", "Search returns complete callable signatures:", `- ${searchSignature}`) - } - } - - const lines = [...intro, ...workflow, ...rules, ...language, ...toolSection] return { - catalog: described, - instructions: lines.join("\n"), + catalog: visible.map(({ description }) => description), searchIndex: visible.map(({ path, tool, description }) => toSearchEntry(path, tool, description)), } } @@ -612,7 +468,7 @@ const resolve = (root: ToolNode, path: ReadonlyArray): Tool => const node = lookup(root, segments) if (node === undefined) { throw new ToolRuntimeError("UnknownTool", `Unknown tool '${segments.join(".")}'.`, [ - "Use search({ query }) to find available described tools.", + "The tool may have been removed or renamed. Use search to find available tools.", ]) } if (node.tool === undefined) { @@ -685,7 +541,11 @@ export const make = ( const input = yield* Effect.try({ try: () => decodeToolInput(tool, externalArgs[0]), catch: (cause) => - new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`), + new ToolRuntimeError( + "InvalidToolInput", + `Invalid input for tool '${name}': ${String(cause)}`, + name === "search" ? [] : ["The signature may have changed. Use search to get the current signature."], + ), }) const index = yield* recordAndObserve(name, input) return yield* observeEnd( diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 2d83f4bcb4c..86b1e076f5e 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -592,7 +592,7 @@ describe("CodeMode public contract", () => { expect(second).toStrictEqual({ ok: true, value: 1, logs: ["hi"], toolCalls: [{ name: "host.echo" }] }) }) - test("inlines a COMPLETE small catalog and keeps search registered but unadvertised", async () => { + test("describes the catalog and keeps the search built-in registered", async () => { const runtime = CodeMode.make({ tools }) expect(runtime.catalog()).toStrictEqual([ { @@ -601,16 +601,7 @@ describe("CodeMode public contract", () => { signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>", }, ]) - expect(runtime.instructions()).toContain("Available tools (COMPLETE list") - expect(runtime.instructions()).toContain("- orders (1 tool)") - expect(runtime.instructions()).toContain( - " - tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}> // Look up an order by ID", - ) - // A fully inlined catalog does not advertise search in the instructions... - expect(runtime.instructions()).not.toContain("search(") - // ...but the search built-in stays available, so a speculative call still works with the - // same signature as the inline catalog. const result = await Effect.runPromise(runtime.execute(`return search({ query: "order" })`)) expect(result.ok).toBe(true) if (result.ok) { @@ -646,22 +637,7 @@ describe("CodeMode public contract", () => { const second = CodeMode.make({ tools: { alpha: { alpha, zeta }, zeta: { alpha, zeta } } }) expect(first.catalog()).toStrictEqual(second.catalog()) - expect(first.instructions()).toBe(second.instructions()) expect(first.catalog().map((tool) => tool.path)).toEqual(["alpha.alpha", "alpha.zeta", "zeta.alpha", "zeta.zeta"]) - - for (const catalogBudget of [0, 10, 20, 40]) { - expect( - CodeMode.make({ - tools: { zeta: { zeta, alpha }, alpha: { zeta, alpha } }, - discovery: { catalogBudget }, - }).instructions(), - ).toBe( - CodeMode.make({ - tools: { alpha: { alpha, zeta }, zeta: { alpha, zeta } }, - discovery: { catalogBudget }, - }).instructions(), - ) - } }) test("renders bracket notation for tool names that are not JavaScript identifiers", async () => { @@ -680,9 +656,6 @@ describe("CodeMode public contract", () => { signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise', }, ]) - expect(runtime.instructions()).toContain( - 'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise', - ) const search = await Effect.runPromise(runtime.execute(`return search({ query: "resolve library id" })`)) expect(search.ok).toBe(true) @@ -713,88 +686,6 @@ describe("CodeMode public contract", () => { if (exact.ok) expect(exact.value).toMatchObject({ remaining: 0, next: null }) }) - test("instructions use markdown sections with placeholder-only call forms", () => { - const runtime = CodeMode.make({ tools }) - const instructions = runtime.instructions() - // Sections in order: workflow at the top, catalog at the bottom. - expect(instructions).toContain("## Workflow") - expect(instructions).toContain("## Rules") - expect(instructions).toContain("## Language") - expect(instructions.indexOf("## Workflow")).toBeLessThan(instructions.indexOf("## Rules")) - expect(instructions.indexOf("## Rules")).toBeLessThan(instructions.indexOf("## Language")) - expect(instructions.indexOf("## Language")).toBeLessThan( - instructions.indexOf("\n## Available tools (COMPLETE list"), - ) - expect(instructions).not.toContain("JSON.parse(res)") - expect(instructions).toContain("Return only the fields you need") - expect(instructions).toContain("avoid returning large raw payloads") - expect(instructions).toContain("Do not infer or normalize tool names") - expect(instructions).toContain("bracket notation and quotes are part of the path") - expect(instructions).toContain("surrounding agent tools are not available") - expect(instructions).toContain("Only tools listed here are available") - // Placeholders use generic namespace/tool/field names only - no fabricated real tools - // and no real catalog tools cherry-picked into example lines. - expect(instructions).toContain("`const result = await tools..(input)`") - expect(instructions).toContain("Return only the fields you need from structured results") - expect(instructions).toContain("check that it is a non-null object and not an array") - expect(instructions).not.toContain("result.") - expect(instructions).not.toContain("data.") - expect(instructions).not.toContain("total_count") - expect(instructions).not.toContain("list_issues") - expect(instructions).not.toContain("tools.orders.lookup({") - // COMPLETE: step 1 picks from the inlined list; search is not advertised. - expect(instructions).toContain("1. Pick a tool from the list under `## Available tools`") - expect(instructions).not.toContain("Browse one namespace") - - const partial = CodeMode.make({ tools, discovery: { catalogBudget: 0 } }).instructions() - // PARTIAL: the workflow starts with search (with query-style guidance that is clearly - // a query string, never a tool name) and the browse-namespace rule appears. - expect(partial).toContain( - '1. If needed, discover tools with the built-in search function: `return search({ query: "" })`.', - ) - expect(partial).toContain("In the next execution, copy a returned path exactly") - expect(partial).toContain("Only tools listed here or returned by the built-in `search` function") - expect(partial).toContain('- Browse one namespace: `search({ query: "", namespace: "" })`.') - expect(partial).toContain("repeat the same search with `offset: next.offset`") - expect(partial).toContain(" limit?: number,\n offset?: number,") - expect(partial).not.toContain("total_count") - expect(partial).not.toContain("tools.orders.lookup({") - }) - - test("the language section describes the restricted runtime without overclaiming", () => { - const instructions = CodeMode.make({ tools }).instructions() - expect(instructions).toContain("restricted JavaScript language for calling tools") - expect(instructions).toContain("not a general-purpose runtime") - expect(instructions).not.toContain("Standard modern JavaScript works") - expect(instructions).not.toContain("TypeScript type annotations") - for (const missing of ["Modules/imports", "classes", "fetch"]) { - expect(instructions).toContain(missing) - } - expect(instructions).not.toContain("generators") - expect(instructions).not.toContain("new Promise(...) are unavailable") - expect(instructions).not.toContain("promise chaining") - expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers") - expect(instructions).not.toContain("host globals") - expect(instructions).toContain("Use tools for external operations") - expect(instructions).toContain( - "Prefer explicit `return`; otherwise only the final top-level expression becomes the result.", - ) - expect(instructions).toContain( - "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.", - ) - }) - - test("zero tools keep minimal sections and the no-tools notice", () => { - const runtime = CodeMode.make({}) - const instructions = runtime.instructions() - expect(instructions).toContain("No tools are currently available.") - expect(instructions).toContain("## Language") - expect(instructions).toContain("## Available tools") - expect(instructions).not.toContain("## Workflow") - expect(instructions).not.toContain("## Rules") - expect(instructions).not.toContain("search(") - }) - test("uses one ranked search returning complete tools for large catalogs", async () => { const upload = Tool.make({ description: "Upload one readable local file to the current Discord thread", @@ -810,13 +701,7 @@ describe("CodeMode public contract", () => { }) const runtime = CodeMode.make({ tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } }, - discovery: { catalogBudget: 0 }, }) - expect(runtime.instructions()).toContain("Available tools (PARTIAL - 0 of 3 shown; find the rest with search(...))") - expect(runtime.instructions()).toContain("- thread (2 tools, none shown)") - expect(runtime.instructions()).toContain("- orders (1 tool, none shown)") - expect(runtime.instructions()).toContain("Search returns complete callable signatures:\n- search(input: {") - expect(runtime.instructions()).not.toMatch(/tools\.thread\.uploadFile\(input/) const result = await Effect.runPromise( runtime.execute(` @@ -1101,64 +986,6 @@ describe("CodeMode public contract", () => { if (exhausted.ok) expect(exhausted.value).toStrictEqual({ items: [], remaining: 0, next: null }) }) - test("inlines round-robin across namespaces so one expensive namespace cannot starve the rest", () => { - const cheap = Tool.make({ - description: "Cheap", - input: Schema.Struct({ q: Schema.String }), - output: Schema.String, - execute: () => Effect.succeed("ok"), - }) - const expensive = Tool.make({ - description: - "An expensive tool whose description alone consumes far more than the remaining inline catalog byte budget for this runtime", - input: Schema.Struct({ - someRatherLongParameterName: Schema.String, - anotherEvenLongerParameterName: Schema.Number, - }), - output: Schema.String, - execute: () => Effect.succeed("ok"), - }) - // Round 1 places alpha.cheap (~17 estimated tokens) and beta.cheap (~17); in round 2 - // alpha.expensive does not fit, which marks only alpha done - it must NOT prevent - // other namespaces from inlining (beta already got its line in the same round). - const runtime = CodeMode.make({ - tools: { alpha: { cheap, expensive }, beta: { cheap } }, - discovery: { catalogBudget: 40 }, - }) - - const instructions = runtime.instructions() - expect(instructions).toContain("Available tools (PARTIAL - 2 of 3 shown; find the rest with search(...))") - expect(instructions).toContain("- alpha (2 tools, 1 shown)") - expect(instructions).toContain(" - tools.alpha.cheap(input: {\n q: string,\n}): Promise // Cheap") - expect(instructions).not.toContain("tools.alpha.expensive(") - // Fully shown namespaces read cleanly (no "shown" annotation). - expect(instructions).toContain("- beta (1 tool)") - expect(instructions).toContain(" - tools.beta.cheap(input: {\n q: string,\n}): Promise // Cheap") - expect(instructions).toContain("Search returns complete callable signatures:\n- search(input: {") - }) - - test("charges inline JSDoc against the catalog token budget", () => { - const documented = Tool.make({ - description: "Look up a record", - input: { - type: "object", - properties: { - id: { type: "string", description: "A detailed identifier description. ".repeat(20) }, - }, - required: ["id"], - } as const, - execute: () => Effect.succeed("ok"), - }) - const runtime = CodeMode.make({ - tools: { records: { lookup: documented } }, - discovery: { catalogBudget: 40 }, - }) - - expect(runtime.catalog()[0]?.signature).toContain("/** A detailed identifier description.") - expect(runtime.instructions()).toContain("Available tools (PARTIAL - 0 of 1 shown; find the rest with search(...))") - expect(runtime.instructions()).not.toContain("tools.records.lookup(input:") - }) - test("decodes tool input and output before exposing either side", async () => { const observed: Array = [] const transformed = Tool.make({ @@ -1219,7 +1046,7 @@ describe("CodeMode public contract", () => { expect(result).toStrictEqual({ ok: true, value: null, toolCalls: [] }) }) - test("rejects invalid configuration and discovery limits", async () => { + test("rejects invalid configuration and search limits", async () => { expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: 0 } })).toThrow(RangeError) expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow( RangeError, @@ -1227,13 +1054,8 @@ describe("CodeMode public contract", () => { expect(() => CodeMode.execute({ code: "return 1", limits: { maxToolCalls: -1 } })).toThrow(RangeError) expect(() => CodeMode.execute({ code: "return 1", limits: { maxOutputBytes: -1 } })).toThrow(RangeError) - expect(() => CodeMode.make({ tools, discovery: { catalogBudget: -1 } })).toThrow(RangeError) - const result = await Effect.runPromise( - CodeMode.make({ - tools, - discovery: { catalogBudget: 0 }, - }).execute(`return search({ query: "order", limit: 0.5 })`), + CodeMode.make({ tools }).execute(`return search({ query: "order", limit: 0.5 })`), ) expect(result.ok).toBe(false) if (result.ok) return diff --git a/packages/codemode/test/signature.test.ts b/packages/codemode/test/signature.test.ts index 38121e5c6ef..4c29281d47f 100644 --- a/packages/codemode/test/signature.test.ts +++ b/packages/codemode/test/signature.test.ts @@ -395,15 +395,15 @@ describe("JSDoc signatures in catalogs and search results", () => { } }) - test("the inline catalog uses the same JSDoc signatures", async () => { - const instructions = runtime.instructions() + test("the catalog uses the same JSDoc signatures as search", async () => { + const catalog = runtime.catalog() const github = (await search("list issues repository")).items.find( ({ path }) => path === "tools.github.list_issues", )! const orders = (await search("look up order")).items.find(({ path }) => path === "tools.orders.lookup")! - expect(instructions).toContain(` - ${github.signature} // List issues in a repository`) - expect(instructions).toContain(` - ${orders.signature} // Look up an order`) - expect(instructions).toContain("/** Repository owner */") + expect(catalog.map(({ signature }) => signature)).toContain(github.signature) + expect(catalog.map(({ signature }) => signature)).toContain(orders.signature) + expect(github.signature).toContain("/** Repository owner */") }) }) @@ -423,16 +423,10 @@ describe("non-identifier tool paths", () => { }) const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } }) - test("inline catalog uses bracket notation for dashed tool names", () => { - const instructions = runtime.instructions() - - expect(instructions).toContain( + test("catalog signatures use bracket notation for dashed tool names", () => { + expect(runtime.catalog()[0]?.signature).toBe( 'tools.context7["resolve-library-id"](input: {\n query: string,\n libraryName: string,\n}): Promise', ) - expect(instructions).toContain("Do not infer or normalize tool names") - expect(instructions).toContain("bracket notation and quotes are part of the path") - expect(instructions).not.toContain("tools.context7.resolve-library-id") - expect(instructions).not.toContain("tools.context7.resolve_library_id") }) test("search results return callable bracket-notation paths and signatures", async () => { diff --git a/packages/codemode/test/tool-paths.test.ts b/packages/codemode/test/tool-paths.test.ts index 359cb7b9c6b..3cc92d6dfcd 100644 --- a/packages/codemode/test/tool-paths.test.ts +++ b/packages/codemode/test/tool-paths.test.ts @@ -30,7 +30,6 @@ describe("dotted tool names", () => { expect(catalog).toHaveLength(1) expect(catalog[0]?.path).toBe("api.issues.list") expect(catalog[0]?.signature).toStartWith("tools.api.issues.list(input:") - expect(runtime.instructions()).toContain("tools.api.issues.list(input:") }) test("the advertised dotted path is executable", async () => { @@ -86,6 +85,9 @@ describe("callable namespaces", () => { const diagnostic = await failure(runtime, `return await tools.issues.missing({})`) expect(diagnostic.kind).toBe("UnknownTool") expect(diagnostic.message).toContain("Unknown tool 'issues.missing'") + expect(diagnostic.suggestions).toEqual([ + "The tool may have been removed or renamed. Use search to find available tools.", + ]) }) test("a namespace without its own tool stays non-callable", async () => { @@ -96,6 +98,31 @@ describe("callable namespaces", () => { }) }) +describe("tool input diagnostics", () => { + const runtime = CodeMode.make({ + tools: { + "notes.echo": Tool.make({ + description: "Echo text", + input: Schema.Struct({ text: Schema.String }), + output: Schema.String, + execute: ({ text }) => Effect.succeed(text), + }), + }, + }) + + test("a schema mismatch suggests searching for the current signature", async () => { + const diagnostic = await failure(runtime, `return await tools.notes.echo({ message: "hello" })`) + expect(diagnostic.kind).toBe("InvalidToolInput") + expect(diagnostic.suggestions).toEqual(["The signature may have changed. Use search to get the current signature."]) + }) + + test("a wrong argument count keeps the existing error without a stale-signature hint", async () => { + const diagnostic = await failure(runtime, `return await tools.notes.echo()`) + expect(diagnostic.kind).toBe("InvalidToolInput") + expect(diagnostic.suggestions).toBeUndefined() + }) +}) + describe("blocked member names on tool paths", () => { const runtime = CodeMode.make({ tools: { diff --git a/packages/core/src/codemode.ts b/packages/core/src/codemode.ts index 7d2dd2c5708..20dcea1d427 100644 --- a/packages/core/src/codemode.ts +++ b/packages/core/src/codemode.ts @@ -1,6 +1,7 @@ export * as CodeMode from "./codemode" import { Context, Effect, Layer, Scope } from "effect" +import { CodeModeCatalog } from "./codemode/catalog" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { PermissionV2 } from "./permission" import { ExecuteTool } from "./tool/execute" @@ -9,7 +10,7 @@ import { Wildcard } from "./util/wildcard" export interface Materialization { readonly tool?: Any - readonly instructions?: string + readonly catalog?: ReadonlyArray } export interface Interface { @@ -67,7 +68,7 @@ const layer = Layer.effect( if (executeRule?.resource === "*" && executeRule.effect === "deny") return {} return { tool: ExecuteTool.create(registrations), - instructions: ExecuteTool.instructions(registrations), + catalog: ExecuteTool.catalog(registrations), } }), }) diff --git a/packages/core/src/codemode/catalog.ts b/packages/core/src/codemode/catalog.ts new file mode 100644 index 00000000000..eafc1568949 --- /dev/null +++ b/packages/core/src/codemode/catalog.ts @@ -0,0 +1,103 @@ +export * as CodeModeCatalog from "./catalog" + +import { Schema } from "effect" + +export const Entry = Schema.Struct({ + path: Schema.String, + description: Schema.String, + signature: Schema.String, +}) +export type Entry = typeof Entry.Type + +const Listing = Schema.Struct({ + path: Schema.String, + line: Schema.String, +}) + +const Namespace = Schema.Struct({ + name: Schema.String, + count: Schema.Number, + entries: Schema.Array(Listing), +}) + +export const Summary = Schema.Struct({ + total: Schema.Number, + shown: Schema.Number, + namespaces: Schema.Array(Namespace), +}) +export type Summary = typeof Summary.Type + +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, +// considering shorter listings first until the inline budget is exhausted. +export function summarize(entries: ReadonlyArray, budget = INLINE_BUDGET): Summary { + const namespaces = [...Map.groupBy(entries, (entry) => entry.path.split(".", 1)[0] ?? entry.path)] + .sort(([left], [right]) => { + if (left < right) return -1 + if (left > right) return 1 + return 0 + }) + .map(([name, namespaceEntries]) => { + const listings = namespaceEntries + .map((entry) => { + const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? "" + const description = + firstLine.length > DESCRIPTION_LIMIT + ? firstLine.slice(0, DESCRIPTION_LIMIT - 3) + "..." + : firstLine + const suffix = description.length === 0 ? "" : ` // ${description}` + return { path: entry.path, line: ` - ${entry.signature}${suffix}` } + }) + .toSorted((left, right) => { + if (left.path < right.path) return -1 + if (left.path > right.path) return 1 + return 0 + }) + return { + name, + listings, + selectionOrder: rankListings(listings), + selectedListings: new Set(), + } + }) + + const active = new Set(namespaces) + let remaining = budget + while (active.size > 0) { + for (const namespace of active) { + const candidate = namespace.selectionOrder[namespace.selectedListings.size] + if (!candidate || candidate.cost > remaining) { + active.delete(namespace) + continue + } + namespace.selectedListings.add(candidate.listing) + remaining -= candidate.cost + if (namespace.selectedListings.size === namespace.selectionOrder.length) active.delete(namespace) + } + } + + const namespaceSummaries = namespaces.map((namespace) => ({ + name: namespace.name, + count: namespace.listings.length, + entries: namespace.listings.filter((listing) => namespace.selectedListings.has(listing)), + })) + return { + total: entries.length, + shown: namespaceSummaries.reduce((total, namespace) => total + namespace.entries.length, 0), + namespaces: namespaceSummaries, + } +} + +function rankListings(listings: ReadonlyArray) { + return listings + .map((listing) => ({ listing, cost: Math.round(listing.line.length / CHARACTERS_PER_TOKEN) })) + .toSorted((left, right) => { + if (left.cost !== right.cost) return left.cost - right.cost + if (left.listing.path < right.listing.path) return -1 + if (left.listing.path > right.listing.path) return 1 + return 0 + }) +} diff --git a/packages/core/src/codemode/instructions.ts b/packages/core/src/codemode/instructions.ts index 0934a4a953a..66e5150dc53 100644 --- a/packages/core/src/codemode/instructions.ts +++ b/packages/core/src/codemode/instructions.ts @@ -1,24 +1,141 @@ export * as CodeModeInstructions from "./instructions" +import { searchSignature, toolExpression } from "@opencode-ai/codemode" import { Effect, Schema } from "effect" import { Instructions } from "../instructions/index" +import { CodeModeCatalog } from "./catalog" -const key = Instructions.Key.make("core/codemode") -const codec = Schema.toCodecJson(Schema.String) -const render = { - initial: (current: string) => current, - changed: (_previous: string, current: string) => - [ - "The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.", - current, - ].join("\n\n"), - removed: () => "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.", +// prettier-ignore +const prompt = (hasMoreTools: boolean) => `Run JavaScript to orchestrate tool calls and compose their results. Imports, direct filesystem access, and timers are unavailable. Do not use \`fetch\`; all external access goes through \`tools\`. + +Prefer an explicit \`return\`; if omitted, the final top-level expression becomes the result. Await tool calls before returning; any calls still pending when execution ends are interrupted. Run independent calls concurrently with \`Promise.all\`. + +Do not infer or normalize tool names; use only the exact signatures shown below${hasMoreTools ? " or returned by `search`" : ""}, preserving bracket notation such as \`tools.["tool-name"](input)\`.${hasMoreTools ? ` + +## Search + +Only some tool signatures are shown. Use \`search\` to discover exact paths and signatures for additional tools: + +- ${searchSignature}` : ""} + +## Available tools` + +export function render(catalog: CodeModeCatalog.Summary) { + if (catalog.total === 0) return "No tools are currently available." + + 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 `${prompt(catalog.shown < catalog.total)} + +${tools.join("\n")}` } -export const make = (content?: string): Instructions.Instructions => - Instructions.make({ +export function update(previous: CodeModeCatalog.Summary, current: CodeModeCatalog.Summary) { + const full = `The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog. + +${render(current)}` + const previousComplete = previous.shown === previous.total + const currentComplete = current.shown === current.total + if (previousComplete !== currentComplete) return full + + const diff = Instructions.diffByKey( + previous.namespaces.flatMap((namespace) => namespace.entries), + current.namespaces.flatMap((namespace) => namespace.entries), + (entry) => entry.path, + (before, after) => before.line !== after.line, + ) + const entriesChanged = diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0 + + if (!currentComplete) { + if (entriesChanged) return full + const namespaces = Instructions.diffByKey( + previous.namespaces, + current.namespaces, + (namespace) => namespace.name, + (before, after) => before.count !== after.count, + ) + const changed = namespaces.added.length > 0 || namespaces.removed.length > 0 || namespaces.changed.length > 0 + if (!changed) return full + + const parts = ["The Code Mode tool catalog has changed."] + if (namespaces.added.length > 0) { + parts.push( + `New tool namespaces are available: ${namespaces.added + .map((namespace) => `\`${namespace.name}\` (${namespace.count} tools)`) + .join(", ")}.`, + ) + } + if (namespaces.changed.length > 0) { + parts.push( + `The following namespace inventories changed; search them again before relying on previous results: ${namespaces.changed + .map((change) => `\`${change.current.name}\` now has ${change.current.count} tools`) + .join(", ")}.`, + ) + } + if (namespaces.removed.length > 0) { + parts.push( + `The following tool namespaces are no longer available and must not be used: ${namespaces.removed + .map((namespace) => `\`${namespace.name}\``) + .join(", ")}.`, + ) + } + const delta = parts.join("\n\n") + if (delta.length < full.length) return delta + return full + } + + if (!entriesChanged) return full + const parts = ["The Code Mode tool catalog has changed."] + if (diff.added.length > 0) { + parts.push( + [ + "New tools are available in addition to those previously listed:", + ...diff.added.map((entry) => entry.line), + ].join("\n"), + ) + } + if (diff.changed.length > 0) { + parts.push( + [ + "Changed tool listings supersede the previously listed ones:", + ...diff.changed.map((change) => change.current.line), + ].join("\n"), + ) + } + if (diff.removed.length > 0) { + parts.push( + `The following tools are no longer available and must not be called: ${diff.removed + .map((entry) => toolExpression(entry.path)) + .join(", ")}.`, + ) + } + const delta = parts.join("\n\n") + if (delta.length < full.length) return delta + return full +} + +const key = Instructions.Key.make("core/codemode") +const codec = Schema.toCodecJson(CodeModeCatalog.Summary) + +export const make = (entries?: ReadonlyArray): Instructions.Instructions => { + const catalog = CodeModeCatalog.summarize(entries ?? []) + return Instructions.make({ key, codec, - read: Effect.succeed(content ?? Instructions.removed), - render, + read: Effect.succeed(catalog.total === 0 ? Instructions.removed : catalog), + render: { + initial: render, + changed: update, + removed: () => "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.", + }, }) +} diff --git a/packages/core/src/session/context.ts b/packages/core/src/session/context.ts index be3d9bb08f8..c2c41e32998 100644 --- a/packages/core/src/session/context.ts +++ b/packages/core/src/session/context.ts @@ -97,7 +97,7 @@ const layer = Layer.effect( agent: { ...agent, info: agent.info }, instructions: Instructions.combine([ loaded.builtins, - CodeModeInstructions.make(loaded.toolSet.codeModeInstructions), + CodeModeInstructions.make(loaded.toolSet.codeModeCatalog), loaded.discovery, loaded.skills, loaded.references, diff --git a/packages/core/src/tool/execute.ts b/packages/core/src/tool/execute.ts index 30ca66712cf..a9d005af456 100644 --- a/packages/core/src/tool/execute.ts +++ b/packages/core/src/tool/execute.ts @@ -126,8 +126,8 @@ export const create = (registrations: ReadonlyMap) => { }) } -export const instructions = (registrations: ReadonlyMap) => { - return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).instructions() +export const catalog = (registrations: ReadonlyMap) => { + return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).catalog() } function runtime( diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index c572541a948..c5e3d1014b5 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -3,6 +3,7 @@ export * as ToolRegistry from "./registry" import { type ToolCall, type ToolContent, type ToolDefinition } from "@opencode-ai/ai" import { Context, Effect, Layer, Schema, Scope, Semaphore } from "effect" import type { AgentV2 } from "../agent" +import { CodeModeCatalog } from "../codemode/catalog" import { Image } from "../image" import { PermissionV2 } from "../permission" import { SessionMessage } from "../session/message" @@ -44,13 +45,13 @@ export interface Interface { } /** - * One request-scoped snapshot pairing Code Mode instructions and advertised + * One request-scoped snapshot pairing the Code Mode catalog and advertised * definitions with captured tools. A model request executes exactly the tool * values it advertised even if registration changes while it is in flight. */ export interface ToolSet { readonly definitions: ReadonlyArray - readonly codeModeInstructions?: string + readonly codeModeCatalog?: ReadonlyArray readonly execute: (input: ExecuteInput) => Effect.Effect } @@ -324,9 +325,9 @@ const registryLayer = Layer.effect( const codeModeMaterialization = yield* codeMode.materialize(permissions) const codemodeTool = codeModeMaterialization.tool return { - ...(codeModeMaterialization.instructions === undefined + ...(codeModeMaterialization.catalog === undefined ? {} - : { codeModeInstructions: codeModeMaterialization.instructions }), + : { codeModeCatalog: codeModeMaterialization.catalog }), definitions: [ // Definitions are prompt-cache prefix bytes, so order only after effective registrations settle. ...Array.from(direct) diff --git a/packages/core/test/codemode.test.ts b/packages/core/test/codemode.test.ts index b80eb1a9855..9d9f8ccfd3c 100644 --- a/packages/core/test/codemode.test.ts +++ b/packages/core/test/codemode.test.ts @@ -22,8 +22,13 @@ describe("CodeMode", () => { const materialized = yield* codeMode.materialize() expect(materialized.tool).toBeDefined() - expect(materialized.instructions).toContain("Echo text") - expect(materialized.instructions).toContain("tools.echo(input:") + expect(materialized.catalog).toStrictEqual([ + { + path: "echo", + description: "Echo text", + signature: "tools.echo(input: {\n text: string,\n}): Promise", + }, + ]) }).pipe(Effect.scoped, Effect.provide(AppNodeBuilder.build(CodeMode.node))), ) }) diff --git a/packages/core/test/codemode/catalog.test.ts b/packages/core/test/codemode/catalog.test.ts new file mode 100644 index 00000000000..176fe1c6350 --- /dev/null +++ b/packages/core/test/codemode/catalog.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, test } from "bun:test" +import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog" +import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions" + +const entry = (path: string, description: string, signature?: string): CodeModeCatalog.Entry => ({ + path, + description, + signature: signature ?? `tools.${path}(input: {\n q: string,\n}): Promise`, +}) + +const lookup = entry( + "orders.lookup", + "Look up an order by ID", + "tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>", +) + +const render = (entries: ReadonlyArray, budget?: number) => + CodeModeInstructions.render(CodeModeCatalog.summarize(entries, budget)) + +const update = ( + previous: ReadonlyArray, + current: ReadonlyArray, + budget?: number, +) => + CodeModeInstructions.update(CodeModeCatalog.summarize(previous, budget), CodeModeCatalog.summarize(current, 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, + ) + expect(catalog).toEqual({ + total: 10_000, + shown: 0, + namespaces: [{ name: "bulk", count: 10_000, entries: [] }], + }) + }) + + 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, + ) + expect(catalog.namespaces.map((namespace) => namespace.name)).toEqual(["alpha", "beta", "gamma"]) + expect(catalog.namespaces.every((namespace) => namespace.entries.length === 0)).toBe(true) + }) + + test("retains only the rendered portion of inline descriptions", () => { + const catalog = CodeModeCatalog.summarize([entry("alpha.one", `Summary\n${"detail".repeat(10_000)}`)]) + expect(catalog.namespaces[0]?.entries[0]?.line).toEndWith("// Summary") + }) + + test("limits inline descriptions to 120 characters", () => { + const catalog = CodeModeCatalog.summarize([entry("alpha.one", "x".repeat(121))]) + const description = catalog.namespaces[0]?.entries[0]?.line.split(" // ")[1] + expect(description).toHaveLength(120) + expect(description).toEndWith("...") + }) +}) + +describe("CodeModeInstructions.render", () => { + test("inlines complete catalogs without search guidance", () => { + const instructions = render([lookup]) + expect(instructions).toContain("## Available tools") + expect(instructions).toContain("- orders (1 tool)") + expect(instructions).toContain(` - ${lookup.signature} // Look up an order by ID`) + expect(instructions).not.toContain("## Search") + expect(instructions).toContain("Do not infer or normalize tool names") + expect(instructions).toContain('`tools.["tool-name"](input)`') + }) + + test("describes the runtime and execution lifecycle concisely", () => { + const instructions = render([lookup]) + expect(instructions).toContain("Run JavaScript to orchestrate tool calls and compose their results.") + expect(instructions).toContain("Imports, direct filesystem access, and timers are unavailable.") + expect(instructions).toContain("Do not use `fetch`; all external access goes through `tools`.") + expect(instructions).toContain( + "Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.", + ) + expect(instructions).toContain("any calls still pending when execution ends are interrupted") + expect(instructions).toContain("Run independent calls concurrently with `Promise.all`.") + }) + + test("adds search guidance when the catalog exceeds the budget", () => { + const partial = render([lookup], 0) + expect(partial).toContain("## Available tools") + expect(partial).toContain("- orders (1 tool, none shown)") + expect(partial).toContain("## Search") + expect(partial).toContain("Only some tool signatures are shown.") + expect(partial).toContain("- search(input: {") + expect(partial).toContain(" limit?: number,\n offset?: number,") + expect(partial).toContain("or returned by `search`") + expect(partial).not.toContain("tools.orders.lookup(input:") + }) + + test("budgets signatures round-robin so every namespace remains visible", () => { + const cheapAlpha = entry("alpha.cheap", "Cheap") + const cheapBeta = entry("beta.cheap", "Cheap") + const expensive = entry( + "alpha.expensive", + "Expensive", + `tools.alpha.expensive(input: {\n aVeryLongParameterName: string,\n anotherEvenLongerParameterName: number,\n yetAnotherExtremelyVerboseParameterName: string,\n}): Promise`, + ) + // 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) + expect(instructions).toContain("## Search") + expect(instructions).toContain("- alpha (2 tools, 1 shown)") + expect(instructions).toContain(` - ${cheapAlpha.signature} // Cheap`) + expect(instructions).not.toContain("tools.alpha.expensive(") + expect(instructions).toContain("- beta (1 tool)") + expect(instructions).toContain(` - ${cheapBeta.signature} // Cheap`) + }) + + test("charges inline JSDoc in signatures against the catalog token budget", () => { + const documented = entry( + "records.lookup", + "Look up a record", + `tools.records.lookup(input: {\n /** ${"A detailed identifier description. ".repeat(20).trim()} */\n id: string,\n}): Promise`, + ) + const instructions = render([documented], 40) + expect(instructions).toContain("- records (1 tool, none shown)") + expect(instructions).not.toContain("tools.records.lookup(input:") + }) + + test("renders only the no-tools notice for an empty catalog", () => { + expect(render([])).toBe("No tools are currently available.") + }) +}) + +describe("CodeModeInstructions.update", () => { + const echo = entry("notes.echo", "Echo text") + + test("renders additions, changes, and removals as a compact semantic delta", () => { + const changed = { ...echo, signature: "tools.notes.echo(input: {\n text: string,\n}): Promise" } + const added = entry("notes.list", "List notes") + const text = update([echo, lookup], [changed, added]) + expect(text).toContain("The Code Mode tool catalog has changed.") + expect(text).toContain(`New tools are available in addition to those previously listed:\n - ${added.signature}`) + expect(text).toContain( + `Changed tool listings supersede the previously listed ones:\n - ${changed.signature} // Echo text`, + ) + expect(text).toContain("The following tools are no longer available and must not be called: tools.orders.lookup.") + expect(text).not.toContain("## Available tools") + }) + + test("names removed tools with exact callable expressions including bracket notation", () => { + const dashed = entry("context7.resolve-library-id", "Resolve a library ID") + const text = update([echo, dashed], [echo]) + expect(text).toContain( + 'The following tools are no longer available and must not be called: tools.context7["resolve-library-id"].', + ) + }) + + 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) + expect(text).toContain( + "The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.", + ) + expect(text).toContain("## Search") + expect(text).toContain("## Available tools") + }) + + test("falls back to full replacement when the delta is larger than the catalog", () => { + const previous = Array.from({ length: 200 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`)) + const text = update([...previous, echo], [echo]) + expect(text).toContain("This catalog supersedes the previous Code Mode tool catalog.") + expect(text).toContain("## Available tools") + expect(text).not.toContain("## Search") + expect(text).not.toContain("must not be called") + }) + + test("renders namespace-only deltas without persisting hidden tool entries", () => { + const alpha = Array.from({ length: 10 }, (_, index) => entry(`alpha.tool${index}`, `Tool ${index}`)) + const text = update(alpha, [...alpha, entry("alpha.tool10", "Tool 10")], 0) + expect(text).toContain("`alpha` now has 11 tools") + expect(text).toContain("search them again before relying on previous results") + expect(text).not.toContain("tools.alpha.tool10(input:") + expect(text).not.toContain("## Available tools") + }) +}) diff --git a/packages/core/test/codemode/instructions.test.ts b/packages/core/test/codemode/instructions.test.ts index 6c8bc790769..95344550894 100644 --- a/packages/core/test/codemode/instructions.test.ts +++ b/packages/core/test/codemode/instructions.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import { CodeMode } from "@opencode-ai/core/codemode" +import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog" import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Tool } from "@opencode-ai/core/tool/tool" @@ -7,8 +8,45 @@ import { Effect, Schema } from "effect" import { it } from "../lib/effect" import { readInitial, readUpdate } from "../lib/instructions" +const echo: CodeModeCatalog.Entry = { + path: "notes.echo", + description: "Echo text", + signature: "tools.notes.echo(input: {\n text: string,\n}): Promise", +} + +const lookup: CodeModeCatalog.Entry = { + path: "orders.lookup", + description: "Look up an order", + signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise", +} + describe("CodeModeInstructions", () => { - it.effect("treats equivalent registration orders as an instruction no-op", () => { + it.effect("renders the initial catalog, semantic deltas, and removal", () => + Effect.gen(function* () { + const initialized = yield* readInitial(CodeModeInstructions.make([echo])) + expect(initialized.text).toContain("## Available tools") + expect(initialized.text).not.toContain("## Search") + expect(initialized.text).toContain(` - ${echo.signature} // Echo text`) + + const added = yield* readUpdate(CodeModeInstructions.make([echo, lookup]), initialized) + expect(added.text).toContain("The Code Mode tool catalog has changed.") + expect(added.text).toContain("New tools are available in addition to those previously listed:") + expect(added.text).toContain(` - ${lookup.signature} // Look up an order`) + expect(added.text).not.toContain("## Available tools") + + const removed = yield* readUpdate(CodeModeInstructions.make([echo]), { values: added.values }) + expect(removed.text).toBe( + "The Code Mode tool catalog has changed.\n\n" + + "The following tools are no longer available and must not be called: tools.orders.lookup.", + ) + + expect(yield* readUpdate(CodeModeInstructions.make(), initialized)).toMatchObject({ + text: "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.", + }) + }), + ) + + it.effect("stores a canonical sorted snapshot so registration order does not churn history", () => { const alpha = Tool.make({ description: "Alpha tool", input: Schema.Struct({}), @@ -21,46 +59,25 @@ describe("CodeModeInstructions", () => { output: Schema.String, execute: () => Effect.succeed({ output: "zeta" }), }) - const codeModeLayer = AppNodeBuilder.build(CodeMode.node) + const layer = AppNodeBuilder.build(CodeMode.node) return Effect.gen(function* () { const codeMode = yield* CodeMode.Service const initialized = yield* Effect.scoped( Effect.gen(function* () { yield* codeMode.register(Tool.registrationEntries({ zeta, alpha }, { namespace: "tools" })) - const materialization = yield* codeMode.materialize() - return yield* readInitial(CodeModeInstructions.make(materialization.instructions)) + return yield* readInitial(CodeModeInstructions.make((yield* codeMode.materialize()).catalog)) }), ) const reordered = yield* Effect.scoped( Effect.gen(function* () { yield* codeMode.register(Tool.registrationEntries({ alpha, zeta }, { namespace: "tools" })) - const materialization = yield* codeMode.materialize() - return yield* readUpdate(CodeModeInstructions.make(materialization.instructions), initialized) + return yield* readUpdate(CodeModeInstructions.make((yield* codeMode.materialize()).catalog), initialized) }), ) expect(reordered.changed).toBe(false) expect(reordered.text).toBe("") - }).pipe(Effect.provide(codeModeLayer)) - }) - - it.effect("renders catalog changes and removal", () => { - let catalog: string | undefined = "Initial Code Mode catalog" - - return Effect.gen(function* () { - const initialized = yield* readInitial(CodeModeInstructions.make(catalog)) - expect(initialized.text).toBe("Initial Code Mode catalog") - - catalog = "Updated Code Mode catalog" - expect(yield* readUpdate(CodeModeInstructions.make(catalog), initialized)).toMatchObject({ - text: "The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.\n\nUpdated Code Mode catalog", - }) - - catalog = undefined - expect(yield* readUpdate(CodeModeInstructions.make(catalog), initialized)).toMatchObject({ - text: "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.", - }) - }) + }).pipe(Effect.provide(layer)) }) }) diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index 20b0ca07fe3..a6910f826f7 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -59,7 +59,11 @@ const client = Layer.mock(LLMClient.Service)({ LLMEvent.textStart({ id: "generate" }), LLMEvent.textDelta({ id: "generate", text: "Transient answer" }), LLMEvent.textEnd({ id: "generate" }), - LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 100, outputTokens: 10 } }), + LLMEvent.stepFinish({ + index: 0, + reason: { normalized: "stop" }, + usage: { inputTokens: 100, outputTokens: 10 }, + }), LLMEvent.finish({ reason: { normalized: "stop" } }), ]) if (!response) throw new Error("Incomplete generate response") @@ -97,7 +101,13 @@ const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void }) const tools = Layer.mock(ToolRegistry.Service, { snapshot: () => Effect.succeed({ - codeModeInstructions: "Captured Code Mode catalog", + codeModeCatalog: [ + { + path: "captured.lookup", + description: "Captured Code Mode catalog", + signature: "tools.captured.lookup(input: {}): Promise", + }, + ], definitions: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })], execute: () => Effect.die(new Error("unused")), }), @@ -293,7 +303,7 @@ it.effect("generates from fresh settled Session context without durable mutation ) expect(instructionUpdates).toHaveLength(1) expect(instructionUpdates?.[0]).toContain("Changed context") - expect(instructionUpdates?.[0]).toContain("Captured Code Mode catalog") + expect(instructionUpdates?.[0]).toContain("tools.captured.lookup(input: {}): Promise") expect(userTexts(requests[0])).toEqual(["Existing durable context", "Summarize privately"]) expect( requests[0]?.messages.flatMap((message) => diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 7ba1322002d..57e494783d9 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -533,7 +533,7 @@ describe("ToolRegistry", () => { .pipe(Scope.provide(scope)) const toolSet = yield* service.snapshot() const execute = toolSet.definitions.find((tool) => tool.name === "execute") - expect(toolSet.codeModeInstructions).toContain("tools.echo") + expect(toolSet.codeModeCatalog?.[0]?.signature).toContain("tools.echo") expect(execute?.description).toContain("confined Code Mode runtime") expect(execute?.description).not.toContain("Echo text") yield* Scope.close(scope, Exit.void) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index a6f6d0c43e9..b4a937b8b51 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -844,12 +844,19 @@ describe("SessionRunnerLLM", () => { output: Schema.String, execute: () => Effect.sync(() => executed.push(name)).pipe(Effect.as({ output: name })), }) + const catalog = (name: string) => [ + { + path: `catalog.${name.toLowerCase()}`, + description: `Code Mode catalog ${name}`, + signature: `tools.catalog.${name.toLowerCase()}(input: {}): Promise`, + }, + ] const session = yield* setup codeModeMaterializations = [ - { instructions: "Code Mode catalog A", tool: execute("A") }, - { instructions: "Code Mode catalog B", tool: execute("B") }, - { instructions: "Code Mode catalog C", tool: execute("C") }, - { instructions: "Code Mode catalog D", tool: execute("D") }, + { catalog: catalog("A"), tool: execute("A") }, + { catalog: catalog("B"), tool: execute("B") }, + { catalog: catalog("C"), tool: execute("C") }, + { catalog: catalog("D"), tool: execute("D") }, ] yield* admit(session, "Use Code Mode") responses = [reply.tool("call-execute", "execute", {}), reply.stop()] @@ -1036,9 +1043,7 @@ describe("SessionRunnerLLM", () => { input: Schema.Struct({}), output: Schema.Struct({ value: Schema.String }), execute: () => - Effect.sync(() => executions.push("advertised")).pipe( - Effect.as({ output: { value: "advertised" } }), - ), + Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ output: { value: "advertised" } })), }), }, { codemode: false }, @@ -1067,9 +1072,7 @@ describe("SessionRunnerLLM", () => { input: Schema.Struct({}), output: Schema.Struct({ value: Schema.String }), execute: () => - Effect.sync(() => executions.push("replacement")).pipe( - Effect.as({ output: { value: "replacement" } }), - ), + Effect.sync(() => executions.push("replacement")).pipe(Effect.as({ output: { value: "replacement" } })), }), }, { codemode: false },