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.
This commit is contained in:
Petr Houška 2026-11-06 00:19:58 +01:00 • committed by GitHub
parent 850ea2eae2
commit 6d3e342b25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 262 additions and 29 deletions

View file

@ -2,6 +2,14 @@
## [Unreleased] ## [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 ## [1.5.0] - 2026-11-02
### Changed ### Changed

View file

@ -153,11 +153,22 @@ interface SchemaContext {
tags: boolean; 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; if (ref !== "#" && !ref.startsWith("#/")) return undefined;
let current: unknown = root; let current: unknown = root;
for (const segment of ref.slice(2).split("/").filter(Boolean)) { 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)) { if (typeof current !== "object" || current === null || Array.isArray(current) || !(key in current)) {
return undefined; return undefined;
} }
@ -176,7 +187,7 @@ function toType(schema: CodemodeJsonSchema, context: SchemaContext): string {
if (typeof schema.$ref === "string") { if (typeof schema.$ref === "string") {
const ref = schema.$ref; const ref = schema.$ref;
if (context.resolving.has(ref) || context.expansions >= MAX_REF_EXPANSIONS) return "unknown"; 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"; if (target === undefined) return "unknown";
context.expansions++; context.expansions++;
context.resolving.add(ref); context.resolving.add(ref);

View file

@ -5,6 +5,7 @@ export {
renderToolOutputType, renderToolOutputType,
renderToolSample, renderToolSample,
renderToolSignature, renderToolSignature,
resolveSchemaRef,
schemaToType, schemaToType,
} from "./declarations.ts"; } from "./declarations.ts";
export { toCodemodeIdentifier } from "./identifier.ts"; export { toCodemodeIdentifier } from "./identifier.ts";

View file

@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest"; 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; 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", () => { describe("tool declarations", () => {
it("renders signatures with normalized identifiers", () => { it("renders signatures with normalized identifiers", () => {
expect( expect(

View file

@ -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 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 `/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 `/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 ## [1.5.0] - 2026-11-02

View file

@ -24,7 +24,12 @@
import { join } from "node:path"; import { join } from "node:path";
import type { AgentTool } from "@earendil-works/pi-agent-core"; import type { AgentTool } from "@earendil-works/pi-agent-core";
import type { CodemodeJsonSchema, CodemodeTool } from "@earendil-works/pi-codemode"; 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 { CODEMODE_SOURCE_GRAMMAR } from "@earendil-works/pi-codemode/source";
import { type Static, Type } from "typebox"; import { type Static, Type } from "typebox";
import { getDocsPath } from "../../config.ts"; 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 * 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 * 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 { function describeFields(schema: unknown, root: CodemodeJsonSchema, depth: number): string | undefined {
if (typeof schema !== "object" || schema === null) return undefined; const resolved = followRef(schema, root);
const { type, properties, required } = schema as Record<string, unknown>; if (typeof resolved !== "object" || resolved === null) return undefined;
const { type, properties, required } = resolved as Record<string, unknown>;
if (type !== "object" || typeof properties !== "object" || properties === null) return undefined; if (type !== "object" || typeof properties !== "object" || properties === null) return undefined;
const requiredNames = new Set(Array.isArray(required) ? required : []); const requiredNames = new Set(Array.isArray(required) ? required : []);
const fields = Object.entries(properties).map(([name, field]) => { const fields = Object.entries(properties).map(([name, field]) => {
const constValue = (field as Record<string, unknown> | null)?.const; const constValue = (followRef(field, root) as Record<string, unknown> | null)?.const;
const nested = const nested =
constValue !== undefined constValue !== undefined
? JSON.stringify(constValue) ? JSON.stringify(constValue)
: depth > 0 : depth > 0
? describeFields(field, depth - 1) ? describeFields(field, root, depth - 1)
: undefined; : undefined;
return `${name}${requiredNames.has(name) ? "" : "?"}${nested ? `: ${nested}` : ""}`; 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 { function describeOutput(name: string, schema: CodemodeJsonSchema | undefined): string {
const type = renderToolOutputType(schema); const type = renderToolOutputType(schema);
if (type === "string") return "a string"; 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 (fields) return `\`${fields}\``;
if (!type.includes("\n")) return `\`${type}\``; if (!type.includes("\n")) return `\`${type}\``;
return `a value; \`await describeTool(${JSON.stringify(name)})\` shows its 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 * The field names of each variant of an `anyOf`/`oneOf` whose variants are all objects, joined by
* `|`. * `|`.
*/ */
function describeUnionFields(schema: unknown): string | undefined { function describeUnionFields(schema: unknown, root: CodemodeJsonSchema): string | undefined {
if (typeof schema !== "object" || schema === null) return undefined; const resolved = followRef(schema, root);
const { anyOf, oneOf } = schema as Record<string, unknown>; if (typeof resolved !== "object" || resolved === null) return undefined;
const { anyOf, oneOf } = resolved as Record<string, unknown>;
const variants = anyOf ?? oneOf; const variants = anyOf ?? oneOf;
if (!Array.isArray(variants) || variants.length === 0) return undefined; 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; 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<unknown>();
let current = schema;
while (typeof current === "object" && current !== null && !seen.has(current)) {
const ref = (current as Record<string, unknown>).$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 * 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. * arguments are the tool's declared parameters, so they are not repeated.

View file

@ -8,6 +8,7 @@
* like any other tool change and survives `/tree`, resume, and fork on that branch. * 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 Static, Type } from "typebox";
import type { import type {
ExtensionAPI, ExtensionAPI,
@ -95,20 +96,31 @@ function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); 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<unknown>): void {
if (!isObject(schema)) return; 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 (typeof schema.description === "string") parts.push(schema.description);
if (isObject(schema.properties)) { if (isObject(schema.properties)) {
for (const [name, property] of Object.entries(schema.properties)) { for (const [name, property] of Object.entries(schema.properties)) {
parts.push(name); 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"]) { for (const key of ["anyOf", "oneOf", "allOf"]) {
const variants = schema[key]; 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, namespace?: ToolNamespace,
): ToolSearchDocument { ): ToolSearchDocument {
const parts = [tool.description]; const parts = [tool.description];
schemaText(tool.parameters, parts); schemaText(tool.parameters, tool.parameters, parts, new Set([tool.parameters]));
const namespaceText = namespace const namespaceText = namespace
? [namespace.name, namespace.description ?? "", namespace.instructions ?? ""].join(" ") ? [namespace.name, namespace.description ?? "", namespace.instructions ?? ""].join(" ")
: ""; : "";

View file

@ -230,6 +230,50 @@ describe("AgentSession codemode tool", () => {
expect(description("count")).toBe("count tool\n\nCodemode: `tools.count(args)` resolves to `number`."); 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<string, unknown>): 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 // #10343
it("shows the guidelines of tools that do not fit the inline budget through describeTool()", async () => { it("shows the guidelines of tools that do not fit the inline budget through describeTool()", async () => {
const harness = await setup(); const harness = await setup();

View file

@ -195,6 +195,61 @@ describe("Bm25Ranker", () => {
}); });
}); });
// #11803
describe("schema references in the search text", () => {
function searchText(parameters: Record<string, unknown>): 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", () => { describe("codemode description catalog", () => {
const plain = tool("read_notes", "Read notes."); const plain = tool("read_notes", "Read notes.");
const github = ["a", "b", "c"].map((suffix) => tool(`mcp__github__${suffix}`, `GitHub ${suffix}.`)); const github = ["a", "b", "c"].map((suffix) => tool(`mcp__github__${suffix}`, `GitHub ${suffix}.`));

View file

@ -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. - `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 ## [1.5.0] - 2026-11-02
### Breaking Changes ### Breaking Changes

View file

@ -3,6 +3,7 @@
* and the matches are loaded with `control.addTools`, so the next request offers them. Loading writes the * 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. * 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 { num, obj, opt, str } from "@earendil-works/pi-schema";
import { defineExtension, defineTool } from "../harness/define.ts"; import { defineExtension, defineTool } from "../harness/define.ts";
import type { ToolRegistration } from "../harness/types.ts"; import type { ToolRegistration } from "../harness/types.ts";
@ -29,27 +30,38 @@ function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); 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<unknown>): void {
if (!isObject(schema)) return; 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 (typeof schema.description === "string") parts.push(schema.description);
if (isObject(schema.properties)) { if (isObject(schema.properties)) {
for (const [name, property] of Object.entries(schema.properties)) { for (const [name, property] of Object.entries(schema.properties)) {
parts.push(name); 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"]) { for (const key of ["anyOf", "oneOf", "allOf"]) {
const variants = schema[key]; 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. */ /** Search text of a tool: its description and its parameters' names and descriptions; the ranker reads the name. */
function searchText(tool: ToolRegistration): string { function searchText(tool: ToolRegistration): string {
const parts = [tool.description]; 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(" "); return parts.filter((part) => part.trim() !== "").join(" ");
} }

View file

@ -193,6 +193,37 @@ describe("deferred tools and tool_search", () => {
await harness.close(context); 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 () => { it("finds nothing for an unrelated query and loads nothing", async () => {
const chat = setup(); const chat = setup();
chat.faux.setResponses([call("tool_search", { query: "weather forecast" }), DONE]); chat.faux.setResponses([call("tool_search", { query: "weather forecast" }), DONE]);