diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index e18e2b343..876a30f6d 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -604,8 +604,12 @@ failures for such entries name this cause and the operation to rerun with Advertised input and output schemas contain no reachable recursive references. Schemas share repeated definitions through schema-local references while -preserving complete nested fields and validation rules. Input properties retain -their descriptions and literal examples. +preserving complete nested fields and validation rules. Input schemas whose +shared definitions would exhaust a client's declaration-rendering reference +budget (Pi 1.1.0 code-mode shares 32 reference expansions across an input and +renders every later reference as `unknown`) are re-projected without shared +definitions, so every advertised field renders with complete structure. +Input properties retain their descriptions and literal examples. When a host forwards input schemas to the model, their examples consume context. REA's advertised examples therefore never copy complete producer Evidence. @@ -652,7 +656,7 @@ JSON Schema: objects or arrays. - The few schemas whose validation structure alone exceeds the budget (`compare_web_captures`, `build_reconstruction_obligation_ledger`, - `evaluate_reconstruction_coverage`, `project_managed_application_graph`, + `evaluate_reconstruction_coverage`, `compare_application_versions`, `capture_browser_scenario`) are advertised in a reduced form: property names, reference-resolved types (including union alternatives), first-level guidance, and the root's required-field constraints, @@ -662,8 +666,7 @@ JSON Schema: instead of silent acceptance. Boundary tests pin every advertised compact schema to the budget, keep them -valid JSON Schema that still accepts every canonical example, and hold the -set of six reduced presentations as contracts evolve. Client verification +valid JSON Schema that still accepts every canonical example. Client verification also checks provider-facing sizes and canonical examples after conversion by the actual Kimi Code CLI; local captures do not establish Moonshot acceptance. diff --git a/src/contracts/inputSchemaPresentation.ts b/src/contracts/inputSchemaPresentation.ts index 9663dd832..22ad0ae30 100644 --- a/src/contracts/inputSchemaPresentation.ts +++ b/src/contracts/inputSchemaPresentation.ts @@ -30,6 +30,7 @@ const resolveReference = ( root: Readonly>, reference: string, ): unknown => { + if (reference === "#") return root; if (!reference.startsWith("#/")) return undefined; let value: unknown = root; for (const token of reference.slice(2).split("/")) { @@ -229,3 +230,76 @@ export const presentInputJsonSchema = ( const described = describeSchema(flattened, flattened, describeProperty); return isObject(described) ? described : flattened; }; + +/** + * Clients that render advertised inputs into SDK declarations expand + * schema-local references with a shared per-input budget: Pi 1.1.0 code-mode + * allows 32 expansions and degrades every later reference to `unknown` + * (morluto/rea#1632), hiding the fields those references describe. The budget + * counts conservatively — every resolvable, non-cyclic reference occurrence + * in every schema position — so any reference-resolving client stays within + * its measured limit. + */ +export const advertisedReferenceExpansionBudget = 32; + +const definitionKeys = new Set(["$defs", "definitions"]); + +const countReferenceExpansions = ( + node: unknown, + root: Readonly>, + active: ReadonlySet, + count: { value: number }, +): void => { + if (Array.isArray(node)) { + node.forEach((child) => + countReferenceExpansions(child, root, active, count), + ); + return; + } + if (!isObject(node)) return; + if (typeof node.$ref === "string" && !active.has(node.$ref)) { + const target = resolveReference(root, node.$ref); + if (target !== undefined) { + count.value += 1; + countReferenceExpansions( + target, + root, + new Set([...active, node.$ref]), + count, + ); + return; + } + } + for (const [key, value] of Object.entries(node)) { + if (definitionKeys.has(key)) continue; + if (schemaMaps.has(key) && isObject(value)) { + for (const child of Object.values(value)) + countReferenceExpansions(child, root, active, count); + } else if (schemaArrays.has(key) || schemaChildren.has(key)) { + countReferenceExpansions(value, root, active, count); + } + } +}; + +/** Conservative client expansion count over every schema position. */ +export const countLocalReferenceExpansions = ( + schema: Record, +): number => { + const count = { value: 0 }; + countReferenceExpansions(schema, schema, new Set(), count); + return count.value; +}; + +/** + * Keep the shared-definition form of an advertised input while clients can + * still resolve it. Once rendering would exhaust the reference budget, fall + * back to the inline projection, which carries no shared definitions for the + * nonrecursive schemas this repository advertises, so every field renders + * with complete structure in every reference-resolving client. + */ +export const inputSchemaWithinReferenceExpansionBudget = ( + shared: Record, + inline: () => Record, + budget: number, +): Record => + countLocalReferenceExpansions(shared) <= budget ? shared : inline(); diff --git a/src/contracts/toolInputReferences.test.ts b/src/contracts/toolInputReferences.test.ts index beb63b7e7..f75a06531 100644 --- a/src/contracts/toolInputReferences.test.ts +++ b/src/contracts/toolInputReferences.test.ts @@ -3,7 +3,12 @@ import { Ajv2020 } from "ajv/dist/2020.js"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { presentInputJsonSchema } from "./inputSchemaPresentation.js"; +import { + advertisedReferenceExpansionBudget, + countLocalReferenceExpansions, + inputSchemaWithinReferenceExpansionBudget, + presentInputJsonSchema, +} from "./inputSchemaPresentation.js"; import { toolContract } from "./toolContracts.js"; import { toolInputSchemaWithMetadata } from "./toolSchemaMetadata.js"; import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "./javascript/javascriptRuntimeReconciliationExample.js"; @@ -166,6 +171,105 @@ describe("advertised input references", () => { ); }); +describe("advertised reference expansion budget", () => { + it("counts references in keyword-named fields, excluding literals", () => { + const fields = ["default", "enum", "examples", "const", "$defs"]; + const schema = { + type: "object", + properties: Object.fromEntries( + fields.map((name) => [name, { $ref: "#/$defs/value" }]), + ), + $defs: { value: { type: "string" } }, + default: { $ref: "#/$defs/value" }, + examples: [{ $ref: "#/$defs/value" }], + }; + expect(countLocalReferenceExpansions(schema)).toBe(fields.length); + }); + const reusedComponent = z + .object({ kind: z.literal("rec"), value: z.string() }) + .strict(); + const inputWithReusedFields = (count: number) => + z.strictObject( + Object.fromEntries( + Array.from({ length: count }, (_, index) => [ + `field_${String(index).padStart(2, "0")}`, + reusedComponent, + ]), + ), + ); + const advertisedInput = ( + canonical: z.ZodType>>, + ) => + toolInputSchemaWithMetadata({ + ...toolContract("open_binary"), + inputSchema: canonical, + })["~standard"].jsonSchema.input({ target: "draft-2020-12" }); + + it("keeps shared definitions while every client can resolve them", () => { + const advertised = advertisedInput(inputWithReusedFields(8)); + expect(advertised.$defs).toBeDefined(); + expect(countLocalReferenceExpansions(advertised)).toBe(8); + }); + + it("keeps shared definitions exactly at the budget", () => { + const advertised = advertisedInput( + inputWithReusedFields(advertisedReferenceExpansionBudget), + ); + expect(advertised.$defs).toBeDefined(); + expect(countLocalReferenceExpansions(advertised)).toBe( + advertisedReferenceExpansionBudget, + ); + }); + + it("re-projects without shared definitions once the budget is exceeded", () => { + const advertised = advertisedInput( + inputWithReusedFields(advertisedReferenceExpansionBudget + 1), + ); + expect(advertised.$defs).toBeUndefined(); + expect(countLocalReferenceExpansions(advertised)).toBe(0); + const properties = advertised.properties as Record; + expect(properties.field_00).toMatchObject({ + type: "object", + properties: { kind: { const: "rec" }, value: { type: "string" } }, + required: ["kind", "value"], + }); + }); + + it("keeps the shared form untouched when it fits and swaps it when not", () => { + const shared = { + type: "object", + properties: { value: { $ref: "#/$defs/shared" } }, + $defs: { shared: { type: "string" } }, + }; + const inlined = { + type: "object", + properties: { value: { type: "string" } }, + }; + expect( + inputSchemaWithinReferenceExpansionBudget( + shared, + () => inlined, + advertisedReferenceExpansionBudget, + ), + ).toBe(shared); + expect( + inputSchemaWithinReferenceExpansionBudget( + { + ...shared, + properties: Object.fromEntries( + Array.from({ length: 33 }, (_, index) => [ + `field_${index}`, + { $ref: "#/$defs/shared" }, + ]), + ), + }, + () => inlined, + advertisedReferenceExpansionBudget, + ), + ).toBe(inlined); + }); +}); + describe("root input presentation", () => { it("preserves prototype-named properties in object-root presentation", () => { const fields = Object.fromEntries([ diff --git a/src/contracts/toolSchemaMetadata.ts b/src/contracts/toolSchemaMetadata.ts index 5ed63db5b..3034b8354 100644 --- a/src/contracts/toolSchemaMetadata.ts +++ b/src/contracts/toolSchemaMetadata.ts @@ -2,7 +2,11 @@ import { z } from "zod"; import type { ToolContract } from "./toolContractTypes.js"; import { advertisedInputExamples } from "./advertisedInputExamples.js"; -import { presentInputJsonSchema } from "./inputSchemaPresentation.js"; +import { + advertisedReferenceExpansionBudget, + inputSchemaWithinReferenceExpansionBudget, + presentInputJsonSchema, +} from "./inputSchemaPresentation.js"; import { digestCanonicalValue } from "../domain/canonicalDigest.js"; const PROPERTY_DESCRIPTIONS: Readonly> = { @@ -66,7 +70,25 @@ export const toolInputSchemaWithMetadata = ( }); const examples = advertisedInputExamples(contract); return { - ...presentInputJsonSchema(shared, fallbackPropertyDescription), + // Sharing definitions only helps clients that resolve them. A client + // rendering declarations from a shared expansion budget degrades + // every reference past it to `unknown`, so an input whose shared form + // exceeds the budget is re-projected without shared definitions. + ...inputSchemaWithinReferenceExpansionBudget( + presentInputJsonSchema(shared, fallbackPropertyDescription), + () => + presentInputJsonSchema( + project({ + ...options, + libraryOptions: { + ...options.libraryOptions, + reused: "inline", + }, + }), + fallbackPropertyDescription, + ), + advertisedReferenceExpansionBudget, + ), ...(examples.length === 0 ? {} : { examples }), }; }, diff --git a/tests/boundary/mcp/toolSchemaValidity.test.ts b/tests/boundary/mcp/toolSchemaValidity.test.ts index 5a94d27bf..e9d5e944a 100644 --- a/tests/boundary/mcp/toolSchemaValidity.test.ts +++ b/tests/boundary/mcp/toolSchemaValidity.test.ts @@ -217,6 +217,46 @@ function resolveReference(schema: unknown, reference: string): unknown { return value; } +function localReferenceExpansions(schema: Record): number { + // Clients that render SDK declarations from the advertised input expand + // schema-local references against a shared per-input budget; Pi 1.1.0 + // code-mode allows 32 and renders every later reference as `unknown`. + // Count conservatively: every resolvable, non-cyclic reference occurrence + // reached while walking the input, never the definitions themselves. + let count = 0; + const visit = (node: unknown, active: ReadonlySet): void => { + if (Array.isArray(node)) { + node.forEach((child) => visit(child, active)); + return; + } + if (!isRecord(node)) return; + if (typeof node.$ref === "string" && !active.has(node.$ref)) { + const target = resolveReference(schema, node.$ref); + if (target !== undefined) { + count += 1; + visit(target, new Set([...active, node.$ref])); + return; + } + } + for (const [key, value] of Object.entries(node)) { + if ( + [ + "$defs", + "definitions", + "examples", + "default", + "const", + "enum", + ].includes(key) + ) + continue; + visit(value, active); + } + }; + visit(schema, new Set()); + return count; +} + function recursiveReferences(tools: readonly ToolSchemas[]): string[] { return tools.flatMap((tool) => (["inputSchema", "outputSchema"] as const).flatMap((kind) => { @@ -674,6 +714,14 @@ it("ships nonrecursive input and output schemas in the generated catalog", () => expect(recursiveReferences(GENERATED_MCP_TOOL_CATALOG)).toEqual([]); }); +it("keeps advertised input references within the client expansion budget", () => { + for (const tool of GENERATED_MCP_TOOL_CATALOG) + expect( + localReferenceExpansions(tool.inputSchema), + `${tool.name} exhausts the shared reference-expansion budget`, + ).toBeLessThanOrEqual(32); +}); + it("rejects unresolved output-schema references even for uncalled tools", () => { const errors = schemaErrors([ {