Просмотр исходного кода

feat(core): render CodeMode catalog deltas from structured snapshots (#38183)

Aiden Cline 3 недель назад
Родитель
Сommit
4605308be2

+ 6 - 7
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<CodeMode.Result, never, ToolServices>
 ```
 
@@ -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
 

+ 4 - 13
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<Provided extends Record<string, unknown> = {}> = {
 export type DataValue = Schema.Json
 
 /** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */
-export type Options<Provided extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Provided>, "code"> & {
-  /** Progressive-disclosure configuration for the agent-facing tool catalog. */
-  readonly discovery?: DiscoveryOptions
-}
+export type Options<Provided extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Provided>, "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<R = never> = {
   readonly catalog: () => ReadonlyArray<ToolDescription>
-  readonly instructions: () => string
   readonly execute: (code: string) => Effect.Effect<Result, never, R>
 }
 
@@ -147,11 +139,10 @@ export const make = <const Provided extends Record<string, unknown> = {}>(
 ): Runtime<Services<Provided>> => {
   const tools = (options.tools ?? {}) as Tools<Services<Provided>>
   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<Provided>({ ...options, code }, limits, prepared.searchIndex),
   }
 }

+ 1 - 0
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"

+ 11 - 151
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<T> = ServicesOf<T, []>
@@ -70,7 +69,6 @@ export type ToolDescription = {
 
 export type SafeObject = Record<string, unknown>
 
-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 = <R>(tools: Tools<R>) =>
 
 export type DiscoveryPlan = {
   readonly catalog: ReadonlyArray<ToolDescription>
-  readonly instructions: string
   readonly searchIndex: ReadonlyArray<SearchEntry>
 }
 
@@ -423,17 +420,12 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): 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 = <R>(path: string, tool: Tool<R>, description: ToolDescription): SearchEntry => ({
   description,
   namespace: path.split(".", 1)[0]!,
@@ -451,146 +443,10 @@ const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescript
 export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> =>
   visibleTools(tools).map(({ path, tool, description }) => toSearchEntry(path, tool, description))
 
-// Budget signatures round-robin so every namespace remains visible.
-export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget): DiscoveryPlan => {
-  if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) {
-    throw new RangeError("discovery.catalogBudget must be a non-negative safe integer")
-  }
+export const prepare = <R>(tools: Tools<R>): DiscoveryPlan => {
   const visible = visibleTools(tools)
-  const described = visible.map(({ description }) => description)
-
-  const namespaces = new Map<string, Array<ToolDescription>>()
-  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<ToolDescription>(),
-    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<string, ReadonlySet<ToolDescription>>(
-    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.<namespace>.<tool>(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: "<intent + key nouns>" })`.',
-              "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<unknown>` 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.<namespace>.<tool>(item)))`, or use `tools.<namespace>["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.<namespace>)` lists its tools; `for...in` works on both.",
-        ...(complete
-          ? []
-          : [
-              '- Browse one namespace: `search({ query: "", namespace: "<name>" })`.',
-              "- 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<string> = [""]
-  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 = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Tool<R> =>
   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 = <R>(
       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(

+ 3 - 181
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<string>',
       },
     ])
-    expect(runtime.instructions()).toContain(
-      'tools.context7["resolve-library-id"](input: {\n  libraryName: string,\n}): Promise<string>',
-    )
 
     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.<namespace>.<tool>(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.<field>")
-    expect(instructions).not.toContain("data.<field>")
-    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: "<intent + key nouns>" })`.',
-    )
-    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: "<name>" })`.')
-    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<string> // 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<string> // 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<unknown> = []
     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

+ 7 - 13
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<unknown>',
     )
-    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 () => {

+ 28 - 1
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: {

+ 3 - 2
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<CodeModeCatalog.Entry>
 }
 
 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),
         }
       }),
     })

+ 103 - 0
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<Entry>, 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<typeof Listing.Type>(),
+      }
+    })
+
+  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<typeof Listing.Type>) {
+  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
+    })
+}

+ 131 - 14
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.<namespace>["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<CodeModeCatalog.Entry>): 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.",
+    },
   })
+}

+ 1 - 1
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,

+ 2 - 2
packages/core/src/tool/execute.ts

@@ -126,8 +126,8 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
   })
 }
 
-export const instructions = (registrations: ReadonlyMap<string, Registration>) => {
-  return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).instructions()
+export const catalog = (registrations: ReadonlyMap<string, Registration>) => {
+  return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).catalog()
 }
 
 function runtime(

+ 5 - 4
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<ToolDefinition>
-  readonly codeModeInstructions?: string
+  readonly codeModeCatalog?: ReadonlyArray<CodeModeCatalog.Entry>
   readonly execute: (input: ExecuteInput) => Effect.Effect<ToolOutcome, ToolOutputStore.Error>
 }
 
@@ -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)

+ 7 - 2
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<string>",
+        },
+      ])
     }).pipe(Effect.scoped, Effect.provide(AppNodeBuilder.build(CodeMode.node))),
   )
 })

+ 183 - 0
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<string>`,
+})
+
+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<CodeModeCatalog.Entry>, budget?: number) =>
+  CodeModeInstructions.render(CodeModeCatalog.summarize(entries, budget))
+
+const update = (
+  previous: ReadonlyArray<CodeModeCatalog.Entry>,
+  current: ReadonlyArray<CodeModeCatalog.Entry>,
+  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.<namespace>["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<string>`,
+    )
+    // 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<string>`,
+    )
+    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<string>" }
+    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")
+  })
+})

+ 43 - 26
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<string>",
+}
+
+const lookup: CodeModeCatalog.Entry = {
+  path: "orders.lookup",
+  description: "Look up an order",
+  signature: "tools.orders.lookup(input: {\n  id: string,\n}): Promise<unknown>",
+}
+
 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))
   })
 })

+ 13 - 3
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<string>",
+        },
+      ],
       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<string>")
     expect(userTexts(requests[0])).toEqual(["Existing durable context", "Summarize privately"])
     expect(
       requests[0]?.messages.flatMap((message) =>

+ 1 - 1
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)

+ 13 - 10
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<string>`,
+        },
+      ]
       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 },