diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 923da8d441..b438319037 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 1f99c01e74..786b8b4f4e 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/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 389d3af276..22f99f946c 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -20,8 +20,6 @@ import { CodeModeURLSearchParams, } from "./values.js" -const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4)) - export type Services = ServicesOf type ServicesOf> = Depth["length"] extends 8 @@ -69,7 +67,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)) @@ -89,7 +86,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(".") @@ -335,7 +332,6 @@ const visibleTools = (tools: Tools) => export type DiscoveryPlan = { readonly catalog: ReadonlyArray - readonly instructions: string readonly searchIndex: ReadonlyArray } @@ -419,17 +415,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]!, @@ -447,146 +438,11 @@ 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") - } +// Hosts render agent-facing instructions themselves from these structured descriptors. +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]) => left.localeCompare(right)) - - const selections = ordered.map(([namespace, group]) => ({ - namespace, - picked: new Set(), - queue: [...group].sort( - (left, right) => - estimateTokens(catalogLine(left)) - estimateTokens(catalogLine(right)) || left.path.localeCompare(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)), } } diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 9d5db0ba58..740d43c6c1 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,9 @@ 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. + // The search built-in stays available, so a speculative call still works with the + // same signature as the catalog. const result = await Effect.runPromise(runtime.execute(`return search({ query: "order" })`)) expect(result.ok).toBe(true) if (result.ok) { @@ -645,9 +638,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) @@ -678,88 +668,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", @@ -775,13 +683,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(` @@ -1066,64 +968,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({ @@ -1184,7 +1028,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, @@ -1192,13 +1036,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 38121e5c6e..4c29281d47 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 91d23cdee7..38ef4c2850 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 () => { diff --git a/packages/core/src/codemode.ts b/packages/core/src/codemode.ts index 7d2dd2c570..20dcea1d42 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 0000000000..adbc8bfbde --- /dev/null +++ b/packages/core/src/codemode/catalog.ts @@ -0,0 +1,217 @@ +export * as CodeModeCatalog from "./catalog" + +import { CodeMode } from "@opencode-ai/codemode" +import { Schema } from "effect" +import { Instructions } from "../instructions/index" + +// Model-facing descriptor for one Code Mode tool. Codemode constructs paths and +// signatures; this module owns every piece of agent-facing catalog prose. +export const Entry = Schema.Struct({ + path: Schema.String, + description: Schema.String, + signature: Schema.String, +}) +export type Entry = typeof Entry.Type + +/** Approximate token budget (chars/4) for inlined full catalog entries. */ +export const defaultBudget = 2_000 + +const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4)) + +const line = (tool: Entry) => { + const first = tool.description.split("\n", 1)[0]!.trim() + const description = first.length > 120 ? first.slice(0, 119) + "..." : first + return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}` +} + +type Plan = { + readonly ordered: ReadonlyArray]> + readonly shown: ReadonlyMap> + readonly totalShown: number + readonly complete: boolean +} + +// Budget signatures round-robin so every namespace remains visible. +const plan = (entries: ReadonlyArray, budget: number): Plan => { + const namespaces = new Map>() + for (const tool of entries) { + 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]) => left.localeCompare(right)) + + const selections = ordered.map(([namespace, group]) => ({ + namespace, + picked: new Set(), + queue: [...group].sort( + (left, right) => estimateTokens(line(left)) - estimateTokens(line(right)) || left.path.localeCompare(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(line(tool)) + if (used + cost > budget) continue + selection.queue.shift() + selection.picked.add(tool) + used += cost + if (selection.queue.length > 0) stillActive.push(selection) + } + active = stillActive + } + const totalShown = selections.reduce((total, { picked }) => total + picked.size, 0) + return { + ordered, + shown: new Map(selections.map(({ namespace, picked }) => [namespace, picked])), + totalShown, + complete: totalShown === entries.length, + } +} + +/** Renders the full agent-facing Code Mode instructions for one catalog snapshot. */ +export const render = (entries: ReadonlyArray, budget = defaultBudget): string => { + const planned = plan(entries, budget) + const empty = entries.length === 0 + + const intro = [ + empty + ? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime." + : planned.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", + "", + ...(planned.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", + "", + planned.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.", + ...(planned.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( + planned.complete + ? "## Available tools (COMPLETE list - every tool is shown below with its full call signature)" + : `## Available tools (PARTIAL - ${planned.totalShown} of ${entries.length} shown; find the rest with search(...))`, + "", + ) + for (const [namespace, group] of planned.ordered) { + const picked = planned.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(line(tool)) + } + if (!planned.complete) { + toolSection.push("", "Search returns complete callable signatures:", `- ${CodeMode.searchSignature}`) + } + } + + return [...intro, ...workflow, ...rules, ...language, ...toolSection].join("\n") +} + +const replacement = (current: ReadonlyArray, budget: number) => + [ + "The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.", + render(current, budget), + ].join("\n\n") + +/** Renders one mid-conversation catalog change as a semantic delta when it is smaller. */ +export const update = ( + previous: ReadonlyArray, + current: ReadonlyArray, + budget = defaultBudget, +): string => { + const diff = Instructions.diffByKey( + previous, + current, + (tool) => tool.path, + (before, after) => before.signature !== after.signature || before.description !== after.description, + ) + const empty = diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0 + // Crossing between full and compact rendering changes the surrounding guidance, so restate everything. + const crossed = plan(previous, budget).complete !== plan(current, budget).complete + if (empty || crossed) return replacement(current, budget) + const delta = [ + "The Code Mode tool catalog has changed.", + ...(diff.added.length === 0 + ? [] + : [["New tools are available in addition to those previously listed:", ...diff.added.map(line)].join("\n")]), + ...(diff.changed.length === 0 + ? [] + : [ + [ + "Changed tool signatures supersede the previously listed ones:", + ...diff.changed.map((change) => line(change.current)), + ].join("\n"), + ]), + ...(diff.removed.length === 0 + ? [] + : [ + `The following tools are no longer available and must not be called: ${diff.removed + .map((tool) => CodeMode.toolExpression(tool.path)) + .join(", ")}.`, + ]), + ].join("\n\n") + const full = replacement(current, budget) + return delta.length < full.length ? delta : full +} diff --git a/packages/core/src/codemode/instructions.ts b/packages/core/src/codemode/instructions.ts index 95e9f5338d..d1210234be 100644 --- a/packages/core/src/codemode/instructions.ts +++ b/packages/core/src/codemode/instructions.ts @@ -4,6 +4,7 @@ import { Context, Effect, Layer, Schema } from "effect" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { AgentV2 } from "../agent" import { CodeMode } from "../codemode" +import { CodeModeCatalog } from "./catalog" import { Instructions } from "../instructions/index" export interface Interface { @@ -19,22 +20,17 @@ const layer = Layer.effect( return Service.of({ load: Effect.fn("CodeModeInstructions.load")(function* (selection) { - const instructions = selection.info - ? (yield* codeMode.materialize(selection.info.permissions)).instructions - : undefined - return Instructions.make({ + const catalog = selection.info ? ((yield* codeMode.materialize(selection.info.permissions)).catalog ?? []) : [] + // Sorted entries keep the stored snapshot canonical so identical catalogs hash identically. + const entries = catalog.toSorted((left, right) => left.path.localeCompare(right.path)) + return Instructions.make>({ key: Instructions.Key.make("core/codemode"), - codec: Schema.toCodecJson(Schema.String), - read: Effect.succeed(instructions ?? Instructions.removed), + codec: Schema.toCodecJson(Schema.Array(CodeModeCatalog.Entry)), + read: Effect.succeed(entries.length === 0 ? Instructions.removed : entries), render: { - initial: (current) => current, - changed: (_previous, current) => - [ - "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.", + initial: (current) => CodeModeCatalog.render(current), + changed: (previous, current) => CodeModeCatalog.update(previous, current), + removed: () => "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.", }, }) }), diff --git a/packages/core/src/tool/execute.ts b/packages/core/src/tool/execute.ts index 30ca66712c..a9d005af45 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/test/codemode.test.ts b/packages/core/test/codemode.test.ts index b80eb1a985..9d9f8ccfd3 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 0000000000..99f055f809 --- /dev/null +++ b/packages/core/test/codemode/catalog.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from "bun:test" +import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog" + +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}>", +) + +describe("CodeModeCatalog.render", () => { + test("inlines complete catalogs with markdown sections and placeholder-only call forms", () => { + const instructions = CodeModeCatalog.render([lookup]) + expect(instructions).toContain("## Available tools (COMPLETE list") + expect(instructions).toContain("- orders (1 tool)") + expect(instructions).toContain(` - ${lookup.signature} // Look up an order by ID`) + // A fully inlined catalog does not advertise search in the instructions. + expect(instructions).not.toContain("search(") + + // 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")) + 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 real catalog tools + // cherry-picked into example lines. + expect(instructions).toContain("`const result = await tools..(input)`") + expect(instructions).toContain("check that it is a non-null object and not an array") + expect(instructions).not.toContain("tools.orders.lookup({") + expect(instructions).toContain("1. Pick a tool from the list under `## Available tools`") + expect(instructions).not.toContain("Browse one namespace") + }) + + test("describes the restricted runtime without overclaiming", () => { + const instructions = CodeModeCatalog.render([lookup]) + expect(instructions).toContain("restricted JavaScript language for calling tools") + expect(instructions).toContain("not a general-purpose runtime") + for (const missing of ["Modules/imports", "classes", "fetch"]) { + expect(instructions).toContain(missing) + } + // Generators are supported by the interpreter and must not be listed as unavailable. + expect(instructions).not.toContain("generators") + expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers") + 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("switches to search-first guidance when the catalog exceeds the budget", () => { + const partial = CodeModeCatalog.render([lookup], 0) + expect(partial).toContain("## Available tools (PARTIAL - 0 of 1 shown; find the rest with search(...))") + expect(partial).toContain("- orders (1 tool, none shown)") + 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`") + // The search signature is codemode-generated and stays complete and callable. + expect(partial).toContain("Search returns complete callable signatures:\n- search(input: {") + expect(partial).toContain(" limit?: number,\n offset?: number,") + 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 = CodeModeCatalog.render([cheapAlpha, expensive, cheapBeta], 40) + 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(` - ${cheapAlpha.signature} // 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(` - ${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 = CodeModeCatalog.render([documented], 40) + expect(instructions).toContain("## Available tools (PARTIAL - 0 of 1 shown; find the rest with search(...))") + expect(instructions).not.toContain("tools.records.lookup(input:") + }) + + test("renders the no-tools notice with minimal sections for an empty catalog", () => { + const instructions = CodeModeCatalog.render([]) + 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(") + }) +}) + +describe("CodeModeCatalog.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 = CodeModeCatalog.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 signatures 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 = CodeModeCatalog.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 = CodeModeCatalog.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("## Available tools (PARTIAL") + }) + + 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 = CodeModeCatalog.update([...previous, echo], [echo]) + expect(text).toContain("This catalog supersedes the previous Code Mode tool catalog.") + expect(text).toContain("## Available tools (COMPLETE list") + expect(text).not.toContain("must not be called") + }) +}) diff --git a/packages/core/test/codemode/instructions.test.ts b/packages/core/test/codemode/instructions.test.ts index 172041a11d..875b4e0da0 100644 --- a/packages/core/test/codemode/instructions.test.ts +++ b/packages/core/test/codemode/instructions.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { AgentV2 } from "@opencode-ai/core/agent" 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 { Effect, Layer } from "effect" @@ -9,14 +10,26 @@ import { readInitial, readUpdate } from "../lib/instructions" const agent = AgentV2.Info.make(AgentV2.Info.empty(AgentV2.ID.make("build"))) +const echo = { + path: "notes.echo", + description: "Echo text", + signature: "tools.notes.echo(input: {\n text: string,\n}): Promise", +} + +const lookup = { + path: "orders.lookup", + description: "Look up an order", + signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise", +} + describe("CodeModeInstructions", () => { - it.effect("renders catalog changes and removal", () => { - let catalog: string | undefined = "Initial Code Mode catalog" + it.effect("renders the initial catalog, semantic deltas, and removal", () => { + let catalog: ReadonlyArray | undefined = [echo] const layer = AppNodeBuilder.build(CodeModeInstructions.node, [ [ CodeMode.node, Layer.mock(CodeMode.Service, { - materialize: () => Effect.succeed({ ...(catalog === undefined ? {} : { instructions: catalog }) }), + materialize: () => Effect.succeed({ ...(catalog === undefined ? {} : { catalog }) }), register: () => Effect.void, }), ], @@ -25,16 +38,28 @@ describe("CodeModeInstructions", () => { return Effect.gen(function* () { const instructions = yield* CodeModeInstructions.Service const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial)) - expect(initialized.text).toBe("Initial Code Mode catalog") + expect(initialized.text).toContain("## Available tools (COMPLETE list") + expect(initialized.text).toContain(` - ${echo.signature} // Echo text`) - catalog = "Updated Code Mode catalog" - expect( - yield* instructions - .load({ id: agent.id, info: agent }) - .pipe(Effect.flatMap((context) => readUpdate(context, initialized))), - ).toMatchObject({ - text: "The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.\n\nUpdated Code Mode catalog", - }) + // A new tool renders as a small addition delta, not a full catalog restatement. + catalog = [echo, lookup] + const added = yield* instructions + .load({ id: agent.id, info: agent }) + .pipe(Effect.flatMap((context) => readUpdate(context, 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") + + // Losing a tool (permission or agent change) renders as a removal delta. + catalog = [echo] + const removed = yield* instructions + .load({ id: agent.id, info: agent }) + .pipe(Effect.flatMap((context) => readUpdate(context, { 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.", + ) catalog = undefined expect( @@ -46,4 +71,28 @@ describe("CodeModeInstructions", () => { }) }).pipe(Effect.provide(layer)) }) + + it.effect("stores a canonical sorted snapshot so registration order does not churn history", () => { + let catalog: ReadonlyArray = [lookup, echo] + const layer = AppNodeBuilder.build(CodeModeInstructions.node, [ + [ + CodeMode.node, + Layer.mock(CodeMode.Service, { + materialize: () => Effect.succeed({ catalog }), + register: () => Effect.void, + }), + ], + ]) + + return Effect.gen(function* () { + const instructions = yield* CodeModeInstructions.Service + const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial)) + + catalog = [echo, lookup] + const update = yield* instructions + .load({ id: agent.id, info: agent }) + .pipe(Effect.flatMap((context) => readUpdate(context, initialized))) + expect(update.changed).toBe(false) + }).pipe(Effect.provide(layer)) + }) })