mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
850ea2eae2
commit
6d3e342b25
12 changed files with 262 additions and 29 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> {
|
|||
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 (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(" ");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue