From 6d3e342b25db295bb266e28bdb1ebf09bdbae58d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Fri, 6 Nov 2026 00:19:58 +0100 Subject: [PATCH] fix(coding-agent): follow local schema refs in tool search and codemode summaries (#11850) Fixes #11803. **Problem** `tool_search` builds a tool's search text from its parameter schema but never followed `$ref`. MCP servers (Pydantic, FastMCP) often put nested models in `$defs`: ```json { "properties": { "options": { "$ref": "#/$defs/options" } }, "$defs": { "options": { "properties": { "mode": { "description": "reconciliation" } } } } } ``` Search text was `Inspect records options`, so `tool_search("reconciliation")` found nothing. Durable's copy of `tool_search` had the same gap. The codemode `resolves to` summary had the same blind spot for output schemas: a field behind a `$ref` showed as `structuredContent?` with no field names. **Fix** - pi-codemode exports its existing local JSON Pointer resolver as `resolveSchemaRef(ref, root)`. A malformed percent escape now resolves to nothing instead of throwing `URIError` (this also affected `schemaToType()`). - coding-agent and Durable `schemaText()` follow local `$ref`s. Each referenced subschema is read once, so recursive and shared definitions neither loop nor inflate BM25 term counts. Unreferenced `$defs` and remote refs stay out. - coding-agent `describeFields()`/`describeUnionFields()` resolve `$ref` chains against the output schema root. **Tests** - `packages/codemode/test/declarations.test.ts`: escaped, remote, missing, malformed refs. - `packages/coding-agent/test/tool-search.test.ts`: refs, escaped segments, recursion and shared definitions, unused definitions. - `packages/coding-agent/test/suite/agent-session-codemode.test.ts`: summary through root, field, const, and union refs. - `packages/durable/test/tool-search.test.ts`: end-to-end load via a ref'd description; unused definition does not match. `npm run check` passes. --- packages/codemode/CHANGELOG.md | 8 +++ packages/codemode/src/declarations.ts | 17 +++++- packages/codemode/src/index.ts | 1 + packages/codemode/test/declarations.test.ts | 27 ++++++++- packages/coding-agent/CHANGELOG.md | 4 +- .../src/extensions/codemode/tool.ts | 51 +++++++++++++---- .../src/extensions/tool-search/tool.ts | 24 ++++++-- .../test/suite/agent-session-codemode.test.ts | 44 +++++++++++++++ .../coding-agent/test/tool-search.test.ts | 55 +++++++++++++++++++ packages/durable/CHANGELOG.md | 5 ++ .../durable/src/tool-search/tool-search.ts | 24 ++++++-- packages/durable/test/tool-search.test.ts | 31 +++++++++++ 12 files changed, 262 insertions(+), 29 deletions(-) diff --git a/packages/codemode/CHANGELOG.md b/packages/codemode/CHANGELOG.md index 6c6de2736..983c2a6c0 100644 --- a/packages/codemode/CHANGELOG.md +++ b/packages/codemode/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Added + +- Added `resolveSchemaRef(ref, root)`, which returns the subschema a local `$ref` (`#`, `#/$defs/Item`) names in a schema, with URI-decoded and JSON Pointer unescaped segments ([#11803](https://github.com/earendil-works/pi/issues/11803)) + +### Fixed + +- Fixed `schemaToType()`, `renderDeclarations()`, `renderToolSignature()`, and `renderToolSample()` throwing `URIError` for a local `$ref` with a malformed percent escape (`#/$defs/%E0`), so one such tool schema failed every caller that renders all tools. The reference now renders as `unknown`, like a reference that names nothing ([#11803](https://github.com/earendil-works/pi/issues/11803)) + ## [1.5.0] - 2026-11-02 ### Changed diff --git a/packages/codemode/src/declarations.ts b/packages/codemode/src/declarations.ts index 3c62e6382..3a19f59e1 100644 --- a/packages/codemode/src/declarations.ts +++ b/packages/codemode/src/declarations.ts @@ -153,11 +153,22 @@ interface SchemaContext { tags: boolean; } -function resolveRef(ref: string, root: CodemodeJsonSchema): CodemodeJsonSchema | undefined { +/** + * The subschema a local `$ref` (`#`, `#/$defs/Item`) names in `root`, or undefined for remote references and + * references that name nothing. Pointer segments are URI-decoded and JSON Pointer unescaped (`~1` is `/`, `~0` is `~`). + */ +export function resolveSchemaRef(ref: string, root: unknown): CodemodeJsonSchema | undefined { if (ref !== "#" && !ref.startsWith("#/")) return undefined; let current: unknown = root; for (const segment of ref.slice(2).split("/").filter(Boolean)) { - const key = decodeURIComponent(segment).replaceAll("~1", "/").replaceAll("~0", "~"); + let decoded: string; + try { + decoded = decodeURIComponent(segment); + } catch { + // Schemas come from tools and MCP servers: a malformed escape names nothing instead of failing the caller. + return undefined; + } + const key = decoded.replaceAll("~1", "/").replaceAll("~0", "~"); if (typeof current !== "object" || current === null || Array.isArray(current) || !(key in current)) { return undefined; } @@ -176,7 +187,7 @@ function toType(schema: CodemodeJsonSchema, context: SchemaContext): string { if (typeof schema.$ref === "string") { const ref = schema.$ref; if (context.resolving.has(ref) || context.expansions >= MAX_REF_EXPANSIONS) return "unknown"; - const target = resolveRef(ref, context.root); + const target = resolveSchemaRef(ref, context.root); if (target === undefined) return "unknown"; context.expansions++; context.resolving.add(ref); diff --git a/packages/codemode/src/index.ts b/packages/codemode/src/index.ts index d2e4d858d..3eedea10e 100644 --- a/packages/codemode/src/index.ts +++ b/packages/codemode/src/index.ts @@ -5,6 +5,7 @@ export { renderToolOutputType, renderToolSample, renderToolSignature, + resolveSchemaRef, schemaToType, } from "./declarations.ts"; export { toCodemodeIdentifier } from "./identifier.ts"; diff --git a/packages/codemode/test/declarations.test.ts b/packages/codemode/test/declarations.test.ts index 84daedef7..df981337b 100644 --- a/packages/codemode/test/declarations.test.ts +++ b/packages/codemode/test/declarations.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { renderDeclarations, renderToolSample, renderToolSignature, schemaToType } from "../src/index.ts"; +import { + renderDeclarations, + renderToolSample, + renderToolSignature, + resolveSchemaRef, + schemaToType, +} from "../src/index.ts"; const execute = () => undefined; @@ -222,6 +228,25 @@ describe("schemaToType", () => { }); }); +describe("resolveSchemaRef", () => { + const item = { type: "string" }; + const root = { $defs: { item, "a/b": item, "c~d": item, "e f": item } }; + + it("resolves local references with escaped segments", () => { + expect(resolveSchemaRef("#", root)).toBe(root); + expect(resolveSchemaRef("#/$defs/item", root)).toBe(item); + expect(resolveSchemaRef("#/$defs/a~1b", root)).toBe(item); + expect(resolveSchemaRef("#/$defs/c~0d", root)).toBe(item); + expect(resolveSchemaRef("#/$defs/e%20f", root)).toBe(item); + }); + + it("returns undefined for remote, missing, and malformed references", () => { + expect(resolveSchemaRef("other.json#/$defs/item", root)).toBeUndefined(); + expect(resolveSchemaRef("#/$defs/missing", root)).toBeUndefined(); + expect(resolveSchemaRef("#/$defs/%E0%A4%A", root)).toBeUndefined(); + }); +}); + describe("tool declarations", () => { it("renders signatures with normalized identifiers", () => { expect( diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f0f3782b1..00fbc6169 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -38,7 +38,9 @@ - Fixed Ctrl+P not switching to the only model in scope (or the only available model) when the active model is a different one; it showed "Only one model in scope" instead. Cycling forward from a model outside the scope now also selects the first scoped model instead of skipping it ([#11806](https://github.com/earendil-works/pi/issues/11806)) - Fixed `/reload` and session switches leaving extension overlays on screen when an extension had more than one `ctx.ui.custom({ overlay: true })` open: only the topmost overlay was removed, and the others kept focus and input although the extension that opened them was gone. All extension overlays, including hidden ones, are now removed and disposed; pi's own overlays stay open - Fixed `/mcp` sign-in staying on "Connecting…" for minutes after a successful browser sign-in, with Ctrl+C doing nothing: pi waited for the browser to drop an unused connection to the OAuth callback port. The callback server now closes remaining connections when it shuts down ([#11311](https://github.com/earendil-works/pi/issues/11311)) - +- Fixed `tool_search` and `searchTools()` not finding a tool by property names and descriptions behind a local `$ref`, as in MCP input schemas that put their models in `$defs`. Referenced definitions are read once, so recursive schemas do not loop, and definitions nothing references stay out of the search text ([#11803](https://github.com/earendil-works/pi/issues/11803)) +- Fixed the codemode `resolves to` summary of a tool leaving out field names behind a local `$ref`, for example `structuredContent?` instead of `structuredContent?: { results }` for MCP output schemas that put their models in `$defs` ([#11803](https://github.com/earendil-works/pi/issues/11803)) +- Fixed every codemode script failing with `URI malformed` when any callable tool, such as an MCP tool, had a `$ref` with a malformed percent escape (`#/$defs/%E0`) in its schema, even scripts that do not call that tool. The parameter now renders as `unknown` in the tool's declaration ([#11803](https://github.com/earendil-works/pi/issues/11803)) ## [1.5.0] - 2026-11-02 diff --git a/packages/coding-agent/src/extensions/codemode/tool.ts b/packages/coding-agent/src/extensions/codemode/tool.ts index 648ba6109..e046e0255 100644 --- a/packages/coding-agent/src/extensions/codemode/tool.ts +++ b/packages/coding-agent/src/extensions/codemode/tool.ts @@ -24,7 +24,12 @@ import { join } from "node:path"; import type { AgentTool } from "@earendil-works/pi-agent-core"; import type { CodemodeJsonSchema, CodemodeTool } from "@earendil-works/pi-codemode"; -import { renderToolOutputType, renderToolSample, toCodemodeIdentifier } from "@earendil-works/pi-codemode/declarations"; +import { + renderToolOutputType, + renderToolSample, + resolveSchemaRef, + toCodemodeIdentifier, +} from "@earendil-works/pi-codemode/declarations"; import { CODEMODE_SOURCE_GRAMMAR } from "@earendil-works/pi-codemode/source"; import { type Static, Type } from "typebox"; import { getDocsPath } from "../../config.ts"; @@ -272,20 +277,22 @@ export function createCodemodeDescription( /** * The field names of an object schema, `{ output, exit_code, full_output_path? }`, with the fields of * object-typed fields `depth` levels deep: `{ content, structuredContent?: { results } }`, and the - * value of `const` fields: `{ action: "start", id }`. Undefined for other schemas. + * value of `const` fields: `{ action: "start", id }`. Undefined for other schemas. Local `$ref`s resolve + * against `root`. */ -function describeFields(schema: unknown, depth: number): string | undefined { - if (typeof schema !== "object" || schema === null) return undefined; - const { type, properties, required } = schema as Record; +function describeFields(schema: unknown, root: CodemodeJsonSchema, depth: number): string | undefined { + const resolved = followRef(schema, root); + if (typeof resolved !== "object" || resolved === null) return undefined; + const { type, properties, required } = resolved as Record; if (type !== "object" || typeof properties !== "object" || properties === null) return undefined; const requiredNames = new Set(Array.isArray(required) ? required : []); const fields = Object.entries(properties).map(([name, field]) => { - const constValue = (field as Record | null)?.const; + const constValue = (followRef(field, root) as Record | null)?.const; const nested = constValue !== undefined ? JSON.stringify(constValue) : depth > 0 - ? describeFields(field, depth - 1) + ? describeFields(field, root, depth - 1) : undefined; return `${name}${requiredNames.has(name) ? "" : "?"}${nested ? `: ${nested}` : ""}`; }); @@ -301,7 +308,8 @@ function describeFields(schema: unknown, depth: number): string | undefined { function describeOutput(name: string, schema: CodemodeJsonSchema | undefined): string { const type = renderToolOutputType(schema); if (type === "string") return "a string"; - const fields = describeFields(schema, 1) ?? describeUnionFields(schema); + const fields = + schema === undefined ? undefined : (describeFields(schema, schema, 1) ?? describeUnionFields(schema, schema)); if (fields) return `\`${fields}\``; if (!type.includes("\n")) return `\`${type}\``; return `a value; \`await describeTool(${JSON.stringify(name)})\` shows its type`; @@ -311,15 +319,34 @@ function describeOutput(name: string, schema: CodemodeJsonSchema | undefined): s * The field names of each variant of an `anyOf`/`oneOf` whose variants are all objects, joined by * `|`. */ -function describeUnionFields(schema: unknown): string | undefined { - if (typeof schema !== "object" || schema === null) return undefined; - const { anyOf, oneOf } = schema as Record; +function describeUnionFields(schema: unknown, root: CodemodeJsonSchema): string | undefined { + const resolved = followRef(schema, root); + if (typeof resolved !== "object" || resolved === null) return undefined; + const { anyOf, oneOf } = resolved as Record; const variants = anyOf ?? oneOf; if (!Array.isArray(variants) || variants.length === 0) return undefined; - const described = variants.map((variant) => describeFields(variant, 0)); + const described = variants.map((variant) => describeFields(variant, root, 0)); return described.every((fields) => fields !== undefined) ? described.join(" | ") : undefined; } +/** + * The subschema the local `$ref` chain of `schema` names in `root`, so MCP output schemas that put their models in + * `$defs` still show field names. Remote, missing, and cyclic references stop at the schema holding them. + */ +function followRef(schema: unknown, root: CodemodeJsonSchema): unknown { + const seen = new Set(); + let current = schema; + while (typeof current === "object" && current !== null && !seen.has(current)) { + const ref = (current as Record).$ref; + if (typeof ref !== "string") break; + const target = resolveSchemaRef(ref, root); + if (target === undefined) break; + seen.add(current); + current = target; + } + return current; +} + /** * A declared tool's description followed by how scripts call it and what the call resolves to. The * arguments are the tool's declared parameters, so they are not repeated. diff --git a/packages/coding-agent/src/extensions/tool-search/tool.ts b/packages/coding-agent/src/extensions/tool-search/tool.ts index 2cf07c769..59942b15c 100644 --- a/packages/coding-agent/src/extensions/tool-search/tool.ts +++ b/packages/coding-agent/src/extensions/tool-search/tool.ts @@ -8,6 +8,7 @@ * like any other tool change and survives `/tree`, resume, and fork on that branch. */ +import { resolveSchemaRef } from "@earendil-works/pi-codemode/declarations"; import { type Static, Type } from "typebox"; import type { ExtensionAPI, @@ -95,20 +96,31 @@ function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -/** Schema descriptions and property names, recursively. */ -function schemaText(schema: unknown, parts: string[]): void { +/** + * Schema descriptions and property names, recursively, following local `$ref`s into `root`. `seen` holds the + * subschemas already reached through a reference, so recursive and shared definitions are read once, and + * definitions no property references are not read. + */ +function schemaText(schema: unknown, root: unknown, parts: string[], seen: Set): void { if (!isObject(schema)) return; + if (typeof schema.$ref === "string") { + const target = resolveSchemaRef(schema.$ref, root); + if (isObject(target) && !seen.has(target)) { + seen.add(target); + schemaText(target, root, parts, seen); + } + } if (typeof schema.description === "string") parts.push(schema.description); if (isObject(schema.properties)) { for (const [name, property] of Object.entries(schema.properties)) { parts.push(name); - schemaText(property, parts); + schemaText(property, root, parts, seen); } } - schemaText(schema.items, parts); + schemaText(schema.items, root, parts, seen); for (const key of ["anyOf", "oneOf", "allOf"]) { const variants = schema[key]; - if (Array.isArray(variants)) for (const variant of variants) schemaText(variant, parts); + if (Array.isArray(variants)) for (const variant of variants) schemaText(variant, root, parts, seen); } } @@ -121,7 +133,7 @@ export function createToolSearchDocument( namespace?: ToolNamespace, ): ToolSearchDocument { const parts = [tool.description]; - schemaText(tool.parameters, parts); + schemaText(tool.parameters, tool.parameters, parts, new Set([tool.parameters])); const namespaceText = namespace ? [namespace.name, namespace.description ?? "", namespace.instructions ?? ""].join(" ") : ""; diff --git a/packages/coding-agent/test/suite/agent-session-codemode.test.ts b/packages/coding-agent/test/suite/agent-session-codemode.test.ts index 3e0620635..0ea7fb791 100644 --- a/packages/coding-agent/test/suite/agent-session-codemode.test.ts +++ b/packages/coding-agent/test/suite/agent-session-codemode.test.ts @@ -230,6 +230,50 @@ describe("AgentSession codemode tool", () => { expect(description("count")).toBe("count tool\n\nCodemode: `tools.count(args)` resolves to `number`."); }); + it("summarizes output fields behind local schema references", async () => { + const outputTool = (name: string, outputSchema: Record): AgentTool => ({ + name, + label: name, + description: `${name} tool`, + parameters: Type.Object({}), + outputSchema: Type.Unsafe(outputSchema), + execute: async () => ({ content: [], details: {} }), + }); + const report = outputTool("report", { + $ref: "#/$defs/Report", + $defs: { + Report: { + type: "object", + properties: { kind: { $ref: "#/$defs/Kind" }, summary: { $ref: "#/$defs/Summary" } }, + required: ["kind", "summary"], + }, + Kind: { const: "ledger" }, + Summary: { type: "object", properties: { total: { type: "number" } }, required: ["total"] }, + }, + }); + const result = outputTool("result", { + anyOf: [{ $ref: "#/$defs/Ok" }, { $ref: "#/$defs/Failed" }], + $defs: { + Ok: { type: "object", properties: { value: { type: "string" } }, required: ["value"] }, + Failed: { type: "object", properties: { error: { type: "string" } }, required: ["error"] }, + }, + }); + const harness = await setup([ + (pi: ExtensionAPI) => { + for (const tool of [report, result]) pi.registerTool(createToolDefinitionFromAgentTool(tool)); + }, + ]); + harness.session.setActiveToolsByName(["report", "result", "codemode"]); + const description = (name: string) => + harness.session.agent.state.tools.find((tool) => tool.name === name)?.description ?? ""; + expect(description("report")).toBe( + 'report tool\n\nCodemode: `tools.report(args)` resolves to `{ kind: "ledger", summary: { total } }`.', + ); + expect(description("result")).toBe( + "result tool\n\nCodemode: `tools.result(args)` resolves to `{ value } | { error }`.", + ); + }); + // #10343 it("shows the guidelines of tools that do not fit the inline budget through describeTool()", async () => { const harness = await setup(); diff --git a/packages/coding-agent/test/tool-search.test.ts b/packages/coding-agent/test/tool-search.test.ts index 68a59cec7..0af75c630 100644 --- a/packages/coding-agent/test/tool-search.test.ts +++ b/packages/coding-agent/test/tool-search.test.ts @@ -195,6 +195,61 @@ describe("Bm25Ranker", () => { }); }); +// #11803 +describe("schema references in the search text", () => { + function searchText(parameters: Record): string { + const entry = tool("mcp__ledger__inspect", "Inspect records"); + return createToolSearchDocument({ ...entry, parameters: Type.Unsafe(parameters) }).text; + } + + it("reads names and descriptions behind local references", () => { + const text = searchText({ + type: "object", + properties: { options: { $ref: "#/$defs/options" } }, + $defs: { + options: { type: "object", properties: { mode: { type: "string", description: "reconciliation" } } }, + }, + }); + expect(text).toBe("Inspect records options mode reconciliation"); + }); + + it("follows escaped pointer segments", () => { + const text = searchText({ + type: "object", + properties: { range: { $ref: "#/$defs/date~1range" } }, + $defs: { "date/range": { description: "fiscal period" } }, + }); + expect(text).toBe("Inspect records range fiscal period"); + }); + + it("reads recursive and shared definitions once", () => { + const text = searchText({ + type: "object", + properties: { root: { $ref: "#/$defs/node" }, other: { $ref: "#/$defs/node" }, self: { $ref: "#" } }, + $defs: { + node: { + description: "tree node", + properties: { children: { type: "array", items: { $ref: "#/$defs/node" } } }, + }, + }, + }); + expect(text).toBe("Inspect records root tree node children other self"); + }); + + it("leaves out unreferenced definitions and remote or missing references", () => { + const text = searchText({ + type: "object", + properties: { + remote: { $ref: "https://example.com/schema.json#/$defs/x" }, + missing: { $ref: "#/$defs/missing" }, + malformed: { $ref: "#/$defs/%E0%A4%A" }, + }, + $defs: { unused: { description: "reconciliation" } }, + }); + expect(text).toBe("Inspect records remote missing malformed"); + }); +}); + describe("codemode description catalog", () => { const plain = tool("read_notes", "Read notes."); const github = ["a", "b", "c"].map((suffix) => tool(`mcp__github__${suffix}`, `GitHub ${suffix}.`)); diff --git a/packages/durable/CHANGELOG.md b/packages/durable/CHANGELOG.md index 9ef9732d9..455bdae13 100644 --- a/packages/durable/CHANGELOG.md +++ b/packages/durable/CHANGELOG.md @@ -10,6 +10,11 @@ - `SqliteStorage.commit()` costs a few SQL statements per call instead of about eight per batch: it reads the check state of all batches up front, checks them in memory, and writes the accepted batches per table in multi-row statements (rows as one JSON array parameter; rows with much text bound column by column), so a group commit over a remote SQLite takes a few round trips, not hundreds. A stored document record that cannot be parsed now rejects the call as a storage failure instead of refusing the batch. +### Fixed + +- Fixed `tool_search` not finding a deferred tool by property names and descriptions behind a local `$ref` in its parameters, as in MCP input schemas that put their models in `$defs`. Referenced definitions are read once, so recursive schemas do not loop, and definitions nothing references stay out of the search text ([#11803](https://github.com/earendil-works/pi/issues/11803)) +- Fixed every code mode run failing with `URI malformed` when any callable tool had a `$ref` with a malformed percent escape (`#/$defs/%E0`) in its schema, even scripts that do not call that tool. The parameter now renders as `unknown` in the tool's declaration ([#11803](https://github.com/earendil-works/pi/issues/11803)) + ## [1.5.0] - 2026-11-02 ### Breaking Changes diff --git a/packages/durable/src/tool-search/tool-search.ts b/packages/durable/src/tool-search/tool-search.ts index 5d79b007e..50cce85f2 100644 --- a/packages/durable/src/tool-search/tool-search.ts +++ b/packages/durable/src/tool-search/tool-search.ts @@ -3,6 +3,7 @@ * and the matches are loaded with `control.addTools`, so the next request offers them. Loading writes the * conversation's `modelTools` (its `add`), so loaded tools stay offered on that branch, also after a compaction. */ +import { resolveSchemaRef } from "@earendil-works/pi-codemode/declarations"; import { num, obj, opt, str } from "@earendil-works/pi-schema"; import { defineExtension, defineTool } from "../harness/define.ts"; import type { ToolRegistration } from "../harness/types.ts"; @@ -29,27 +30,38 @@ function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -/** Schema descriptions and property names, recursively. */ -function schemaText(schema: unknown, parts: string[]): void { +/** + * Schema descriptions and property names, recursively, following local `$ref`s into `root`. `seen` holds the + * subschemas already reached through a reference, so recursive and shared definitions are read once, and + * definitions no property references are not read. + */ +function schemaText(schema: unknown, root: unknown, parts: string[], seen: Set): void { if (!isObject(schema)) return; + if (typeof schema.$ref === "string") { + const target = resolveSchemaRef(schema.$ref, root); + if (isObject(target) && !seen.has(target)) { + seen.add(target); + schemaText(target, root, parts, seen); + } + } if (typeof schema.description === "string") parts.push(schema.description); if (isObject(schema.properties)) { for (const [name, property] of Object.entries(schema.properties)) { parts.push(name); - schemaText(property, parts); + schemaText(property, root, parts, seen); } } - schemaText(schema.items, parts); + schemaText(schema.items, root, parts, seen); for (const key of ["anyOf", "oneOf", "allOf"]) { const variants = schema[key]; - if (Array.isArray(variants)) for (const variant of variants) schemaText(variant, parts); + if (Array.isArray(variants)) for (const variant of variants) schemaText(variant, root, parts, seen); } } /** Search text of a tool: its description and its parameters' names and descriptions; the ranker reads the name. */ function searchText(tool: ToolRegistration): string { const parts = [tool.description]; - schemaText(tool.parameters, parts); + schemaText(tool.parameters, tool.parameters, parts, new Set([tool.parameters])); return parts.filter((part) => part.trim() !== "").join(" "); } diff --git a/packages/durable/test/tool-search.test.ts b/packages/durable/test/tool-search.test.ts index 988c85d5a..80a00b6d8 100644 --- a/packages/durable/test/tool-search.test.ts +++ b/packages/durable/test/tool-search.test.ts @@ -193,6 +193,37 @@ describe("deferred tools and tool_search", () => { await harness.close(context); }); + // #11803 + it("finds a tool by a parameter description behind a local reference, and not by unreferenced definitions", async () => { + const chat = setup(); + addTool( + chat.registry, + tool("ledger_inspect", "Inspect records", { + deferred: true, + parameters: { + type: "object", + properties: { options: { $ref: "#/$defs/options" } }, + $defs: { + options: { type: "object", properties: { mode: { type: "string", description: "reconciliation" } } }, + unused: { description: "forecast" }, + }, + }, + }), + ); + chat.faux.setResponses([ + call("tool_search", { query: "forecast" }), + call("tool_search", { query: "reconciliation" }, "c2"), + DONE, + ]); + const { harness, root } = await openChat(new MemoryStorage(), chat); + await (await root.submit({ type: "input", content: "go" }, context)).wait(context); + expect(toolResultTexts(await allEntries(root))).toEqual([ + "No matching tools found.", + "Loaded 1 tool. They are available from your next call:\n- ledger_inspect: Inspect records", + ]); + await harness.close(context); + }); + it("finds nothing for an unrelated query and loads nothing", async () => { const chat = setup(); chat.faux.setResponses([call("tool_search", { query: "weather forecast" }), DONE]);