From 0d00f015ecdf415dc415e88ca186788bf913a15b Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 15 May 2026 22:38:05 +0200 Subject: [PATCH] feat(ai): added 2020-12 schema migration to ai strict validation - Added draft-07-to-2020-12 schema upgrade logic for `$schema`, `definitions`, `$ref`, tuple, and nullable mapping. - Integrated `upgradeJsonSchemaTo202012` into strict adaptation, validation, strict-mode, sanitizer, and wire/dereference flows. - Replaced set-based traversal helpers with record maps, `for...in` loops, and epoch/`once` cycle guards. - Added unreleased changelog entries and tests for schema normalization, `oneOf` to `anyOf`, and tuple validation edge cases. --- packages/ai/CHANGELOG.md | 5 + packages/ai/src/utils/schema/adapt.ts | 23 +- packages/ai/src/utils/schema/compatibility.ts | 83 ++++- packages/ai/src/utils/schema/dereference.ts | 17 +- packages/ai/src/utils/schema/draft.ts | 341 ++++++++++++++++++ packages/ai/src/utils/schema/equality.ts | 32 +- packages/ai/src/utils/schema/fields.ts | 238 ++++++------ packages/ai/src/utils/schema/index.ts | 1 + .../src/utils/schema/json-schema-validator.ts | 83 +++-- .../ai/src/utils/schema/meta-validator.ts | 56 +-- packages/ai/src/utils/schema/normalize-cca.ts | 141 +++++--- .../ai/src/utils/schema/sanitize-google.ts | 99 +++-- packages/ai/src/utils/schema/stamps.ts | 97 +++++ packages/ai/src/utils/schema/strict-mode.ts | 327 ++++++++++------- packages/ai/src/utils/schema/wire.ts | 44 ++- packages/ai/src/utils/validation.ts | 27 +- packages/ai/test/schema-normalization.test.ts | 60 +++ .../ai/test/tool-argument-coercion.test.ts | 33 +- packages/coding-agent/src/hashline/parser.ts | 3 +- 19 files changed, 1256 insertions(+), 454 deletions(-) create mode 100644 packages/ai/src/utils/schema/draft.ts create mode 100644 packages/ai/src/utils/schema/stamps.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 159850899..2fd454a41 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Breaking Changes - Rejected draft-07 tuple and dependency keywords (`items` arrays, `dependencies`, `additionalItems`) in JSON Schema validation @@ -11,10 +12,14 @@ ### Changed +- Normalized legacy draft-07 JSON Schema constructs used in tool parameters (`items` arrays, `additionalItems`, `definitions`, `dependencies`) to draft 2020-12 before OpenAI/Google/CCA sanitization, wire conversion, and argument validation +- Reworked OpenAI response schema adaptation to rewrite `oneOf` into `anyOf` while preserving existing `anyOf` branches - Changed tuple array validation to validate per-index schemas from `prefixItems` and apply `items` only to remaining elements ### Fixed +- Fixed validation of plain JSON Schema tool arguments that omitted a `$schema` URI so draft-07-shaped schemas now pass validation instead of being rejected +- Fixed tuple-array validation for legacy JSON Schema tool schemas to enforce `additionalItems: false` and per-position constraints after automatic draft upgrade - Fixed Anthropic tool schema normalization to recurse into `prefixItems` so unsupported constraints inside tuple items are stripped in the generated input schema ## [15.1.0] - 2026-05-15 diff --git a/packages/ai/src/utils/schema/adapt.ts b/packages/ai/src/utils/schema/adapt.ts index d94cad353..0e968da81 100644 --- a/packages/ai/src/utils/schema/adapt.ts +++ b/packages/ai/src/utils/schema/adapt.ts @@ -1,3 +1,4 @@ +import { upgradeJsonSchemaTo202012 } from "./draft"; import { tryEnforceStrictSchema } from "./strict-mode"; import type { JsonObject } from "./types"; /** @@ -5,7 +6,8 @@ import type { JsonObject } from "./types"; * * Each provider computes its own `strict` boolean (logic differs), then calls * this to handle the tryEnforceStrictSchema dance uniformly: - * - If `strict` is false, passes the schema through unchanged. + * - Draft-07-shaped inputs are upgraded to draft 2020-12 first. + * - If `strict` is false, passes the upgraded schema through unchanged. * - If `strict` is true, attempts to enforce strict mode; falls back to * non-strict if the schema isn't representable. */ @@ -13,11 +15,12 @@ export function adaptSchemaForStrict( schema: Record, strict: boolean, ): { schema: Record; strict: boolean } { + const upgraded = upgradeJsonSchemaTo202012(schema) as Record; if (!strict) { - return { schema, strict: false }; + return { schema: upgraded, strict: false }; } - return tryEnforceStrictSchema(schema); + return tryEnforceStrictSchema(upgraded); } /** @@ -29,6 +32,13 @@ export function sanitizeSchemaForOpenAIResponses(schema: JsonObject): JsonObject return rewriteOneOfToAnyOf(schema) as JsonObject; } +/** + * Recursively replace every `oneOf` keyword with `anyOf`. Identity-preserving: + * returns the input reference unchanged when no rewrite occurred so callers + * can dedupe via reference equality (and the strict-mode cache stays warm). + * If a node has both `oneOf` and `anyOf`, the two are concatenated (the wire + * payload accepts a single union; preserving both would not survive). + */ function rewriteOneOfToAnyOf(value: unknown): unknown { if (Array.isArray(value)) { let changed = false; @@ -47,7 +57,10 @@ function rewriteOneOfToAnyOf(value: unknown): unknown { const input = value as Record; let changed = false; const output: Record = {}; - for (const [key, child] of Object.entries(input)) { + for (const key in input) { + const child = input[key]; + // Skip `oneOf` here; it is re-emitted as `anyOf` after the loop so + // neighboring `anyOf` entries can be folded in. if (key === "oneOf") { changed = true; continue; @@ -57,6 +70,8 @@ function rewriteOneOfToAnyOf(value: unknown): unknown { output[key] = next; } + // Re-emit `oneOf` content under `anyOf`, concatenating with any existing + // `anyOf` branches in the original node. if (Array.isArray(input.oneOf)) { const rewrittenOneOf = rewriteOneOfToAnyOf(input.oneOf); const existingAnyOf = output.anyOf; diff --git a/packages/ai/src/utils/schema/compatibility.ts b/packages/ai/src/utils/schema/compatibility.ts index ab1bc943f..d52fe3f84 100644 --- a/packages/ai/src/utils/schema/compatibility.ts +++ b/packages/ai/src/utils/schema/compatibility.ts @@ -7,6 +7,17 @@ import { import { isValidJsonSchema } from "./meta-validator"; import { isJsonObject, type JsonObject } from "./types"; +/** + * Schema compatibility audits. + * + * Each provider has a different idea of what JSON Schema features it accepts + * for tool definitions. The sanitizers in `normalize-cca`, `sanitize-google`, + * and `strict-mode` rewrite incoming schemas to fit. This module is the + * *audit* counterpart: it walks a (presumably already-sanitized) schema and + * reports any feature the target provider would reject. Tests use it to lock + * down the contract; the runtime uses it to fail-open with diagnostic logs + * rather than silently shipping a broken tool definition. + */ export type SchemaCompatibilityProvider = "openai-strict" | "google" | "cloud-code-assist-claude"; export interface SchemaCompatibilityViolation { @@ -28,12 +39,24 @@ export interface StrictSchemaEnforcementResult { strict: boolean; } -const STRICT_FORBIDDEN_KEYS = new Set([...NON_STRUCTURAL_SCHEMA_KEYS, "const", "nullable"]); -const GOOGLE_FORBIDDEN_KEYS = new Set([...UNSUPPORTED_SCHEMA_FIELDS, "const"]); -const CCA_FORBIDDEN_KEYS = new Set([...CCA_UNSUPPORTED_SCHEMA_FIELDS, "const"]); +// Per-provider forbidden-key sets. Subsets of the shared `fields.ts` constants +// plus a few provider-specific extras (`const`, `nullable`) folded in here so +// each rule is defined in exactly one place. +const STRICT_FORBIDDEN_KEYS: Record = { ...NON_STRUCTURAL_SCHEMA_KEYS, const: true, nullable: true }; +const GOOGLE_FORBIDDEN_KEYS: Record = { ...UNSUPPORTED_SCHEMA_FIELDS, const: true }; +const CCA_FORBIDDEN_KEYS: Record = { ...CCA_UNSUPPORTED_SCHEMA_FIELDS, const: true }; -const NON_SCHEMA_CONTAINER_ARRAY_KEYS = new Set(["enum", "required", "examples", "type"]); -const NON_SCHEMA_CONTAINER_OBJECT_KEYS = new Set(["const", "default", "example"]); +// Keys whose values are JSON-Schema *containers* (arrays of values, scalars, +// etc.) rather than nested schemas. The traversal must skip these — recursing +// would walk into `enum` strings or `default` objects and emit spurious +// violations against keys that happen to share JSON-Schema keyword names. +const NON_SCHEMA_CONTAINER_ARRAY_KEYS: Record = { + enum: true, + required: true, + examples: true, + type: true, +}; +const NON_SCHEMA_CONTAINER_OBJECT_KEYS: Record = { const: true, default: true, example: true }; interface TraversalState { path: string; @@ -55,6 +78,14 @@ function createViolation( }; } +/** + * Recursively visit every schema node in a JSON Schema tree. + * + * The walker is *structural*, not type-aware: it knows which keywords contain + * nested schemas vs. plain values, so it descends into `properties.*`, + * `$defs.*`, `items`, combinator arrays, etc. but never into `enum`, `const`, + * `default`, or `type` arrays. + */ function walkSchema( value: unknown, state: TraversalState, @@ -73,19 +104,25 @@ function walkSchema( visitNode(value, state); - for (const [key, entry] of Object.entries(value)) { + for (const key in value) { + // Schema-map keywords: value is `{ name: schema, … }`. Recurse into each + // entry's schema rather than the map object itself. + const entry = value[key]; if (key === "properties" || key === "$defs" || key === "definitions" || key === "dependentSchemas") { if (isJsonObject(entry)) { - for (const [name, child] of Object.entries(entry)) { + for (const name in entry) { + const child = entry[name]; walkSchema(child, { path: `${state.path}.${key}.${name}` }, visitNode); } } continue; } + // Non-schema container keywords — values are not schemas, do not descend. - if (NON_SCHEMA_CONTAINER_ARRAY_KEYS.has(key) || NON_SCHEMA_CONTAINER_OBJECT_KEYS.has(key)) { + if (key in NON_SCHEMA_CONTAINER_ARRAY_KEYS || key in NON_SCHEMA_CONTAINER_OBJECT_KEYS) { continue; } + // Array-of-schemas keywords (e.g. `allOf`, `anyOf`, `oneOf`, `prefixItems`). if (Array.isArray(entry)) { for (let index = 0; index < entry.length; index++) { @@ -100,11 +137,23 @@ function walkSchema( } } +/** + * Strict-mode audit (OpenAI Responses / Codex `strict: true`): + * 1. Forbid keywords that strict mode disallows (`format`, `pattern`, `const`, + * `nullable`, etc. — see `STRICT_FORBIDDEN_KEYS`). + * 2. Every node must declare *something* concrete: a `type`, a combinator, + * a `$ref`, or a `not` branch. Empty `{}` is rejected. + * 3. Object nodes must set `additionalProperties: false`, declare a real + * `properties` map, and require every property in that map. Required + * properties not in `properties` are also rejected — strict mode demands + * a closed object shape. + */ function validateStrictNode(node: JsonObject, state: TraversalState): SchemaCompatibilityViolation[] { const violations: SchemaCompatibilityViolation[] = []; - for (const [key, value] of Object.entries(node)) { - if (!STRICT_FORBIDDEN_KEYS.has(key)) { + for (const key in node) { + const value = node[key]; + if (!(key in STRICT_FORBIDDEN_KEYS)) { continue; } @@ -118,6 +167,7 @@ function validateStrictNode(node: JsonObject, state: TraversalState): SchemaComp ), ); } + // Rule 2: node must declare at least one concrete shape descriptor. const hasCombinator = COMBINATOR_KEYS.some(key => Array.isArray(node[key])); const hasRef = typeof node.$ref === "string"; @@ -131,6 +181,7 @@ function validateStrictNode(node: JsonObject, state: TraversalState): SchemaComp ), ); } + // Rules 3a-3d apply only to object-shaped nodes. const isObjectNode = node.type === "object" || isJsonObject(node.properties); if (!isObjectNode) { @@ -148,6 +199,7 @@ function validateStrictNode(node: JsonObject, state: TraversalState): SchemaComp ), ); } + // 3b: `properties` must exist and be an object — without it strict mode has nothing to validate. if (!isJsonObject(node.properties)) { violations.push( @@ -182,6 +234,7 @@ function validateStrictNode(node: JsonObject, state: TraversalState): SchemaComp ), ); } + // 3d: any property declared in `required` but missing from `properties` is unrepresentable. const propertyNameSet = new Set(propertyNames); for (const requiredKey of requiredValues) { @@ -205,8 +258,9 @@ function validateStrictNode(node: JsonObject, state: TraversalState): SchemaComp function validateGoogleNode(node: JsonObject, state: TraversalState): SchemaCompatibilityViolation[] { const violations: SchemaCompatibilityViolation[] = []; - for (const [key, value] of Object.entries(node)) { - if (!GOOGLE_FORBIDDEN_KEYS.has(key)) { + for (const key in node) { + const value = node[key]; + if (!(key in GOOGLE_FORBIDDEN_KEYS)) { continue; } violations.push( @@ -238,8 +292,9 @@ function validateGoogleNode(node: JsonObject, state: TraversalState): SchemaComp function validateCloudCodeAssistNode(node: JsonObject, state: TraversalState): SchemaCompatibilityViolation[] { const violations: SchemaCompatibilityViolation[] = []; - for (const [key, value] of Object.entries(node)) { - if (CCA_FORBIDDEN_KEYS.has(key)) { + for (const key in node) { + const value = node[key]; + if (key in CCA_FORBIDDEN_KEYS) { violations.push( createViolation( `${state.path}.${key}`, diff --git a/packages/ai/src/utils/schema/dereference.ts b/packages/ai/src/utils/schema/dereference.ts index 0f2716b4e..92429e5b2 100644 --- a/packages/ai/src/utils/schema/dereference.ts +++ b/packages/ai/src/utils/schema/dereference.ts @@ -47,17 +47,22 @@ function dereferenceNode(node: unknown, root: JsonObject, visiting: Set) // Merge sibling keywords (e.g. description, default) from the // referencing node. In draft 2020-12 these are valid alongside $ref. - const hasSiblings = Object.keys(node).some(k => k !== "$ref"); - if (!hasSiblings || !isJsonObject(inlined)) return inlined; - const merged: JsonObject = { ...inlined }; - for (const [key, value] of Object.entries(node)) { - if (key !== "$ref") merged[key] = value; + let hasSiblings = false; + for (const k in node) { + if (k !== "$ref") { + hasSiblings = true; + break; + } } + if (!hasSiblings || !isJsonObject(inlined)) return inlined; + const merged: JsonObject = { ...inlined, ...node }; + delete merged.$ref; return merged; } const result: JsonObject = {}; - for (const [key, value] of Object.entries(node)) { + for (const key in node) { + const value = node[key]; // Skip $defs/definitions — they get inlined into consumers if (key === "$defs" || key === "definitions") continue; diff --git a/packages/ai/src/utils/schema/draft.ts b/packages/ai/src/utils/schema/draft.ts new file mode 100644 index 000000000..7dd240482 --- /dev/null +++ b/packages/ai/src/utils/schema/draft.ts @@ -0,0 +1,341 @@ +import { areJsonValuesEqual } from "./equality"; +import { epochNext, once } from "./stamps"; +import { isJsonObject, type JsonObject } from "./types"; + +export const JSON_SCHEMA_DRAFT_2020_12_URI = "https://json-schema.org/draft/2020-12/schema"; + +/** Draft-07 schema URIs we recognise as needing an upgrade. The trailing `#` is the canonical form in the JSON Schema spec, but providers (and Zod) emit both. */ +const DRAFT_07_SCHEMA_URIS: Record = { + "http://json-schema.org/draft-07/schema#": true, + "https://json-schema.org/draft-07/schema#": true, + "http://json-schema.org/draft-07/schema": true, + "https://json-schema.org/draft-07/schema": true, +}; + +/** + * Keys whose values are property-name → schema maps. We recurse into each map + * entry rather than the map object itself so legacy `definitions`-style refs + * inside property schemas get rewritten. + */ +const SCHEMA_MAP_KEYS: Record = { properties: true, patternProperties: true, dependentSchemas: true }; +/** + * Keys whose values are JSON-Schema *values*, not nested schemas. The upgrade + * walker must NOT descend into these — `type: ["string","null"]` is not a + * schema, and recursing would corrupt `enum`/`const`/`default` payloads. + */ +const NON_SCHEMA_VALUE_KEYS: Record = { + const: true, + default: true, + enum: true, + example: true, + examples: true, + required: true, + dependentRequired: true, + type: true, +}; + +/** Rewrite draft-07's `#/definitions/Foo` ref form to draft 2020-12's `#/$defs/Foo`. External refs (`http://…`) pass through. */ +function convertRef(value: string): string { + return value.startsWith("#/definitions/") ? `#/$defs/${value.slice("#/definitions/".length)}` : value; +} + +/** Get-or-create a child object map on `target[key]`. Used to lazily build up `$defs`/`dependentRequired`/`dependentSchemas` during conversion. */ +function getObjectMap(target: JsonObject, key: string): JsonObject { + const existing = target[key]; + if (isJsonObject(existing)) return existing; + const next: JsonObject = {}; + target[key] = next; + return next; +} + +/** Recursively upgrade every entry of a schema-map (e.g. `properties`) and merge into `target[key]`. */ +function mergeSchemaMap(target: JsonObject, key: string, value: JsonObject, cache: WeakMap): void { + const map = getObjectMap(target, key); + for (const name in value) { + map[name] = upgradeJsonSchemaTo202012Impl(value[name], cache); + } +} +/** Copy a schema-map field with upgrade; non-object values are passed through verbatim. */ +function copySchemaMap(target: JsonObject, key: string, value: unknown, cache: WeakMap): void { + if (!isJsonObject(value)) { + target[key] = value; + return; + } + mergeSchemaMap(target, key, value, cache); +} + +/** + * Intersect two schemas. Used when draft-07 `dependencies` map keys collide + * with each other or with existing `dependentSchemas` entries. + * - `true`/undefined is the identity (matches anything). + * - `false` is the absorbing element (matches nothing). + * - Equal schemas collapse. Otherwise wrap in `allOf` so both still apply. + */ +function combineSchemas(left: unknown, right: unknown): unknown { + if (left === undefined || left === true) return right; + if (right === undefined || right === true) return left; + if (left === false || right === false) return false; + if (areJsonValuesEqual(left, right)) return left; + return { allOf: [left, right] }; +} + +/** Union two arrays of JSON values, deduping by deep equality. Used to merge `dependentRequired` arrays. */ +function mergeArrayValues(left: unknown[], right: unknown[]): unknown[] { + const merged = [...left]; + for (const value of right) { + if (!merged.some(existing => areJsonValuesEqual(existing, value))) { + merged.push(value); + } + } + return merged; +} + +/** + * Merge converted tuple items into an existing `prefixItems` array. When the + * same index already has a schema (e.g. from a prior recursive pass via the + * cache), intersect the two so both constraints survive. + */ +function mergePrefixItems(existing: unknown, convertedItems: unknown[]): unknown[] { + if (!Array.isArray(existing)) return convertedItems; + const merged = [...existing]; + for (let index = 0; index < convertedItems.length; index += 1) { + merged[index] = index in merged ? combineSchemas(merged[index], convertedItems[index]) : convertedItems[index]; + } + return merged; +} + +/** Record `key → deps` in `dependentRequired`, unioning with any existing array. */ +function mergeDependentRequired(target: JsonObject, key: string, deps: unknown[]): void { + const dependentRequired = getObjectMap(target, "dependentRequired"); + const existing = dependentRequired[key]; + if (existing === undefined) { + dependentRequired[key] = deps; + return; + } + if (Array.isArray(existing)) { + dependentRequired[key] = mergeArrayValues(existing, deps); + } +} + +/** Record `key → schema` in `dependentSchemas`, intersecting with any existing entry. */ +function mergeDependentSchema(target: JsonObject, key: string, schema: unknown): void { + const dependentSchemas = getObjectMap(target, "dependentSchemas"); + dependentSchemas[key] = combineSchemas(dependentSchemas[key], schema); +} + +/** + * Convert draft-07's `dependencies` keyword (which mixes array deps and schema + * deps under one key) into the draft 2020-12 split: + * - array value → `dependentRequired` + * - schema value → `dependentSchemas` + */ +function convertDependencies(source: JsonObject, target: JsonObject, cache: WeakMap): void { + const dependencies = source.dependencies; + if (!isJsonObject(dependencies)) return; + for (const key in dependencies) { + const dependency = dependencies[key]; + const converted = upgradeJsonSchemaTo202012Impl(dependency, cache); + if (Array.isArray(converted)) { + mergeDependentRequired(target, key, converted); + } else { + mergeDependentSchema(target, key, converted); + } + } +} + +/** True if `type` is `"null"` or an array that includes `"null"`. */ +function hasNullType(type: unknown): boolean { + return type === "null" || (Array.isArray(type) && type.includes("null")); +} + +/** True if any variant in `anyOf` declares (only) a null type. Used to avoid double-adding `{type:"null"}`. */ +function hasNullVariant(variants: unknown[]): boolean { + return variants.some(variant => isJsonObject(variant) && hasNullType(variant.type)); +} + +/** + * Mutate `schema` in place to accept `null`. Strategy depends on existing shape: + * - scalar type → expand to `[type, "null"]`. + * - type array → append `"null"` if missing. + * - existing `anyOf` → append `{type:"null"}` branch if missing. + * - otherwise → wrap the whole schema in `anyOf:[schema, {type:"null"}]`. + * Returns the resulting object (which may be a new wrapper). + */ +function makeNullable(schema: JsonObject): JsonObject { + const type = schema.type; + if (typeof type === "string") { + if (type !== "null") schema.type = [type, "null"]; + return schema; + } + if (Array.isArray(type)) { + if (!type.includes("null")) schema.type = [...type, "null"]; + return schema; + } + if (Array.isArray(schema.anyOf)) { + if (!hasNullVariant(schema.anyOf)) schema.anyOf = [...schema.anyOf, { type: "null" }]; + return schema; + } + return { anyOf: [schema, { type: "null" }] }; +} + +/** True if any entry in a schema-map needs upgrading. Shortcut used during pre-check to skip the full clone when nothing has changed. */ +function schemaMapNeedsDraft202012Upgrade(value: unknown, epoch: number): boolean { + if (!isJsonObject(value)) return false; + for (const k in value) { + if (schemaNeedsDraft202012UpgradeImpl(value[k], epoch)) return true; + } + return false; +} + +/** + * Cheap pre-check: walk the schema looking for any keyword/value that the + * upgrade pass would have to rewrite. Lets the public entrypoint short-circuit + * and return the input identity-unchanged when there is nothing to do. + * + * Uses `once(value, epoch)` to break cycles without allocating a per-call set. + */ +function schemaNeedsDraft202012UpgradeImpl(value: unknown, epoch: number): boolean { + if (Array.isArray(value)) { + if (!once(value, epoch)) return false; + return value.some(entry => schemaNeedsDraft202012UpgradeImpl(entry, epoch)); + } + if (!isJsonObject(value)) return false; + if (!once(value, epoch)) return false; + + for (const key in value) { + const entry = value[key]; + if (key === "$schema") { + if (typeof entry === "string" && entry in DRAFT_07_SCHEMA_URIS) return true; + continue; + } + if (key === "definitions" || key === "dependencies" || key === "additionalItems" || key === "nullable") { + return true; + } + if (key === "$ref") { + if (typeof entry === "string" && entry.startsWith("#/definitions/")) return true; + continue; + } + if (key === "items" && Array.isArray(entry)) return true; + if (key === "$defs" || key in SCHEMA_MAP_KEYS) { + if (schemaMapNeedsDraft202012Upgrade(entry, epoch)) return true; + continue; + } + if (key in NON_SCHEMA_VALUE_KEYS) continue; + if (schemaNeedsDraft202012UpgradeImpl(entry, epoch)) return true; + } + + return false; +} + +/** + * Recursive upgrade core. The `cache` WeakMap keys input objects to their + * converted output so shared subgraphs are converted once and cycles terminate + * — we insert the empty result into the cache *before* recursing so back-edges + * resolve to a (later-populated) reference rather than infinite-looping. + */ +function upgradeJsonSchemaTo202012Impl(value: unknown, cache: WeakMap): unknown { + if (Array.isArray(value)) { + const cached = cache.get(value); + if (cached !== undefined) return cached; + const result: unknown[] = []; + cache.set(value, result); + for (const entry of value) { + result.push(upgradeJsonSchemaTo202012Impl(entry, cache)); + } + return result; + } + if (!isJsonObject(value)) return value; + + const cached = cache.get(value); + if (cached !== undefined) return cached; + + const result: JsonObject = {}; + // Seed cache before recursion so back-edges in cyclic graphs resolve. + cache.set(value, result); + for (const key in value) { + const entry = value[key]; + // `definitions` is the draft-07 name; merge under the canonical `$defs`. + // `$defs` may appear pre-upgraded — still walk entries to upgrade their bodies. + if (key === "definitions" || key === "$defs") { + if (isJsonObject(entry)) mergeSchemaMap(result, "$defs", entry, cache); + continue; + } + // Recurse into each entry; the map shape itself is preserved. + if (key in SCHEMA_MAP_KEYS) { + copySchemaMap(result, key, entry, cache); + continue; + } + // JSON-Schema *value* keywords — copy verbatim. + if (key in NON_SCHEMA_VALUE_KEYS) { + result[key] = entry; + continue; + } + // Draft-07-only keywords with no draft 2020-12 spelling — drop entirely. + // `items` arrays are handled below via `prefixItems` conversion. + if (key === "dependencies" || key === "additionalItems" || key === "nullable") { + continue; + } + // Rewrite `$schema` URI to the 2020-12 form; non-draft-07 URIs pass through. + if (key === "$schema") { + result.$schema = + typeof entry === "string" && entry in DRAFT_07_SCHEMA_URIS ? JSON_SCHEMA_DRAFT_2020_12_URI : entry; + continue; + } + // `#/definitions/Foo` → `#/$defs/Foo`. + if (key === "$ref" && typeof entry === "string") { + result.$ref = convertRef(entry); + continue; + } + // Array-valued `items` is the draft-07 tuple form — handled after the loop. + if (key === "items" && Array.isArray(entry)) { + continue; + } + result[key] = upgradeJsonSchemaTo202012Impl(entry, cache); + } + + // Draft-07 tuple form: `items: [a, b]` (+ optional `additionalItems`) becomes + // draft 2020-12 `prefixItems: [a, b]` (+ optional `items` for the rest). + if (Array.isArray(value.items)) { + const convertedItems = upgradeJsonSchemaTo202012Impl(value.items, cache) as unknown[]; + result.prefixItems = mergePrefixItems(result.prefixItems, convertedItems); + if (value.additionalItems !== undefined && value.additionalItems !== true) { + result.items = upgradeJsonSchemaTo202012Impl(value.additionalItems, cache); + } else { + // `additionalItems: true` (or absent) in draft-07 == no `items` in 2020-12. + delete result.items; + } + } + + convertDependencies(value, result, cache); + + // OpenAPI 3.0 `nullable: true` → 2020-12 nullability. `makeNullable` may + // return a fresh wrapper object, in which case update the cache so callers + // referring to the same input see the wrapper instead of the inner result. + if (value.nullable === true) { + const nullable = makeNullable(result); + if (nullable !== result) cache.set(value, nullable); + return nullable; + } + + return result; +} + +/** Pre-check entrypoint. Exposed so callers can decide whether to take the upgrade path at all. */ +export function schemaNeedsDraft202012Upgrade(schema: unknown): boolean { + return schemaNeedsDraft202012UpgradeImpl(schema, epochNext()); +} + +/** + * Upgrade legacy JSON Schema shapes to the draft 2020-12 form emitted by Zod. + * + * This keeps extension/MCP/TypeBox schemas compatible with providers whose tool + * validators reject draft-07 tuple and dependency keywords. + */ +// `WeakMap` is intentional: this cache is per-call and seeded *before* recursion +// (see `cache.set(value, result)` in `upgradeJsonSchemaTo202012Impl`) so cyclic +// graphs resolve to a knot-tied reference. A symbol stamp would either cache +// across calls (unsafe under input mutation) or require an epoch indirection. +export function upgradeJsonSchemaTo202012(schema: unknown): unknown { + if (!schemaNeedsDraft202012Upgrade(schema)) return schema; + return upgradeJsonSchemaTo202012Impl(schema, new WeakMap()); +} diff --git a/packages/ai/src/utils/schema/equality.ts b/packages/ai/src/utils/schema/equality.ts index e613be793..98f004ca9 100644 --- a/packages/ai/src/utils/schema/equality.ts +++ b/packages/ai/src/utils/schema/equality.ts @@ -19,17 +19,14 @@ export function areJsonValuesEqual(left: unknown, right: unknown): boolean { if (!isJsonObject(left) || !isJsonObject(right)) { return false; } - const leftKeys = Object.keys(left); - const rightKeys = Object.keys(right); - if (leftKeys.length !== rightKeys.length) { - return false; + let rightLen = 0; + for (const _ in right) rightLen++; + let leftLen = 0; + for (const key in left) { + leftLen++; + if (!(key in right) || !areJsonValuesEqual(left[key], right[key])) return false; } - for (const key of leftKeys) { - if (!(key in right) || !areJsonValuesEqual(left[key], right[key])) { - return false; - } - } - return true; + return leftLen === rightLen; } export function mergeCompatibleEnumSchemas(existing: unknown, incoming: unknown): JsonObject | null { @@ -44,12 +41,19 @@ export function mergeCompatibleEnumSchemas(existing: unknown, incoming: unknown) if (!areJsonValuesEqual(existing.type, incoming.type)) { return null; } - const existingKeys = Object.keys(existing).filter(key => key !== "enum"); - const incomingKeys = Object.keys(incoming).filter(key => key !== "enum"); - if (existingKeys.length !== incomingKeys.length) { + let existingNonEnumCount = 0; + for (const key in existing) { + if (key !== "enum") existingNonEnumCount++; + } + let incomingNonEnumCount = 0; + for (const key in incoming) { + if (key !== "enum") incomingNonEnumCount++; + } + if (existingNonEnumCount !== incomingNonEnumCount) { return null; } - for (const key of existingKeys) { + for (const key in existing) { + if (key === "enum") continue; if (!(key in incoming) || !areJsonValuesEqual(existing[key], incoming[key])) { return null; } diff --git a/packages/ai/src/utils/schema/fields.ts b/packages/ai/src/utils/schema/fields.ts index efbc6440d..d5f0dc46d 100644 --- a/packages/ai/src/utils/schema/fields.ts +++ b/packages/ai/src/utils/schema/fields.ts @@ -3,134 +3,146 @@ * * Each set serves a different provider need. They overlap intentionally — * co-locating them makes the overlap visible and maintainable. + * + * All keysets here are static and small (≤ ~40 entries) so they live as + * `Record` literals — `k in REC` resolves through hidden + * class inline caches without the per-call hashtable cost of `Set.has`. */ /** * Google Generative AI unsupported schema fields. * Stripped during sanitizeSchemaForGoogle / sanitizeSchemaForCCA. */ -export const UNSUPPORTED_SCHEMA_FIELDS = new Set([ - "$schema", - "$ref", - "$defs", - "$dynamicRef", - "$dynamicAnchor", - "examples", - "prefixItems", - "unevaluatedProperties", - "unevaluatedItems", - "patternProperties", - "additionalProperties", - "propertyNames", - "minItems", - "maxItems", - "minLength", - "maxLength", - "minimum", - "maximum", - "exclusiveMinimum", - "exclusiveMaximum", - "pattern", - "format", -]); +export const UNSUPPORTED_SCHEMA_FIELDS: Record = { + $schema: true, + $ref: true, + $defs: true, + $dynamicRef: true, + $dynamicAnchor: true, + examples: true, + prefixItems: true, + unevaluatedProperties: true, + unevaluatedItems: true, + patternProperties: true, + additionalProperties: true, + propertyNames: true, + minItems: true, + maxItems: true, + minLength: true, + maxLength: true, + minimum: true, + maximum: true, + exclusiveMinimum: true, + exclusiveMaximum: true, + pattern: true, + format: true, +}; /** * Non-structural schema keys stripped during OpenAI strict mode sanitization. * These are decorative/validation-only keywords that don't affect the structural * shape OpenAI's strict mode enforces. */ -export const NON_STRUCTURAL_SCHEMA_KEYS = new Set([ - "format", - "pattern", - "minLength", - "maxLength", - "minimum", - "maximum", - "exclusiveMinimum", - "exclusiveMaximum", - "minItems", - "maxItems", - "uniqueItems", - "multipleOf", - "$schema", - "examples", - "default", - "title", - "$comment", - "if", - "then", - "else", - "not", - "unevaluatedProperties", - "unevaluatedItems", - "patternProperties", - "propertyNames", - "contains", - "minContains", - "maxContains", - "dependentRequired", - "dependentSchemas", - "contentEncoding", - "contentMediaType", - "contentSchema", - "deprecated", - "readOnly", - "writeOnly", - "minProperties", - "maxProperties", - "$dynamicRef", - "$dynamicAnchor", -]); +export const NON_STRUCTURAL_SCHEMA_KEYS: Record = { + format: true, + pattern: true, + minLength: true, + maxLength: true, + minimum: true, + maximum: true, + exclusiveMinimum: true, + exclusiveMaximum: true, + minItems: true, + maxItems: true, + uniqueItems: true, + multipleOf: true, + $schema: true, + examples: true, + default: true, + title: true, + $comment: true, + if: true, + // biome-ignore lint/suspicious/noThenProperty: JSON Schema keyword + then: true, + else: true, + not: true, + unevaluatedProperties: true, + unevaluatedItems: true, + patternProperties: true, + propertyNames: true, + contains: true, + minContains: true, + maxContains: true, + dependentRequired: true, + dependentSchemas: true, + contentEncoding: true, + contentMediaType: true, + contentSchema: true, + deprecated: true, + readOnly: true, + writeOnly: true, + minProperties: true, + maxProperties: true, + $dynamicRef: true, + $dynamicAnchor: true, +}; /** * Cloud Code Assist type-specific allowed keys per JSON Schema type. * Used when collapsing mixed-type combiner variants for CCA Claude. */ -export const CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS: Record> = { - array: new Set([ - "items", - "prefixItems", - "contains", - "minContains", - "maxContains", - "minItems", - "maxItems", - "uniqueItems", - "unevaluatedItems", - ]), - object: new Set([ - "properties", - "required", - "additionalProperties", - "patternProperties", - "propertyNames", - "minProperties", - "maxProperties", - "dependentRequired", - "dependentSchemas", - "unevaluatedProperties", - ]), - string: new Set(["minLength", "maxLength", "pattern", "format", "contentEncoding", "contentMediaType"]), - number: new Set(["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"]), - integer: new Set(["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"]), - boolean: new Set(), - null: new Set(), +export const CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS: Record> = { + array: { + items: true, + prefixItems: true, + contains: true, + minContains: true, + maxContains: true, + minItems: true, + maxItems: true, + uniqueItems: true, + unevaluatedItems: true, + }, + object: { + properties: true, + required: true, + additionalProperties: true, + patternProperties: true, + propertyNames: true, + minProperties: true, + maxProperties: true, + dependentRequired: true, + dependentSchemas: true, + unevaluatedProperties: true, + }, + string: { + minLength: true, + maxLength: true, + pattern: true, + format: true, + contentEncoding: true, + contentMediaType: true, + }, + number: { minimum: true, maximum: true, exclusiveMinimum: true, exclusiveMaximum: true, multipleOf: true }, + integer: { minimum: true, maximum: true, exclusiveMinimum: true, exclusiveMaximum: true, multipleOf: true }, + boolean: {}, + null: {}, }; /** * Cloud Code Assist shared schema keys allowed on any type. * Used alongside CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS for CCA combiner collapsing. */ -export const CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS = new Set([ - "title", - "description", - "default", - "examples", - "deprecated", - "readOnly", - "writeOnly", - "$comment", -]); +export const CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS: Record = { + title: true, + description: true, + default: true, + examples: true, + deprecated: true, + readOnly: true, + writeOnly: true, + $comment: true, +}; /** * Combinator keys used across schema sanitization modules. @@ -144,11 +156,11 @@ export const COMBINATOR_KEYS = ["anyOf", "allOf", "oneOf"] as const; * validation keywords like additionalProperties, minLength, pattern, etc. * Meta/reference keywords plus object-key validators that CCA cannot resolve are stripped. */ -export const CCA_UNSUPPORTED_SCHEMA_FIELDS = new Set([ - "$schema", - "$ref", - "$defs", - "$dynamicRef", - "$dynamicAnchor", - "propertyNames", -]); +export const CCA_UNSUPPORTED_SCHEMA_FIELDS: Record = { + $schema: true, + $ref: true, + $defs: true, + $dynamicRef: true, + $dynamicAnchor: true, + propertyNames: true, +}; diff --git a/packages/ai/src/utils/schema/index.ts b/packages/ai/src/utils/schema/index.ts index 43b91243c..d7c93e675 100644 --- a/packages/ai/src/utils/schema/index.ts +++ b/packages/ai/src/utils/schema/index.ts @@ -1,6 +1,7 @@ export * from "./adapt"; export * from "./compatibility"; export * from "./dereference"; +export * from "./draft"; export * from "./equality"; export * from "./fields"; export * from "./json-schema-validator"; diff --git a/packages/ai/src/utils/schema/json-schema-validator.ts b/packages/ai/src/utils/schema/json-schema-validator.ts index 4f0ed0868..93a69cf15 100644 --- a/packages/ai/src/utils/schema/json-schema-validator.ts +++ b/packages/ai/src/utils/schema/json-schema-validator.ts @@ -1,3 +1,17 @@ +/** + * In-tree JSON Schema validator. + * + * Used by `validation.ts` for tools authored as plain JSON Schema (no Zod + * runtime). Covers the keyword set tool authors actually rely on — type, + * enum, const, combinators, if/then/else, object/array/string/number + * constraints, $ref, prefixItems/items, contains, propertyNames, pattern & + * dependent* — but treats `unevaluatedProperties` / `unevaluatedItems` as + * permissive (with a one-shot warning) since those require evaluation + * tracking we do not implement. + * + * Compared to AJV this is single-pass, synchronous, dependency-free, and + * tolerates non-standard shapes (`nullable`) that LLM-emitted schemas carry. + */ import { logger } from "@oh-my-pi/pi-utils"; import { areJsonValuesEqual } from "./equality"; @@ -63,6 +77,8 @@ function typeOfJsonValue(value: unknown): string { return typeof value; } +/** Push a validation issue with a copied path so later mutations to `path` do not corrupt earlier issues. */ + function matchesJsonSchemaType(value: unknown, type: string): boolean { switch (type) { case "string": @@ -84,6 +100,8 @@ function matchesJsonSchemaType(value: unknown, type: string): boolean { } } +/** Decide whether `value` satisfies a single JSON-Schema `type` keyword string. `integer` is a refinement of `number`. */ + function schemaTypes(schema: Record): string[] { const raw = schema.type; const types = @@ -98,10 +116,14 @@ function schemaTypes(schema: Record): string[] { return types; } +/** Extract the effective `type` list from a schema, treating `nullable: true` as adding `"null"`. */ + function decodePointerToken(token: string): string { return token.replace(/~1/g, "/").replace(/~0/g, "~"); } +/** RFC 6901 token decode: `~1` → `/`, `~0` → `~`. */ + function resolveLocalRef(root: unknown, ref: string): unknown | undefined { if (ref === "#") return root; if (!ref.startsWith("#/")) return undefined; @@ -114,10 +136,20 @@ function resolveLocalRef(root: unknown, ref: string): unknown | undefined { return current; } +/** Resolve a `#/path/to/node` pointer against the root schema. Returns `undefined` for external/unsupported refs. */ + function isRequiredSet(value: unknown): value is string[] { return Array.isArray(value) && value.every(entry => typeof entry === "string"); } +/** Narrow `required: unknown` to `required: string[]` — the spec allows it to be missing but rejects non-string entries. */ + +/** + * Core validator. Walks a schema node, applies every applicable keyword to + * `value`, and accumulates issues. Returns `true` only if no keyword + * rejected; combinators may add issues but still return true (e.g. `anyOf` + * succeeds if at least one branch matches). + */ function validateSchemaNode( schema: unknown, value: unknown, @@ -276,6 +308,7 @@ function validateSchemaNode( return valid; } +/** Apply object-shaped JSON-Schema keywords: `required`, `properties`, `propertyNames`, `patternProperties`, `dependentRequired`, `dependentSchemas`, `additionalProperties`, and the `min/maxProperties` counts. */ function validateObjectKeywords( schema: Record, value: Record, @@ -294,9 +327,9 @@ function validateObjectKeywords( } } - for (const [key, propertySchema] of Object.entries(properties)) { + for (const key in properties) { if (!(key in value)) continue; - valid = validateSchemaNode(propertySchema, value[key], [...path, key], ctx, issues) && valid; + valid = validateSchemaNode(properties[key], value[key], [...path, key], ctx, issues) && valid; } if (schema.propertyNames !== undefined) { @@ -307,7 +340,9 @@ function validateObjectKeywords( const known = new Set(Object.keys(properties)); if (isJsonObject(schema.patternProperties)) { - for (const [pattern, patternSchema] of Object.entries(schema.patternProperties)) { + const patternProperties = schema.patternProperties; + for (const pattern in patternProperties) { + const patternSchema = patternProperties[pattern]; let re: RegExp; try { re = new RegExp(pattern); @@ -316,16 +351,18 @@ function validateObjectKeywords( valid = false; continue; } - for (const [key, entry] of Object.entries(value)) { + for (const key in value) { if (!re.test(key)) continue; known.add(key); - valid = validateSchemaNode(patternSchema, entry, [...path, key], ctx, issues) && valid; + valid = validateSchemaNode(patternSchema, value[key], [...path, key], ctx, issues) && valid; } } } if (isJsonObject(schema.dependentRequired)) { - for (const [key, deps] of Object.entries(schema.dependentRequired)) { + const dependentRequired = schema.dependentRequired; + for (const key in dependentRequired) { + const deps = dependentRequired[key]; if (!(key in value)) continue; if (!Array.isArray(deps)) continue; for (const dep of deps) { @@ -341,31 +378,10 @@ function validateObjectKeywords( } if (isJsonObject(schema.dependentSchemas)) { - for (const [key, depSchema] of Object.entries(schema.dependentSchemas)) { + const dependentSchemas = schema.dependentSchemas; + for (const key in dependentSchemas) { if (!(key in value)) continue; - valid = validateSchemaNode(depSchema, value, path, ctx, issues) && valid; - } - } - - // Compatibility for older schemas that still use `dependencies`: each entry - // is either a schema (validate value when key present) or a string[] of - // additional required keys. - if (isJsonObject(schema.dependencies)) { - for (const [key, dep] of Object.entries(schema.dependencies)) { - if (!(key in value)) continue; - if (Array.isArray(dep)) { - for (const required of dep) { - if (typeof required !== "string") continue; - if (!(required in value)) { - pushIssue(issues, [...path, required], `is required when "${key}" is present`, { - keyword: "dependencies", - }); - valid = false; - } - } - } else if (dep !== undefined) { - valid = validateSchemaNode(dep, value, path, ctx, issues) && valid; - } + valid = validateSchemaNode(dependentSchemas[key], value, path, ctx, issues) && valid; } } @@ -379,9 +395,9 @@ function validateObjectKeywords( valid = false; } } else if (additional !== undefined && additional !== true) { - for (const [key, entry] of Object.entries(value)) { + for (const key in value) { if (known.has(key)) continue; - valid = validateSchemaNode(additional, entry, [...path, key], ctx, issues) && valid; + valid = validateSchemaNode(additional, value[key], [...path, key], ctx, issues) && valid; } } @@ -397,6 +413,7 @@ function validateObjectKeywords( return valid; } +/** Apply array-shaped keywords: `min/maxItems`, `uniqueItems`, `prefixItems` + `items` tuple validation, and `contains` with `min/maxContains`. */ function validateArrayKeywords( schema: Record, value: unknown[], @@ -471,6 +488,7 @@ function validateArrayKeywords( return valid; } +/** Apply string-shaped keywords: `min/maxLength`, `pattern`. Invalid regexes flag the schema itself rather than the value. */ function validateStringKeywords( schema: Record, value: string, @@ -500,6 +518,7 @@ function validateStringKeywords( return valid; } +/** Apply number-shaped keywords: `minimum`/`maximum`, `exclusiveMinimum`/`exclusiveMaximum` (both numeric draft 2020-12 and boolean draft-07 forms), and `multipleOf`. */ function validateNumberKeywords( schema: Record, value: number, diff --git a/packages/ai/src/utils/schema/meta-validator.ts b/packages/ai/src/utils/schema/meta-validator.ts index 7333fb6f3..28f1d5ebb 100644 --- a/packages/ai/src/utils/schema/meta-validator.ts +++ b/packages/ai/src/utils/schema/meta-validator.ts @@ -1,4 +1,5 @@ import { areJsonValuesEqual } from "./equality"; +import { epochNext, once } from "./stamps"; /** * Hand-rolled JSON Schema meta-validator. @@ -15,7 +16,15 @@ function isPlainObject(value: Json): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -const TYPE_NAMES = new Set(["string", "number", "integer", "boolean", "object", "array", "null"]); +const TYPE_NAMES: Record = { + string: true, + number: true, + integer: true, + boolean: true, + object: true, + array: true, + null: true, +}; function isNonNegativeInteger(value: Json): value is number { return typeof value === "number" && Number.isInteger(value) && value >= 0; @@ -31,49 +40,51 @@ function hasUniqueJsonValues(values: readonly unknown[]): boolean { } function checkTypeKeyword(value: Json): boolean { - if (typeof value === "string") return TYPE_NAMES.has(value); + if (typeof value === "string") return value in TYPE_NAMES; if (!Array.isArray(value) || value.length === 0) return false; const seen = new Set(); for (const entry of value) { - if (typeof entry !== "string" || !TYPE_NAMES.has(entry) || seen.has(entry)) return false; + if (typeof entry !== "string" || !(entry in TYPE_NAMES) || seen.has(entry)) return false; seen.add(entry); } return true; } -function checkSchemaArray(value: Json, seen: WeakSet): boolean { - return Array.isArray(value) && value.every(entry => checkNode(entry, seen)); +function checkSchemaArray(value: Json, epoch: number): boolean { + return Array.isArray(value) && value.every(entry => checkNode(entry, epoch)); } -function checkSchemaMap(value: Json, seen: WeakSet): boolean { +function checkSchemaMap(value: Json, epoch: number): boolean { if (!isPlainObject(value)) return false; - return Object.values(value).every(sub => checkNode(sub, seen)); + for (const k in value) { + if (!checkNode(value[k], epoch)) return false; + } + return true; } /** Validate a single sub-schema node. */ -function checkNode(node: Json, seen: WeakSet): boolean { +function checkNode(node: Json, epoch: number): boolean { // Boolean schemas (`true` / `false`) are valid JSON Schema. if (node === true || node === false) return true; if (!isPlainObject(node)) return false; - if (seen.has(node)) return true; - seen.add(node); + if (!once(node, epoch)) return true; if ("type" in node && !checkTypeKeyword(node.type)) return false; for (const key of ["anyOf", "oneOf", "allOf"] as const) { - if (key in node && !checkSchemaArray(node[key], seen)) return false; + if (key in node && !checkSchemaArray(node[key], epoch)) return false; } - if ("not" in node && !checkNode(node.not, seen)) return false; + if ("not" in node && !checkNode(node.not, epoch)) return false; for (const key of ["if", "then", "else"] as const) { - if (key in node && !checkNode(node[key], seen)) return false; + if (key in node && !checkNode(node[key], epoch)) return false; } for (const key of ["properties", "patternProperties", "$defs", "definitions"] as const) { - if (key in node && !checkSchemaMap(node[key], seen)) return false; + if (key in node && !checkSchemaMap(node[key], epoch)) return false; } - if ("propertyNames" in node && !checkNode(node.propertyNames, seen)) return false; - if ("contains" in node && !checkNode(node.contains, seen)) return false; + if ("propertyNames" in node && !checkNode(node.propertyNames, epoch)) return false; + if ("contains" in node && !checkNode(node.contains, epoch)) return false; if ("required" in node) { const value = node.required; @@ -88,9 +99,9 @@ function checkNode(node: Json, seen: WeakSet): boolean { if ("items" in node) { const items = node.items; if (Array.isArray(items)) return false; - if (!checkNode(items, seen)) return false; + if (!checkNode(items, epoch)) return false; } - if ("prefixItems" in node && !checkSchemaArray(node.prefixItems, seen)) return false; + if ("prefixItems" in node && !checkSchemaArray(node.prefixItems, epoch)) return false; // Obsolete tuple/dependency keywords are not valid in the 2020-12 schema // shape we emit and forward. if ("additionalItems" in node || "dependencies" in node) return false; @@ -98,14 +109,15 @@ function checkNode(node: Json, seen: WeakSet): boolean { for (const key of ["additionalProperties", "unevaluatedProperties", "unevaluatedItems"] as const) { if (!(key in node)) continue; const value = node[key]; - if (typeof value !== "boolean" && !checkNode(value, seen)) return false; + if (typeof value !== "boolean" && !checkNode(value, epoch)) return false; } - if ("dependentSchemas" in node && !checkSchemaMap(node.dependentSchemas, seen)) return false; + if ("dependentSchemas" in node && !checkSchemaMap(node.dependentSchemas, epoch)) return false; if ("dependentRequired" in node) { const value = node.dependentRequired; if (!isPlainObject(value)) return false; - for (const entry of Object.values(value)) { + for (const k in value) { + const entry = value[k]; if (!Array.isArray(entry) || !entry.every(item => typeof item === "string")) return false; } } @@ -148,7 +160,7 @@ function checkNode(node: Json, seen: WeakSet): boolean { /** Validate that `schema` is structurally a valid JSON Schema (subset). */ export function isValidJsonSchema(schema: unknown): boolean { try { - return checkNode(schema, new WeakSet()); + return checkNode(schema, epochNext()); } catch { return false; } diff --git a/packages/ai/src/utils/schema/normalize-cca.ts b/packages/ai/src/utils/schema/normalize-cca.ts index a6bc5b79c..fcf9fe61f 100644 --- a/packages/ai/src/utils/schema/normalize-cca.ts +++ b/packages/ai/src/utils/schema/normalize-cca.ts @@ -1,8 +1,32 @@ +/** + * Cloud Code Assist (CCA) for Claude rejects most JSON Schema combinator and + * nullable shapes. This module is the multi-pass rewriter that turns whatever + * the tool author authored into the narrow subset CCA accepts: + * + * 1. `sanitizeSchemaForCCA` — strip Google-incompatible keywords, normalize + * `type: [..., "null"]` arrays into a scalar + nullable. + * 2. `mergeObjectCombinerVariants` — collapse `anyOf` of object variants + * into a single merged object. + * 3. `collapseMixedTypeCombinerVariants` — `anyOf` of distinct scalar types + * collapses to the first non-null type (lossy, intentional). + * 4. `collapseSameTypeCombinerVariants` — `anyOf` of variants with one + * shared type collapses to that variant (lossy, intentional). + * 5. `stripResidualCombiners` — fixpoint loop applying 3+4 to combiners that + * pass-1 merging produced from inside merged subtrees. + * 6. `normalizeNullablePropertiesForCloudCodeAssist` — extract `nullable: T` + * from `anyOf:[T,null]`-shaped property schemas and demote those keys + * from `required`. + * + * If any incompatibility survives, we ship a stub `{type:"object",properties:{}}` + * fallback for that tool — CCA will accept the call but the model will see no + * arguments documented. Better than rejecting the whole turn. + */ import { logger } from "@oh-my-pi/pi-utils"; import { areJsonValuesEqual, mergePropertySchemas } from "./equality"; import { CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS, CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS } from "./fields"; import { isValidJsonSchema } from "./meta-validator"; import { sanitizeSchemaForCCA } from "./sanitize-google"; +import { epochNext, once } from "./stamps"; import type { JsonObject } from "./types"; import { isJsonObject } from "./types"; @@ -49,13 +73,14 @@ function mergeObjectCombinerVariants(schema: JsonObject, combiner: "anyOf" | "on const mergedProperties: JsonObject = {}; const ownProperties = isJsonObject(schema.properties) ? schema.properties : {}; - for (const [name, propertySchema] of Object.entries(ownProperties)) { - mergedProperties[name] = propertySchema; + for (const name in ownProperties) { + mergedProperties[name] = ownProperties[name]; } for (const variant of variants) { const properties = isJsonObject(variant.properties) ? variant.properties : {}; - for (const [name, propertySchema] of Object.entries(properties)) { + for (const name in properties) { + const propertySchema = properties[name]; const existingSchema = mergedProperties[name]; mergedProperties[name] = existingSchema === undefined ? propertySchema : mergePropertySchemas(existingSchema, propertySchema); @@ -67,7 +92,11 @@ function mergeObjectCombinerVariants(schema: JsonObject, combiner: "anyOf" | "on nextSchema.type = "object"; nextSchema.properties = mergedProperties; - // Compute intersection of all variants' required arrays + // Compute the `required` set for the merged object. We intersect each + // variant's required keys (a property is only required if every variant + // required it) and then union in the parent's own required keys for + // properties that lived on the parent. Filter against `mergedProperties` + // so we never reference a key that does not exist on the result. let requiredIntersection: string[] | undefined; for (const variant of variants) { const variantRequired = Array.isArray(variant.required) @@ -83,17 +112,20 @@ function mergeObjectCombinerVariants(schema: JsonObject, combiner: "anyOf" | "on const parentRequired = Array.isArray(schema.required) ? schema.required.filter((r): r is string => typeof r === "string") : []; - const ownPropertyNames = new Set(Object.keys(ownProperties)); const safeRequired = new Set(); for (const name of requiredIntersection ?? []) { if (name in mergedProperties) safeRequired.add(name); } for (const name of parentRequired) { - if (ownPropertyNames.has(name) && name in mergedProperties) { + if (name in ownProperties && name in mergedProperties) { safeRequired.add(name); } } - const requiredInPropertyOrder = Object.keys(mergedProperties).filter(name => safeRequired.has(name)); + // Emit required in property-insertion order so the wire payload is stable. + const requiredInPropertyOrder: string[] = []; + for (const name in mergedProperties) { + if (safeRequired.has(name)) requiredInPropertyOrder.push(name); + } if (requiredInPropertyOrder.length > 0) { nextSchema.required = requiredInPropertyOrder; } else { @@ -133,9 +165,10 @@ function collapseMixedTypeCombinerVariants(schema: JsonObject, combiner: "anyOf" return schema; } - for (const [key, variantValue] of Object.entries(entry)) { + for (const key in entry) { + const variantValue = entry[key]; if (key === "type") continue; - if (!allowedKeys.has(key) && !CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS.has(key)) { + if (!(key in allowedKeys) && !(key in CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS)) { return schema; } @@ -160,7 +193,8 @@ function collapseMixedTypeCombinerVariants(schema: JsonObject, combiner: "anyOf" // Lossy: when multiple non-null types exist we pick the first. CCA requires // a scalar type and keeping the anyOf would cause an API rejection at runtime. nextSchema.type = nonNullTypes[0] ?? variantTypes[0]; - for (const [key, value] of Object.entries(mergedVariantFields)) { + for (const key in mergedVariantFields) { + const value = mergedVariantFields[key]; const existingValue = nextSchema[key]; if (existingValue !== undefined && !areJsonValuesEqual(existingValue, value)) { return schema; @@ -192,8 +226,8 @@ function collapseSameTypeCombinerVariants(schema: JsonObject, combiner: "anyOf" } if (!firstEntry) return schema; const nextSchema = copySchemaWithout(schema, combiner); - for (const [key, value] of Object.entries(firstEntry)) { - if (!(key in nextSchema)) nextSchema[key] = value; + for (const key in firstEntry) { + if (!(key in nextSchema)) nextSchema[key] = firstEntry[key]; } return nextSchema; } @@ -203,18 +237,16 @@ function collapseSameTypeCombinerVariants(schema: JsonObject, combiner: "anyOf" * This is needed because mergeObjectCombinerVariants can create new anyOf in merged * properties AFTER the recursive normalization pass has already processed children. */ -export function stripResidualCombiners(value: unknown, seen = new WeakSet()): unknown { +export function stripResidualCombiners(value: unknown, epoch: number = epochNext()): unknown { if (Array.isArray(value)) { - if (seen.has(value)) return []; - seen.add(value); - return value.map(entry => stripResidualCombiners(entry, seen)); + if (!once(value, epoch)) return []; + return value.map(entry => stripResidualCombiners(entry, epoch)); } if (!isJsonObject(value)) return value; - if (seen.has(value)) return {}; - seen.add(value); + if (!once(value, epoch)) return {}; const result: JsonObject = {}; - for (const [key, entry] of Object.entries(value)) { - result[key] = stripResidualCombiners(entry, seen); + for (const key in value) { + result[key] = stripResidualCombiners(value[key], epoch); } let current: JsonObject = result; let changed = true; @@ -236,22 +268,19 @@ export function stripResidualCombiners(value: unknown, seen = new WeakSet): unknown { - if (!seen) seen = new WeakSet(); +function normalizeSchemaForCCA(value: unknown, epoch: number = epochNext()): unknown { if (Array.isArray(value)) { - if (seen.has(value)) return []; - seen.add(value); - return value.map(entry => normalizeSchemaForCCA(entry, seen)); + if (!once(value, epoch)) return []; + return value.map(entry => normalizeSchemaForCCA(entry, epoch)); } if (!isJsonObject(value)) { return value; } - if (seen.has(value)) return {}; - seen.add(value); + if (!once(value, epoch)) return {}; const normalized: JsonObject = {}; - for (const [key, entry] of Object.entries(value)) { - normalized[key] = normalizeSchemaForCCA(entry, seen); + for (const key in value) { + normalized[key] = normalizeSchemaForCCA(value[key], epoch); } const mergedAnyOf = mergeObjectCombinerVariants(normalized, "anyOf"); @@ -294,9 +323,15 @@ function extractNullableUnionSchema(schema: unknown): NullableExtractionResult { let hasNullVariant = false; const nonNullVariants: unknown[] = []; for (const variant of variantsRaw) { - if (isJsonObject(variant) && variant.type === "null" && Object.keys(variant).length === 1) { - hasNullVariant = true; - continue; + if (isJsonObject(variant) && variant.type === "null") { + let keyCount = 0; + for (const _k in variant) { + if (++keyCount > 1) break; + } + if (keyCount === 1) { + hasNullVariant = true; + continue; + } } nonNullVariants.push(variant); } @@ -306,7 +341,9 @@ function extractNullableUnionSchema(schema: unknown): NullableExtractionResult { } const nextSchema = copySchemaWithout(schema, combiner); - for (const [key, value] of Object.entries(nonNullVariants[0])) { + const nonNullVariant = nonNullVariants[0]; + for (const key in nonNullVariant) { + const value = nonNullVariant[key]; const existingValue = nextSchema[key]; if (existingValue !== undefined && !areJsonValuesEqual(existingValue, value)) { return { schema, nullable: false }; @@ -329,41 +366,39 @@ interface NullableNormalizationResult { function normalizeNullablePropertiesForCloudCodeAssist( value: unknown, isPropertySchema = false, - seen?: WeakSet, + epoch: number = epochNext(), ): NullableNormalizationResult { - if (!seen) seen = new WeakSet(); if (Array.isArray(value)) { - if (seen.has(value)) { + if (!once(value, epoch)) { return { schema: [], nullable: false }; } - seen.add(value); return { - schema: value.map(entry => normalizeNullablePropertiesForCloudCodeAssist(entry, false, seen).schema), + schema: value.map(entry => normalizeNullablePropertiesForCloudCodeAssist(entry, false, epoch).schema), nullable: false, }; } if (!isJsonObject(value)) { return { schema: value, nullable: false }; } - if (seen.has(value)) { + if (!once(value, epoch)) { return { schema: {}, nullable: false }; } - seen.add(value); const normalized: JsonObject = {}; - for (const [key, entry] of Object.entries(value)) { - normalized[key] = normalizeNullablePropertiesForCloudCodeAssist(entry, false, seen).schema; + for (const key in value) { + normalized[key] = normalizeNullablePropertiesForCloudCodeAssist(value[key], false, epoch).schema; } if (isJsonObject(normalized.properties)) { + const properties = normalized.properties; const required = new Set( Array.isArray(normalized.required) ? normalized.required.filter((entry): entry is string => typeof entry === "string") : [], ); const nextProperties: JsonObject = {}; - for (const [name, propertySchema] of Object.entries(normalized.properties)) { - const normalizedProperty = normalizeNullablePropertiesForCloudCodeAssist(propertySchema, true, seen); + for (const name in properties) { + const normalizedProperty = normalizeNullablePropertiesForCloudCodeAssist(properties[name], true, epoch); nextProperties[name] = normalizedProperty.schema; if (normalizedProperty.nullable) { required.delete(name); @@ -393,21 +428,19 @@ function isValidCCASchema(schema: unknown): boolean { } /** See COMBINATOR_KEYS in fields.ts — CCA forbids all three combiners. */ -const CCA_FORBIDDEN_COMBINERS = new Set(["anyOf", "oneOf", "allOf"] as const); +const CCA_FORBIDDEN_COMBINERS: Record = { anyOf: true, oneOf: true, allOf: true }; -function hasResidualCloudCodeAssistIncompatibilities(value: unknown, seen = new WeakSet()): boolean { +function hasResidualCloudCodeAssistIncompatibilities(value: unknown, epoch: number = epochNext()): boolean { if (Array.isArray(value)) { - if (seen.has(value)) return false; - seen.add(value); - return value.some(entry => hasResidualCloudCodeAssistIncompatibilities(entry, seen)); + if (!once(value, epoch)) return false; + return value.some(entry => hasResidualCloudCodeAssistIncompatibilities(entry, epoch)); } if (!isJsonObject(value)) { return false; } - if (seen.has(value)) { + if (!once(value, epoch)) { return false; } - seen.add(value); if (Array.isArray(value.type) || value.type === "null") { return true; @@ -415,13 +448,13 @@ function hasResidualCloudCodeAssistIncompatibilities(value: unknown, seen = new if (Object.hasOwn(value, "nullable")) { return true; } - for (const combiner of CCA_FORBIDDEN_COMBINERS) { + for (const combiner in CCA_FORBIDDEN_COMBINERS) { if (Array.isArray(value[combiner])) { return true; } } - for (const entry of Object.values(value)) { - if (hasResidualCloudCodeAssistIncompatibilities(entry, seen)) { + for (const k in value) { + if (hasResidualCloudCodeAssistIncompatibilities(value[k], epoch)) { return true; } } diff --git a/packages/ai/src/utils/schema/sanitize-google.ts b/packages/ai/src/utils/schema/sanitize-google.ts index 7cc445667..de52a0b22 100644 --- a/packages/ai/src/utils/schema/sanitize-google.ts +++ b/packages/ai/src/utils/schema/sanitize-google.ts @@ -1,13 +1,38 @@ +/** + * Provider-specific JSON Schema sanitizers used in the request path. + * + * Google's Schema proto, Cloud Code Assist's Claude bridge, and MCP/AJV + * validation all reject different subsets of standard JSON Schema. Rather + * than ship three near-identical walkers, this module exposes a shared + * `sanitizeSchemaImpl` parameterised by an options bag, plus three thin + * wrappers that fix the option set for each target. + */ import { dereferenceJsonSchema } from "./dereference"; +import { upgradeJsonSchemaTo202012 } from "./draft"; import { areJsonValuesEqual } from "./equality"; import { UNSUPPORTED_SCHEMA_FIELDS } from "./fields"; +import { epochNext, once } from "./stamps"; +/** + * Options that pin the behavior of `sanitizeSchemaImpl`. + * + * - `insideProperties`: true when we are walking the children of a `properties` + * object. Keys at that level are property *names*, not JSON-Schema keywords — + * so the "strip unsupported keyword" rule must not apply. + * - `normalizeTypeArrayToNullable`: convert `type: ["string","null"]` to + * `type: "string"` + `nullable: true`. Required for Google's proto; left off + * for MCP which keeps standard JSON Schema shapes. + * - `stripNullableKeyword`: remove `nullable` entirely. CCA forbids the + * keyword; Google keeps it. + * - `unsupportedFields`: provider-specific keyword blacklist. + * - `epoch`: shared cycle guard (see `stamps.ts`). + */ interface SanitizeSchemaOptions { insideProperties: boolean; normalizeTypeArrayToNullable: boolean; stripNullableKeyword: boolean; - unsupportedFields: ReadonlySet; - seen: WeakSet; + unsupportedFields: Record; + epoch: number; } function inferJsonSchemaTypeFromValue(value: unknown): string | undefined { @@ -33,17 +58,25 @@ function pushEnumValue(values: unknown[], value: unknown): void { } } +/** + * Generic sanitizer core. Two phases: + * 1. If a combiner (`anyOf`/`oneOf`) holds variants that are all `const` + * values, collapse it into an `enum`. Google/CCA do not accept + * `const`-in-combinator unions but do accept enums. + * 2. Otherwise, walk the schema, stripping disallowed keywords and + * recursing into children. Standalone `const` values are converted to + * single-entry `enum` arrays. + * Cycle-safe via `once(epoch)`; cycles short-circuit to `{}`/`[]`. + */ function sanitizeSchemaImpl(value: unknown, options: SanitizeSchemaOptions): unknown { if (Array.isArray(value)) { - if (options.seen.has(value)) return []; - options.seen.add(value); + if (!once(value, options.epoch)) return []; return value.map(entry => sanitizeSchemaImpl(entry, options)); } if (!value || typeof value !== "object") { return value; } - if (options.seen.has(value as object)) return {}; - options.seen.add(value as object); + if (!once(value as object, options.epoch)) return {}; const obj = value as Record; const result: Record = {}; for (const combiner of ["anyOf", "oneOf"] as const) { @@ -51,6 +84,7 @@ function sanitizeSchemaImpl(value: unknown, options: SanitizeSchemaOptions): unk const variants = obj[combiner] as Record[]; const allHaveConst = variants.every(v => v && typeof v === "object" && "const" in v); if (allHaveConst && variants.length > 0) { + // Step 1a: collect deduped enum values from every variant's const. const dedupedEnum: unknown[] = []; for (const variant of variants) { pushEnumValue(dedupedEnum, variant.const); @@ -63,6 +97,9 @@ function sanitizeSchemaImpl(value: unknown, options: SanitizeSchemaOptions): unk const allHaveSameExplicitType = explicitTypes.length === variants.length && explicitTypes.every(variantType => variantType === explicitTypes[0]); + // Step 1b: pick a `type` for the synthesized enum. Prefer an explicit + // type declared on every variant; otherwise infer from the values + // themselves. Mixed types stay un-typed (Google accepts a bare enum). if (allHaveSameExplicitType && explicitTypes[0]) { result.type = explicitTypes[0]; } else { @@ -75,6 +112,7 @@ function sanitizeSchemaImpl(value: unknown, options: SanitizeSchemaOptions): unk } else { const nonNullInferredTypes = inferredTypes.filter(inferredType => inferredType !== "null"); const nonNullTypeSet = new Set(nonNullInferredTypes); + // nullable + single non-null type: collapse to scalar + nullable marker. if (inferredTypes.includes("null") && nonNullTypeSet.size === 1) { result.type = nonNullInferredTypes[0]; if (!options.stripNullableKeyword) { @@ -84,8 +122,10 @@ function sanitizeSchemaImpl(value: unknown, options: SanitizeSchemaOptions): unk } } + // Step 1c: pull non-combiner siblings (description, etc.) through. // Copy description and other top-level fields (not the combiner) - for (const [key, entry] of Object.entries(obj)) { + for (const key in obj) { + const entry = obj[key]; if (key !== combiner && !(key in result)) { result[key] = sanitizeSchemaImpl(entry, { ...options, @@ -97,14 +137,17 @@ function sanitizeSchemaImpl(value: unknown, options: SanitizeSchemaOptions): unk } } } - // Regular field processing + // Phase 2: not a const-combiner — process keys one by one. let constValue: unknown; - for (const [key, entry] of Object.entries(obj)) { + for (const key in obj) { + const entry = obj[key]; // Only strip unsupported schema keywords when NOT inside "properties" object // Inside "properties", keys are property names (e.g., "pattern") not schema keywords - if (!options.insideProperties && options.unsupportedFields.has(key)) continue; + if (!options.insideProperties && key in options.unsupportedFields) continue; if (options.stripNullableKeyword && key === "nullable") continue; if (key === "const") { + // `const` is converted to a single-entry `enum` after the loop so the + // `type` inference can use it. constValue = entry; continue; } @@ -146,18 +189,18 @@ function sanitizeSchemaImpl(value: unknown, options: SanitizeSchemaOptions): unk * Sanitize a JSON Schema for Google's generative AI APIs by stripping unsupported * JSON Schema keywords and normalizing representable nullable/type patterns. * - * **Prerequisite:** The input schema must be fully dereferenced — all `$ref` - * pointers resolved inline — before calling this function. `$ref` is silently - * stripped as an unsupported keyword; unresolved references will produce an - * incomplete schema with no warning. + * Draft-07-shaped schemas are upgraded to 2020-12 before provider-specific + * unsupported keywords are stripped. `$ref` is still stripped as unsupported; + * callers that need references preserved must dereference before this path. */ export function sanitizeSchemaForGoogle(value: unknown): unknown { - return sanitizeSchemaImpl(value, { + const upgraded = upgradeJsonSchemaTo202012(value); + return sanitizeSchemaImpl(upgraded, { insideProperties: false, normalizeTypeArrayToNullable: true, stripNullableKeyword: false, unsupportedFields: UNSUPPORTED_SCHEMA_FIELDS, - seen: new WeakSet(), + epoch: epochNext(), }); } @@ -165,18 +208,18 @@ export function sanitizeSchemaForGoogle(value: unknown): unknown { * Sanitize a JSON Schema for Cloud Code Assist Claude. * Starts from Google sanitizer behavior, then strips `nullable` markers. * - * **Prerequisite:** The input schema must be fully dereferenced — all `$ref` - * pointers resolved inline — before calling this function. `$ref` is silently - * stripped as an unsupported keyword; unresolved references will produce an - * incomplete schema with no warning. + * Draft-07-shaped schemas are upgraded to 2020-12 before provider-specific + * unsupported keywords are stripped. `$ref` is still stripped as unsupported; + * callers that need references preserved must dereference before this path. */ export function sanitizeSchemaForCCA(value: unknown): unknown { - return sanitizeSchemaImpl(value, { + const upgraded = upgradeJsonSchemaTo202012(value); + return sanitizeSchemaImpl(upgraded, { insideProperties: false, normalizeTypeArrayToNullable: true, stripNullableKeyword: true, unsupportedFields: UNSUPPORTED_SCHEMA_FIELDS, - seen: new WeakSet(), + epoch: epochNext(), }); } @@ -185,7 +228,7 @@ export function sanitizeSchemaForCCA(value: unknown): unknown { * Only `$schema` — AJV throws on unrecognised meta-schema URIs * (e.g. draft 2020-12 emitted by schemars 1.x / rmcp 0.15+). */ -const MCP_UNSUPPORTED_SCHEMA_FIELDS = new Set(["$schema"]); +const MCP_UNSUPPORTED_SCHEMA_FIELDS: Record = { $schema: true }; /** * Sanitize a JSON Schema for MCP tool parameter validation (AJV compatibility). @@ -198,15 +241,15 @@ const MCP_UNSUPPORTED_SCHEMA_FIELDS = new Set(["$schema"]); * (`pattern`, `format`, `additionalProperties`, etc.) and `$ref`/`$defs`. */ export function sanitizeSchemaForMCP(value: unknown): unknown { - // Dereference $ref/$defs first — MCP servers emit standard JSON Schema - // with $defs, but providers (Anthropic, Google) only forward `properties` - // and `required`, dropping $defs and leaving dangling $ref pointers. - const dereferenced = dereferenceJsonSchema(value); + // Upgrade before dereferencing so legacy `definitions` refs become the + // canonical `$defs` form, then inline refs for providers that drop `$defs`. + const upgraded = upgradeJsonSchemaTo202012(value); + const dereferenced = dereferenceJsonSchema(upgraded); return sanitizeSchemaImpl(dereferenced, { insideProperties: false, normalizeTypeArrayToNullable: false, stripNullableKeyword: true, unsupportedFields: MCP_UNSUPPORTED_SCHEMA_FIELDS, - seen: new WeakSet(), + epoch: epochNext(), }); } diff --git a/packages/ai/src/utils/schema/stamps.ts b/packages/ai/src/utils/schema/stamps.ts new file mode 100644 index 000000000..a5ba19abc --- /dev/null +++ b/packages/ai/src/utils/schema/stamps.ts @@ -0,0 +1,97 @@ +/** + * Symbol-keyed lazy memoization stamped directly onto the host object. + * + * Faster than a module-level `WeakMap` in V8/JSC because the symbol slot is + * resolved through the object's hidden class instead of a side-table hash + * lookup. The slot is defined as a non-enumerable property so the stamp + * does not leak through `{...spread}`, `Object.keys`, `JSON.stringify`, or + * `toEqual`-style deep equality. + * + * Caveats: the stamp lives as long as the host object, even after callers + * release their references to the cached value — only use this for caches + * whose lifetime should match the host. Frozen hosts will throw on write in + * strict mode; callers that may receive frozen input must handle that. + */ + +function define(target: T, key: symbol, value: unknown): void { + Object.defineProperty(target, key, { value, writable: true, configurable: true }); +} + +export function stamp(target: T, key: symbol, compute: (target: T) => V): V { + const slot = target as Record; + const existing = slot[key]; + if (existing !== undefined) return existing; + const value = compute(target); + define(target, key, value); + return value; +} + +/** + * Epoch-keyed cycle guard. Cheaper than `WeakSet` for recursive traversal + * because the marker is a single property slot on the host object, written + * once and overwritten in place on every subsequent traversal — the hidden + * class transitions once per object lifetime, not per traversal. + * + * Usage: + * function walk(node, epoch = epochNext()) { + * if (!once(node, epoch)) return; // cycle + * for (const child of node.children) walk(child, epoch); + * } + */ +const kEpoch = Symbol("pi.schema.epoch"); +let __epoch = 0; + +export function epochNext(): number { + return ++__epoch; +} + +/** + * Marks `target` as visited for this `epoch`. Returns `true` the first time + * it is called for a given (target, epoch) pair and `false` on every + * subsequent call within the same epoch. + */ +export function once(target: T, epoch: number): boolean { + const slot = target as Record; + const cur = slot[kEpoch]; + if (cur !== undefined && cur >= epoch) return false; + if (cur === undefined) define(target, kEpoch, epoch); + else slot[kEpoch] = epoch; + return true; +} + +/** + * Counter-based path tracker. Use when a traversal needs to distinguish + * "currently on the recursion path" from "previously visited" — i.e. cycle + * detection that throws while still allowing DAG sharing. Increment on + * entry, decrement on exit; the slot returns to 0 after a balanced walk so + * subsequent top-level calls see a fresh state without any reset. + * + * Unlike a `WeakSet` with `seen.delete(...)`, the property is never deleted + * — only incremented and decremented — so the host object's hidden class + * is never invalidated. + * + * Usage: + * function walk(node) { + * if (!enter(node)) throw new Error("cycle"); + * try { for (const c of node.children) walk(c); } + * finally { exit(node); } + * } + */ +const kDepth = Symbol("pi.schema.depth"); + +/** Returns `true` on first entry, `false` if `target` is already on the current path. */ +export function enter(target: T): boolean { + const slot = target as Record; + const cur = slot[kDepth]; + if (cur === undefined) { + define(target, kDepth, 1); + return true; + } + slot[kDepth] = cur + 1; + return cur === 0; +} + +export function exit(target: T): void { + const slot = target as Record; + slot[kDepth]--; +} diff --git a/packages/ai/src/utils/schema/strict-mode.ts b/packages/ai/src/utils/schema/strict-mode.ts index 12535a8f7..0e9ce6f08 100644 --- a/packages/ai/src/utils/schema/strict-mode.ts +++ b/packages/ai/src/utils/schema/strict-mode.ts @@ -1,7 +1,9 @@ import { $flag } from "@oh-my-pi/pi-utils"; import { type ZodType, z } from "zod/v4"; +import { upgradeJsonSchemaTo202012 } from "./draft"; import { areJsonValuesEqual } from "./equality"; import { COMBINATOR_KEYS, NON_STRUCTURAL_SCHEMA_KEYS } from "./fields"; +import { enter, epochNext, exit, once, stamp } from "./stamps"; import { isJsonObject } from "./types"; /** @@ -37,15 +39,38 @@ export function StringEnum( } export const NO_STRICT = $flag("PI_NO_STRICT"); +/** + * Per-schema-object memoization slot. The result of `tryEnforceStrictSchema` + * is stamped directly onto the input via `stamp(target, kStrictSchema, …)` + * so repeated calls (different providers, retries, batching) reuse the same + * computed pair without re-walking the tree. + */ +const kStrictSchema = Symbol("pi.schema.strict"); -const strictSchemaCache = new WeakMap, { schema: Record; strict: boolean }>(); -function hasUnrepresentableStrictObjectMap(schema: Record, seen?: WeakSet): boolean { - if (!seen) seen = new WeakSet(); - if (seen.has(schema)) return false; - seen.add(schema); +/** + * Detect schemas that strict mode *cannot* represent. + * + * Strict mode requires closed object shapes — every property is declared in + * `properties` and listed in `required`. That is incompatible with: + * - `patternProperties` (open keyset matched by regex), + * - `additionalProperties: true` or `additionalProperties: ` (open + * keyset with optional further constraint). + * + * This check recurses into every place a child schema may live (properties, + * items/prefixItems, combinator branches, $defs) so a single offender deep + * in the tree disqualifies the whole schema. Used to fail-open early in + * `tryEnforceStrictSchema` rather than throwing during enforcement. + */ +function hasUnrepresentableStrictObjectMap(schema: Record, epoch: number = epochNext()): boolean { + if (!once(schema, epoch)) return false; - const hasPatternProperties = - isJsonObject(schema.patternProperties) && Object.keys(schema.patternProperties).length > 0; + let hasPatternProperties = false; + if (isJsonObject(schema.patternProperties)) { + for (const _ in schema.patternProperties) { + hasPatternProperties = true; + break; + } + } const additionalPropertiesValue = schema.additionalProperties; const hasSchemaAdditionalProperties = additionalPropertiesValue === true || isJsonObject(additionalPropertiesValue); if (hasPatternProperties || hasSchemaAdditionalProperties) { @@ -53,27 +78,29 @@ function hasUnrepresentableStrictObjectMap(schema: Record, seen } if (isJsonObject(schema.properties)) { - for (const propertySchema of Object.values(schema.properties)) { - if (isJsonObject(propertySchema) && hasUnrepresentableStrictObjectMap(propertySchema, seen)) { + const properties = schema.properties; + for (const k in properties) { + const propertySchema = properties[k]; + if (isJsonObject(propertySchema) && hasUnrepresentableStrictObjectMap(propertySchema, epoch)) { return true; } } } if (isJsonObject(schema.items)) { - if (hasUnrepresentableStrictObjectMap(schema.items, seen)) { + if (hasUnrepresentableStrictObjectMap(schema.items, epoch)) { return true; } } else if (Array.isArray(schema.items)) { for (const itemSchema of schema.items) { - if (isJsonObject(itemSchema) && hasUnrepresentableStrictObjectMap(itemSchema, seen)) { + if (isJsonObject(itemSchema) && hasUnrepresentableStrictObjectMap(itemSchema, epoch)) { return true; } } } if (Array.isArray(schema.prefixItems)) { for (const itemSchema of schema.prefixItems) { - if (isJsonObject(itemSchema) && hasUnrepresentableStrictObjectMap(itemSchema, seen)) { + if (isJsonObject(itemSchema) && hasUnrepresentableStrictObjectMap(itemSchema, epoch)) { return true; } } @@ -83,7 +110,7 @@ function hasUnrepresentableStrictObjectMap(schema: Record, seen const variants = schema[key]; if (!Array.isArray(variants)) continue; for (const variant of variants) { - if (isJsonObject(variant) && hasUnrepresentableStrictObjectMap(variant, seen)) { + if (isJsonObject(variant) && hasUnrepresentableStrictObjectMap(variant, epoch)) { return true; } } @@ -92,8 +119,9 @@ function hasUnrepresentableStrictObjectMap(schema: Record, seen for (const defsKey of ["$defs", "definitions"] as const) { const defs = schema[defsKey]; if (!isJsonObject(defs)) continue; - for (const defSchema of Object.values(defs)) { - if (isJsonObject(defSchema) && hasUnrepresentableStrictObjectMap(defSchema, seen)) { + for (const k in defs) { + const defSchema = defs[k]; + if (isJsonObject(defSchema) && hasUnrepresentableStrictObjectMap(defSchema, epoch)) { return true; } } @@ -101,29 +129,46 @@ function hasUnrepresentableStrictObjectMap(schema: Record, seen return false; } +/** + * First pass of strict-mode preparation. + * + * Rewrites everything strict mode forbids into something it accepts: + * - Drops non-structural keywords (`format`, `pattern`, `examples`, …), + * `const`, `nullable`, and `additionalProperties` (re-added by + * `enforceStrictSchema` as `false`). + * - `type: [a, b]` → `anyOf: [{type: a, …}, {type: b, …}]`, copying only the + * keywords each variant can use (e.g. `properties` stays only on the + * object variant). + * - `const` → single-entry `enum`. + * - Description carries a `(default: X)` suffix so the model still sees the + * documented default after the keyword is stripped. + * - `nullable: true` wraps the whole node in `anyOf:[T,{type:"null"}]`. + * + * Recurses into properties, items, prefixItems, combinators, and $defs. The + * `cache` WeakMap dedupes shared subgraphs; the `epoch` is the cycle guard. + */ export function sanitizeSchemaForStrictMode( schema: Record, - seen?: WeakSet, - cache?: WeakMap, Record>, + epoch: number = epochNext(), + cache: WeakMap, Record> = new WeakMap(), ): Record { - if (!seen) seen = new WeakSet(); - if (!cache) cache = new WeakMap(); const cached = cache.get(schema); if (cached) return cached; - if (seen.has(schema)) return {}; - seen.add(schema); + if (!once(schema, epoch)) return {}; const typeValue = schema.type; if (Array.isArray(typeValue)) { const typeVariants = typeValue.filter((entry): entry is string => typeof entry === "string"); const schemaWithoutType = { ...schema }; delete schemaWithoutType.type; - const sanitizedWithoutType = sanitizeSchemaForStrictMode(schemaWithoutType, seen, cache); + const sanitizedWithoutType = sanitizeSchemaForStrictMode(schemaWithoutType, epoch, cache); if (typeVariants.length === 0) { cache.set(schema, sanitizedWithoutType); - seen.delete(schema); return sanitizedWithoutType; } + // Build one variant schema per type. Each variant keeps only the keywords + // relevant to that type — object-only keywords stay on the object variant, + // array-only keywords on the array variant, etc. const variants = typeVariants.map(variantType => { const variantSchema: Record = { ...sanitizedWithoutType, type: variantType }; @@ -135,12 +180,11 @@ export function sanitizeSchemaForStrictMode( if (variantType !== "array") { delete variantSchema.items; } - return sanitizeSchemaForStrictMode(variantSchema, seen, cache); + return sanitizeSchemaForStrictMode(variantSchema, epoch, cache); }); if (variants.length === 1) { cache.set(schema, variants[0] as Record); - seen.delete(schema); return variants[0] as Record; } @@ -148,66 +192,74 @@ export function sanitizeSchemaForStrictMode( anyOf: variants, }; cache.set(schema, result); - seen.delete(schema); return result; } + // Scalar `type`: walk the keys, rewriting or stripping per strict-mode rules. const sanitized: Record = {}; cache.set(schema, sanitized); - for (const [key, value] of Object.entries(schema)) { - if (NON_STRUCTURAL_SCHEMA_KEYS.has(key) || key === "type" || key === "const" || key === "nullable") { + for (const key in schema) { + const value = schema[key]; + if (key in NON_STRUCTURAL_SCHEMA_KEYS || key === "type" || key === "const" || key === "nullable") { continue; } + // `properties` map — recurse into each property schema. if (key === "properties" && isJsonObject(value)) { - const properties = Object.fromEntries( - Object.entries(value).map(([propertyName, propertySchema]) => [ - propertyName, - isJsonObject(propertySchema) ? sanitizeSchemaForStrictMode(propertySchema, seen, cache) : propertySchema, - ]), - ); + const properties: Record = {}; + for (const propertyName in value) { + const propertySchema = value[propertyName]; + properties[propertyName] = isJsonObject(propertySchema) + ? sanitizeSchemaForStrictMode(propertySchema, epoch, cache) + : propertySchema; + } sanitized.properties = properties; continue; } + // `items` can be schema, tuple-array, or scalar boolean — recurse where applicable. if (key === "items") { if (isJsonObject(value)) { - sanitized.items = sanitizeSchemaForStrictMode(value, seen, cache); + sanitized.items = sanitizeSchemaForStrictMode(value, epoch, cache); } else if (Array.isArray(value)) { sanitized.items = value.map(entry => - isJsonObject(entry) ? sanitizeSchemaForStrictMode(entry, seen, cache) : entry, + isJsonObject(entry) ? sanitizeSchemaForStrictMode(entry, epoch, cache) : entry, ); } else { sanitized.items = value; } continue; } + // `prefixItems` is always an array of schemas (draft 2020-12). if (key === "prefixItems" && Array.isArray(value)) { sanitized.prefixItems = value.map(entry => - isJsonObject(entry) ? sanitizeSchemaForStrictMode(entry, seen, cache) : entry, + isJsonObject(entry) ? sanitizeSchemaForStrictMode(entry, epoch, cache) : entry, ); continue; } + // `anyOf`/`oneOf`/`allOf` arrays — recurse into each branch. if (COMBINATOR_KEYS.includes(key as (typeof COMBINATOR_KEYS)[number]) && Array.isArray(value)) { sanitized[key] = value.map(entry => - isJsonObject(entry) ? sanitizeSchemaForStrictMode(entry, seen, cache) : entry, + isJsonObject(entry) ? sanitizeSchemaForStrictMode(entry, epoch, cache) : entry, ); continue; } + // Definition maps — recurse into each named schema. if ((key === "$defs" || key === "definitions") && isJsonObject(value)) { - sanitized[key] = Object.fromEntries( - Object.entries(value).map(([definitionName, definitionSchema]) => [ - definitionName, - isJsonObject(definitionSchema) - ? sanitizeSchemaForStrictMode(definitionSchema, seen, cache) - : definitionSchema, - ]), - ); + const defs: Record = {}; + for (const definitionName in value) { + const definitionSchema = value[definitionName]; + defs[definitionName] = isJsonObject(definitionSchema) + ? sanitizeSchemaForStrictMode(definitionSchema, epoch, cache) + : definitionSchema; + } + sanitized[key] = defs; continue; } + // `additionalProperties` is owned by `enforceStrictSchema`, which sets it to false. if (key === "additionalProperties") { continue; @@ -225,6 +277,7 @@ export function sanitizeSchemaForStrictMode( sanitized[key] = value; } + // Post-pass: re-derive `type` and turn dropped keywords into a representable shape. if (Object.hasOwn(schema, "const")) { const constVal = schema.const; @@ -235,6 +288,7 @@ export function sanitizeSchemaForStrictMode( sanitized.enum = existingEnum; } + // Preserve the original scalar type after the strip-and-rebuild loop. if (typeof typeValue === "string") { sanitized.type = typeValue; } @@ -247,34 +301,39 @@ export function sanitizeSchemaForStrictMode( sanitized.type = "array"; } + // Last-resort inference: a bare `enum` with homogeneous primitives gets a `type`. if (sanitized.type === undefined && Array.isArray(sanitized.enum)) { - const enumTypes = new Set( - sanitized.enum - .map(v => - v === null - ? "null" - : typeof v === "string" - ? "string" - : typeof v === "number" - ? "number" - : typeof v === "boolean" - ? "boolean" - : undefined, - ) - .filter((t): t is "null" | "string" | "number" | "boolean" => t !== undefined), - ); - if (enumTypes.size === 1) { - sanitized.type = [...enumTypes][0]; + let inferredType: "null" | "string" | "number" | "boolean" | undefined; + let conflicting = false; + for (const v of sanitized.enum) { + const t = + v === null + ? "null" + : typeof v === "string" + ? "string" + : typeof v === "number" + ? "number" + : typeof v === "boolean" + ? "boolean" + : undefined; + if (t === undefined) continue; + if (inferredType === undefined) inferredType = t; + else if (inferredType !== t) { + conflicting = true; + break; + } + } + if (!conflicting && inferredType !== undefined) { + sanitized.type = inferredType; } } + // `nullable: true` was stripped above — re-introduce it as an `anyOf` wrapper. if (schema.nullable === true) { const { nullable: _, ...withoutNullable } = sanitized; - seen.delete(schema); return { anyOf: [withoutNullable, { type: "null" }] }; } - seen.delete(schema); return sanitized; } @@ -294,21 +353,27 @@ export function sanitizeSchemaForStrictMode( */ export function enforceStrictSchema( schema: Record, - seen?: WeakSet, - cache?: WeakMap, Record>, + cache: WeakMap, Record> = new WeakMap(), ): Record { - if (!seen) seen = new WeakSet(); - if (!cache) cache = new WeakMap(); - if (seen.has(schema)) { + if (!enter(schema)) { throw new Error("Schema contains a circular object graph — cannot enforce strict mode"); } - const cached = cache.get(schema); - if (cached) { - return cached; + try { + const cached = cache.get(schema); + if (cached) return cached; + const result = { ...schema }; + cache.set(schema, result); + return enforceStrictSchemaBody(schema, result, cache); + } finally { + exit(schema); } - seen.add(schema); - const result = { ...schema }; - cache.set(schema, result); +} + +function enforceStrictSchemaBody( + _schema: Record, + result: Record, + cache: WeakMap, Record>, +): Record { const isObjectType = result.type === "object"; if (isObjectType) { result.additionalProperties = false; @@ -317,36 +382,39 @@ export function enforceStrictSchema( propertiesValue != null && typeof propertiesValue === "object" && !Array.isArray(propertiesValue) ? (propertiesValue as Record) : {}; - const originalRequired = new Set( + const originalRequired = new Set( Array.isArray(result.required) ? result.required.filter((value): value is string => typeof value === "string") : [], ); - const strictProperties = Object.fromEntries( - Object.entries(props).map(([key, value]) => { - const processed = - value != null && typeof value === "object" && !Array.isArray(value) - ? enforceStrictSchema(value as Record, seen, cache) - : value; - // Optional property — wrap as nullable so strict mode accepts it - if (!originalRequired.has(key)) { - // Don't double-wrap if already nullable - if ( - isJsonObject(processed) && - Array.isArray(processed.anyOf) && - processed.anyOf.some(v => isJsonObject(v) && v.type === "null") - ) { - return [key, processed]; - } - if (isJsonObject(processed) && typeof processed.description === "string") { - const { description, ...withoutDescription } = processed; - return [key, { anyOf: [withoutDescription, { type: "null" }], description }]; - } - return [key, { anyOf: [processed, { type: "null" }] }]; + const strictProperties: Record = {}; + for (const key in props) { + const value = props[key]; + const processed = + value != null && typeof value === "object" && !Array.isArray(value) + ? enforceStrictSchema(value as Record, cache) + : value; + // Optional property — wrap as nullable so strict mode accepts it + if (!originalRequired.has(key)) { + // Don't double-wrap if already nullable + if ( + isJsonObject(processed) && + Array.isArray(processed.anyOf) && + processed.anyOf.some(v => isJsonObject(v) && v.type === "null") + ) { + strictProperties[key] = processed; + continue; } - return [key, processed]; - }), - ); + if (isJsonObject(processed) && typeof processed.description === "string") { + const { description, ...withoutDescription } = processed; + strictProperties[key] = { anyOf: [withoutDescription, { type: "null" }], description }; + continue; + } + strictProperties[key] = { anyOf: [processed, { type: "null" }] }; + continue; + } + strictProperties[key] = processed; + } result.properties = strictProperties; result.required = Object.keys(strictProperties); } @@ -354,17 +422,17 @@ export function enforceStrictSchema( if (Array.isArray(result.items)) { result.items = result.items.map(entry => entry != null && typeof entry === "object" && !Array.isArray(entry) - ? enforceStrictSchema(entry as Record, seen, cache) + ? enforceStrictSchema(entry as Record, cache) : entry, ); } else { - result.items = enforceStrictSchema(result.items as Record, seen, cache); + result.items = enforceStrictSchema(result.items as Record, cache); } } if (Array.isArray(result.prefixItems)) { result.prefixItems = result.prefixItems.map(entry => entry != null && typeof entry === "object" && !Array.isArray(entry) - ? enforceStrictSchema(entry as Record, seen, cache) + ? enforceStrictSchema(entry as Record, cache) : entry, ); } @@ -372,7 +440,7 @@ export function enforceStrictSchema( if (Array.isArray(result[key])) { result[key] = (result[key] as unknown[]).map(entry => entry != null && typeof entry === "object" && !Array.isArray(entry) - ? enforceStrictSchema(entry as Record, seen, cache) + ? enforceStrictSchema(entry as Record, cache) : entry, ); } @@ -380,14 +448,15 @@ export function enforceStrictSchema( for (const defsKey of ["$defs", "definitions"] as const) { if (result[defsKey] != null && typeof result[defsKey] === "object" && !Array.isArray(result[defsKey])) { const defs = result[defsKey] as Record; - result[defsKey] = Object.fromEntries( - Object.entries(defs).map(([name, def]) => [ - name, + const nextDefs: Record = {}; + for (const name in defs) { + const def = defs[name]; + nextDefs[name] = def != null && typeof def === "object" && !Array.isArray(def) - ? enforceStrictSchema(def as Record, seen, cache) - : def, - ]), - ); + ? enforceStrictSchema(def as Record, cache) + : def; + } + result[defsKey] = nextDefs; } } // Strict mode requires every schema node to declare a concrete type (or combinator/$ref). @@ -400,30 +469,20 @@ export function enforceStrictSchema( ) { throw new Error("Schema node has no type, combinator, or $ref — cannot enforce strict mode"); } - seen.delete(schema); return result; } -export function tryEnforceStrictSchema(schema: Record): { - schema: Record; - strict: boolean; -} { - const cached = strictSchemaCache.get(schema); - if (cached) { - return cached; - } - - try { - if (hasUnrepresentableStrictObjectMap(schema)) { - throw new Error("Schema uses dynamic object keys that are not representable in strict mode"); +export function tryEnforceStrictSchema(schema: Record) { + return stamp(schema, kStrictSchema, s => { + const upgraded = upgradeJsonSchemaTo202012(s) as Record; + if (hasUnrepresentableStrictObjectMap(upgraded)) { + return { schema: upgraded, strict: false }; } - const sanitized = sanitizeSchemaForStrictMode(schema); - const result = { schema: enforceStrictSchema(sanitized), strict: true }; - strictSchemaCache.set(schema, result); - return result; - } catch { - const result = { schema, strict: false }; - strictSchemaCache.set(schema, result); - return result; - } + try { + const sanitized = sanitizeSchemaForStrictMode(upgraded); + return { schema: enforceStrictSchema(sanitized), strict: true }; + } catch { + return { schema: upgraded, strict: false }; + } + }); } diff --git a/packages/ai/src/utils/schema/wire.ts b/packages/ai/src/utils/schema/wire.ts index bcb23bba6..03ee7a2c7 100644 --- a/packages/ai/src/utils/schema/wire.ts +++ b/packages/ai/src/utils/schema/wire.ts @@ -4,16 +4,19 @@ * * Tools may author parameters in two shapes: * 1. Zod (canonical going forward) — converted to JSON Schema on demand. - * 2. TypeBox / plain JSON Schema (legacy + extension compat) — used as-is. + * 2. TypeBox / plain JSON Schema (legacy + extension compat) — upgraded to + * draft 2020-12 without converting through Zod. * - * Both are normalized at the boundary so providers always receive JSON Schema - * and the validator always runs against Zod. + * Both are normalized at the boundary so providers and validators see the same + * JSON Schema dialect. */ // We import the Zod *value* (z) for runtime APIs. Marker checks rely on the // `_zod` symbol that every Zod v4 schema instance carries. import { type ZodType, z } from "zod/v4"; import type { Tool, TSchema } from "../../types"; +import { upgradeJsonSchemaTo202012 } from "./draft"; +import { stamp } from "./stamps"; /** True when `value` is a Zod schema instance. */ export function isZodSchema(value: unknown): value is ZodType { @@ -31,8 +34,9 @@ export function isZodSchema(value: unknown): value is ZodType { ); } -/** WeakMap cache: Zod schema instance -> JSON Schema object. */ -const wireCache = new WeakMap>(); +/** Symbol-stamped caches keyed by schema object identity. */ +const kZodWireSchema = Symbol("pi.schema.zod.wire"); +const kJsonWireSchema = Symbol("pi.schema.json.wire"); /** * Post-process Zod-emitted JSON Schema so it matches the wire shape providers @@ -85,31 +89,31 @@ function walk(node: unknown): void { } } - for (const child of Object.values(obj)) walk(child); + for (const k in obj) walk(obj[k]); } /** Convert a Zod schema into the JSON Schema shape providers consume. */ export function zodToWireSchema(schema: ZodType): Record { - let json = wireCache.get(schema); - if (json) return json; - // `target: "draft-2020-12"` matches what Anthropic's `input_schema` validator - // requires out of the box; our other provider sanitizers (OpenAI strict, - // Google, Anthropic CCA) already handle the superset structurally. - const raw = z.toJSONSchema(schema, { target: "draft-2020-12" }) as Record; - json = postProcess(raw); - wireCache.set(schema, json); - return json; + return stamp(schema, kZodWireSchema, s => { + // `target: "draft-2020-12"` matches what Anthropic's `input_schema` validator + // requires out of the box; our other provider sanitizers (OpenAI strict, + // Google, Anthropic CCA) already handle the superset structurally. + const raw = z.toJSONSchema(s, { target: "draft-2020-12" }) as Record; + return postProcess(raw); + }); } /** * Resolve a tool's parameters to a JSON Schema object suitable for sending * over the wire. Zod schemas are converted (and cached); legacy TypeBox / raw - * JSON Schema parameters are returned unchanged. + * JSON Schema parameters are upgraded to draft 2020-12 (and cached). */ export function toolWireSchema(tool: Tool): Record { const params: TSchema = tool.parameters; - if (isZodSchema(params)) { - return zodToWireSchema(params); - } - return params as unknown as Record; + if (isZodSchema(params)) return zodToWireSchema(params); + return stamp( + params as Record, + kJsonWireSchema, + p => upgradeJsonSchemaTo202012(p) as Record, + ); } diff --git a/packages/ai/src/utils/validation.ts b/packages/ai/src/utils/validation.ts index 6c25c1347..f7ed55301 100644 --- a/packages/ai/src/utils/validation.ts +++ b/packages/ai/src/utils/validation.ts @@ -1,7 +1,32 @@ +/** + * Tool-call argument validation pipeline. + * + * Tools may declare their parameters as either Zod schemas (canonical) or + * plain JSON Schema (legacy / extensions). This module is the single + * entrypoint the agent calls before dispatching a tool — it: + * + * 1. Builds (or fetches from cache) a `ValidationContext` for the tool — + * the Zod schema if available plus the equivalent wire JSON Schema, or + * just the JSON Schema for non-Zod tools. + * 2. Normalizes LLM quirks (null / "null" → omit-or-default substitution) + * against the JSON Schema before validation. + * 3. Validates with the Zod or JSON-Schema validator. + * 4. On failure, walks the resulting issues and coerces JSON-stringified + * values (`"[1,2]"` → `[1,2]`), drops unrecognized keys, and retries up + * to `MAX_COERCION_PASSES` times. + * 5. Throws a formatted error if reconciliation fails; otherwise returns + * the parsed arguments with original unknown root fields preserved (so + * hallucinated top-level keys still surface to the caller). + * + * The goal is to be conservative: every coercion is a structural rewrite that + * keeps the schema in charge of acceptance — we never invent values, only + * massage shapes the LLM almost got right. + */ import { structuredCloneJSON } from "@oh-my-pi/pi-utils"; import type { ZodType } from "zod/v4"; import type { $ZodIssue as ZodIssue } from "zod/v4/core"; import type { Tool, ToolCall } from "../types"; +import { upgradeJsonSchemaTo202012 } from "./schema/draft"; import { isJsonSchemaValueValid, type JsonSchemaValidationIssue, @@ -855,7 +880,7 @@ function getValidationContext(tool: Tool): ValidationContext { if (isZodSchema(params)) { ctx = { kind: "zod", zod: params, json: zodToWireSchema(params) }; } else { - ctx = { kind: "json", json: params as unknown as Record }; + ctx = { kind: "json", json: upgradeJsonSchemaTo202012(params) as Record }; } validationContextCache.set(params, ctx); return ctx; diff --git a/packages/ai/test/schema-normalization.test.ts b/packages/ai/test/schema-normalization.test.ts index ad13c85a3..ec0b7fce2 100644 --- a/packages/ai/test/schema-normalization.test.ts +++ b/packages/ai/test/schema-normalization.test.ts @@ -6,8 +6,10 @@ import { sanitizeSchemaForCCA, sanitizeSchemaForGoogle, sanitizeSchemaForStrictMode, + schemaNeedsDraft202012Upgrade, stripResidualCombiners, tryEnforceStrictSchema, + upgradeJsonSchemaTo202012, } from "@oh-my-pi/pi-ai/utils/schema"; // --------------------------------------------------------------------------- @@ -94,6 +96,64 @@ describe("sanitizeSchemaForStrictMode", () => { }); }); +// --------------------------------------------------------------------------- +// upgradeJsonSchemaTo202012 +// --------------------------------------------------------------------------- + +describe("upgradeJsonSchemaTo202012", () => { + it("infers draft-07 tuple and dependency keywords without a $schema URI", () => { + const schema = { + type: "object", + properties: { + definitions: { type: "string" }, + tuple: { + type: "array", + items: [{ type: "string" }, { type: "integer" }], + additionalItems: false, + }, + gated: { + type: "object", + dependencies: { + a: ["b"], + c: { required: ["d"] }, + }, + }, + }, + definitions: { + Ref: { type: "string" }, + }, + }; + + expect(schemaNeedsDraft202012Upgrade(schema)).toBe(true); + expect(upgradeJsonSchemaTo202012(schema)).toEqual({ + type: "object", + properties: { + definitions: { type: "string" }, + tuple: { + type: "array", + prefixItems: [{ type: "string" }, { type: "integer" }], + items: false, + }, + gated: { + type: "object", + dependentRequired: { a: ["b"] }, + dependentSchemas: { c: { required: ["d"] } }, + }, + }, + $defs: { + Ref: { type: "string" }, + }, + }); + }); + + it("returns unchanged schemas by identity when no draft upgrade is needed", () => { + const schema = { type: "object", properties: { name: { type: "string" } } }; + + expect(schemaNeedsDraft202012Upgrade(schema)).toBe(false); + expect(upgradeJsonSchemaTo202012(schema)).toBe(schema); + }); +}); + // --------------------------------------------------------------------------- // sanitizeSchemaForGoogle // --------------------------------------------------------------------------- diff --git a/packages/ai/test/tool-argument-coercion.test.ts b/packages/ai/test/tool-argument-coercion.test.ts index 3cbf54fe4..9429f7025 100644 --- a/packages/ai/test/tool-argument-coercion.test.ts +++ b/packages/ai/test/tool-argument-coercion.test.ts @@ -112,19 +112,23 @@ describe("Tool argument coercion", () => { expect(result).toEqual({ env: { FOO: "bar" } }); }); - it("validates JSON Schema without converting it through Zod", () => { + it("upgrades draft-07-shaped JSON Schema without $schema before validation", () => { const tool: Tool = { name: "json_schema", description: "", parameters: { type: "object", properties: { - item: { $ref: "#/$defs/Item" }, - name: { type: ["string", "null"] }, - ids: { type: "array", items: { type: "string" }, uniqueItems: true }, + item: { $ref: "#/definitions/Item" }, + name: { type: "string", nullable: true }, + pair: { + type: "array", + items: [{ type: "string" }, { type: "integer" }], + additionalItems: false, + }, }, - required: ["item", "name", "ids"], - $defs: { + required: ["item", "name", "pair"], + definitions: { Item: { type: "string" }, }, }, @@ -134,18 +138,27 @@ describe("Tool argument coercion", () => { type: "toolCall", id: "call-json-ok", name: "json_schema", - arguments: { item: "ok", name: null, ids: ["a", "b"] }, + arguments: { item: "ok", name: null, pair: ["a", 1] }, }); - expect(valid).toEqual({ item: "ok", name: null, ids: ["a", "b"] }); + expect(valid).toEqual({ item: "ok", name: null, pair: ["a", 1] }); expect(() => validateToolArguments(tool, { type: "toolCall", id: "call-json-bad", name: "json_schema", - arguments: { item: "ok", name: null, ids: ["a", "a"] }, + arguments: { item: "ok", name: null, pair: ["a", "not-an-integer"] }, }), - ).toThrow("unique"); + ).toThrow("integer"); + + expect(() => + validateToolArguments(tool, { + type: "toolCall", + id: "call-json-extra", + name: "json_schema", + arguments: { item: "ok", name: null, pair: ["a", 1, "extra"] }, + }), + ).toThrow("false schema"); }); it("parses nested JSON arrays in string values", () => { diff --git a/packages/coding-agent/src/hashline/parser.ts b/packages/coding-agent/src/hashline/parser.ts index 04b51ec94..e0ed8fc33 100644 --- a/packages/coding-agent/src/hashline/parser.ts +++ b/packages/coding-agent/src/hashline/parser.ts @@ -102,8 +102,7 @@ function collectPayload( while (lookahead < lines.length && lines[lookahead].length === 0) { lookahead++; } - const followedByPayload = - lookahead < lines.length && lines[lookahead].startsWith(HL_EDIT_SEP); + const followedByPayload = lookahead < lines.length && lines[lookahead].startsWith(HL_EDIT_SEP); const acceptBareBlank = requirePayload && payload.length === 0; if (followedByPayload || acceptBareBlank) { for (let j = index; j < lookahead; j++) payload.push("");