From d06dcfe7c5dceb460b418e06036d8142a8cf8d8b Mon Sep 17 00:00:00 2001 From: sunss Date: Thu, 22 Oct 2026 11:22:54 +0800 Subject: [PATCH 1/2] fix(ai-providers): rewrite const schema literals to enum for Cloud Code Assist TypeBox serializes Type.Union([Type.Literal(...)]) tool parameters as anyOf branches carrying "const". Cloud Code Assist validates function declarations against OpenAPI 3.0, which has no "const", so every request from a session with such a tool 400s with "Unknown name \"const\" at ...parameters.properties[N].value.any_of[M]" (e.g. subagent_check.evidence since #652; setup's role/browser unions are affected too). enum-carrying parameters in the same payload pass, so rewrite const to a single-value enum in sanitizeSchemaForCca instead of dropping the constraint. Verified live: pi -ne -a -e ai-providers -e subagents against google-antigravity/gemini-3.8-flash-high 400s before, replies after the rewrite; broader load with workflows+setup passes as well. Unit tests cover anyOf-branch literals, enum merging, and a property named const. --- .../ai-providers/antigravity/provider.ts | 20 ++++++ .../ai-providers/antigravity.test.ts | 68 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/extensions/ai-providers/antigravity/provider.ts b/extensions/ai-providers/antigravity/provider.ts index 9c5fefae7..3ab9a6748 100644 --- a/extensions/ai-providers/antigravity/provider.ts +++ b/extensions/ai-providers/antigravity/provider.ts @@ -148,6 +148,8 @@ function sanitizeSchemaForCcaValue( if (value === null || typeof value !== "object") return value; const out: Record = {}; const spill: Array<[string, unknown]> = []; + let constEntry: unknown; + let hasConst = false; for (const [key, entry] of Object.entries(value)) { // Keys below `properties` are user-defined parameter names, not JSON // Schema keywords. A tool parameter named `pattern`, for example, must be @@ -156,6 +158,11 @@ function sanitizeSchemaForCcaValue( out[key] = sanitizeSchemaForCcaValue(entry, false); continue; } + if (key === "const") { + hasConst = true; + constEntry = entry; + continue; + } if (Object.hasOwn(CCA_UNSUPPORTED_SCHEMA_FIELDS, key)) { if ( entry !== undefined && @@ -167,6 +174,19 @@ function sanitizeSchemaForCcaValue( } out[key] = sanitizeSchemaForCcaValue(entry, key === "properties"); } + if (hasConst) { + // OpenAPI 3.0 — the schema dialect CCA validates against — has no + // "const"; TypeBox literal unions (`anyOf` of `{type, const}`) 400 the + // whole request. Rewrite the literal as a single-value enum, which CCA + // accepts (enum-carrying parameters in the same request pass). + const enumValues = Array.isArray(out.enum) + ? [...(out.enum as unknown[])] + : []; + if (!enumValues.includes(constEntry)) { + enumValues.push(constEntry); + } + out.enum = enumValues; + } if (spill.length > 0) { const formatted = `{${spill.map(([key, entry]) => `${key}: ${JSON.stringify(entry)}`).join(", ")}}`; const existing = typeof out.description === "string" ? out.description : ""; diff --git a/tests/extensions/ai-providers/antigravity.test.ts b/tests/extensions/ai-providers/antigravity.test.ts index ee74a90b0..4833746b0 100644 --- a/tests/extensions/ai-providers/antigravity.test.ts +++ b/tests/extensions/ai-providers/antigravity.test.ts @@ -555,6 +555,74 @@ test("sanitizeSchemaForCca preserves property names that match schema keywords", ); }); +test("sanitizeSchemaForCca rewrites const literals to enum inside anyOf branches", () => { + // TypeBox serializes Type.Union([Type.Literal(...)]) this way; CCA 400s the + // whole request on "const" while accepting enum in the same payload. + assert.deepEqual( + sanitizeSchemaForCca({ + type: "object", + properties: { + evidence: { + description: 'Evidence to read: "transcript", "final", "structured".', + anyOf: [ + { type: "string", const: "transcript" }, + { type: "string", const: "final" }, + { type: "string", const: "structured" }, + ], + }, + }, + }), + { + type: "object", + properties: { + evidence: { + description: 'Evidence to read: "transcript", "final", "structured".', + anyOf: [ + { type: "string", enum: ["transcript"] }, + { type: "string", enum: ["final"] }, + { type: "string", enum: ["structured"] }, + ], + }, + }, + }, + ); +}); + +test("sanitizeSchemaForCca merges a bare const into enum without duplicates", () => { + assert.deepEqual( + sanitizeSchemaForCca({ type: "string", const: "chromium" }), + { + type: "string", + enum: ["chromium"], + }, + ); + assert.deepEqual( + sanitizeSchemaForCca({ + type: "string", + const: "chrome", + enum: ["chrome", "edge"], + }), + { type: "string", enum: ["chrome", "edge"] }, + ); +}); + +test("sanitizeSchemaForCca preserves a property named const", () => { + assert.deepEqual( + sanitizeSchemaForCca({ + type: "object", + properties: { + const: { type: "string", description: "Value to pin" }, + }, + }), + { + type: "object", + properties: { + const: { type: "string", description: "Value to pin" }, + }, + }, + ); +}); + test("buildRequestBody strips CCA-rejected keywords and spills constraints into description", () => { const contextWithTools = { ...SIMPLE_CONTEXT, From 7e18c1f9e76db59b377a544d74652657bed56b75 Mon Sep 17 00:00:00 2001 From: sunss Date: Thu, 22 Oct 2026 14:32:56 +0800 Subject: [PATCH 2/2] fix(ai-providers): harden CCA schema sanitization (omp-aligned) Align sanitizeSchemaForCca with oh-my-pi's current schema pipeline (packages/ai/src/utils/schema, MIT) for the same wire path: - Collapse all-literal combinators (anyOf/oneOf/allOf where every variant carries const, e.g. TypeBox Type.Union([Type.Literal(...)])) into a flat enum with a single inferred type, instead of anyOf-of-singleton-enums. - Infer the type of a bare const node from its value. - Sync nine missing Google-dialect denylist entries from omp fields.ts: $id, $anchor, contains, minContains, maxContains, additionalItems, contentEncoding, contentMediaType, contentSchema. - Add a final allowlist pass: only keys CCA's parameters dialect has accepted in practice (type/description/enum/items/properties/required/ anyOf/oneOf/allOf) reach the wire; type arrays are scalarized. A novel schema keyword now degrades the advertised schema (fail-soft) instead of 400ing every request, mirroring omp's validateAndFallback. Verified live against google-antigravity/gemini-3.8-flash-high with subagents + workflows + setup loaded: plain reply OK, wide smoke OK, and a real subagent_check tool round-trip through the collapsed enum. Unit tests 68/68. --- .../ai-providers/antigravity/provider.ts | 150 ++++++++++++++++-- .../ai-providers/antigravity.test.ts | 140 +++++++++++++++- 2 files changed, 275 insertions(+), 15 deletions(-) diff --git a/extensions/ai-providers/antigravity/provider.ts b/extensions/ai-providers/antigravity/provider.ts index 3ab9a6748..80e742788 100644 --- a/extensions/ai-providers/antigravity/provider.ts +++ b/extensions/ai-providers/antigravity/provider.ts @@ -82,6 +82,8 @@ const MAX_SESSION_STATES = 64; // model are serialized into the sibling description before being removed. const CCA_UNSUPPORTED_SCHEMA_FIELDS: Record = { $schema: true, + $id: true, + $anchor: true, $ref: true, $defs: true, $dynamicRef: true, @@ -96,6 +98,11 @@ const CCA_UNSUPPORTED_SCHEMA_FIELDS: Record = { propertyNames: true, minItems: true, maxItems: true, + uniqueItems: true, + contains: true, + minContains: true, + maxContains: true, + additionalItems: true, minLength: true, maxLength: true, minProperties: true, @@ -105,9 +112,11 @@ const CCA_UNSUPPORTED_SCHEMA_FIELDS: Record = { exclusiveMinimum: true, exclusiveMaximum: true, multipleOf: true, - uniqueItems: true, pattern: true, format: true, + contentEncoding: true, + contentMediaType: true, + contentSchema: true, default: true, deprecated: true, readOnly: true, @@ -138,6 +147,39 @@ const CCA_LIFTABLE_TO_DESCRIPTION: Record = { examples: true, }; +function isJsonObjectWithConst( + value: unknown, +): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + "const" in value + ); +} + +function inferSchemaTypeFromValue(value: unknown): string | undefined { + if (typeof value === "string") return "string"; + if (typeof value === "number") return "number"; + if (typeof value === "boolean") return "boolean"; + return undefined; +} + +function mergeEnumValues( + out: Record, + values: unknown[], +): void { + const enumValues = Array.isArray(out.enum) + ? [...(out.enum as unknown[])] + : []; + for (const value of values) { + if (!enumValues.includes(value)) { + enumValues.push(value); + } + } + out.enum = enumValues; +} + function sanitizeSchemaForCcaValue( value: unknown, insidePropertiesMap: boolean, @@ -146,11 +188,46 @@ function sanitizeSchemaForCcaValue( return value.map((entry) => sanitizeSchemaForCcaValue(entry, false)); } if (value === null || typeof value !== "object") return value; + const source = value as Record; + + // All-literal combinators — every variant carries `const`, e.g. TypeBox's + // Type.Union([Type.Literal(...)]) — collapse into a flat enum before the + // keyword pass (omp normalize.ts "allHaveConst"). Collapsing an allOf union + // widens acceptance (union instead of intersection), which is sound for an + // advertised tool schema: the model may only pick from a superset. + let collapsedCombiner: "anyOf" | "oneOf" | "allOf" | undefined; + let collapsedEnum: unknown[] | undefined; + let collapsedType: string | undefined; + for (const combiner of ["anyOf", "oneOf", "allOf"] as const) { + const variants = source[combiner]; + if (!Array.isArray(variants) || variants.length === 0) continue; + if (!variants.every(isJsonObjectWithConst)) continue; + const enumValues: unknown[] = []; + const variantTypes = new Set(); + for (const variant of variants) { + if (!enumValues.includes(variant.const)) { + enumValues.push(variant.const); + } + if (typeof variant.type === "string") { + variantTypes.add(variant.type); + } + } + collapsedCombiner = combiner; + collapsedEnum = enumValues; + if (variantTypes.size === 1) { + collapsedType = [...variantTypes][0]; + } else if (variantTypes.size === 0) { + const inferred = new Set(enumValues.map(inferSchemaTypeFromValue)); + if (inferred.size === 1) collapsedType = [...inferred][0]; + } + break; + } + const out: Record = {}; const spill: Array<[string, unknown]> = []; let constEntry: unknown; let hasConst = false; - for (const [key, entry] of Object.entries(value)) { + for (const [key, entry] of Object.entries(source)) { // Keys below `properties` are user-defined parameter names, not JSON // Schema keywords. A tool parameter named `pattern`, for example, must be // preserved while its schema value is sanitized normally. @@ -158,6 +235,7 @@ function sanitizeSchemaForCcaValue( out[key] = sanitizeSchemaForCcaValue(entry, false); continue; } + if (collapsedCombiner === key) continue; if (key === "const") { hasConst = true; constEntry = entry; @@ -179,13 +257,16 @@ function sanitizeSchemaForCcaValue( // "const"; TypeBox literal unions (`anyOf` of `{type, const}`) 400 the // whole request. Rewrite the literal as a single-value enum, which CCA // accepts (enum-carrying parameters in the same request pass). - const enumValues = Array.isArray(out.enum) - ? [...(out.enum as unknown[])] - : []; - if (!enumValues.includes(constEntry)) { - enumValues.push(constEntry); + mergeEnumValues(out, [constEntry]); + if (out.type === undefined) { + out.type = inferSchemaTypeFromValue(constEntry); + } + } + if (collapsedEnum) { + mergeEnumValues(out, collapsedEnum); + if (out.type === undefined && collapsedType !== undefined) { + out.type = collapsedType; } - out.enum = enumValues; } if (spill.length > 0) { const formatted = `{${spill.map(([key, entry]) => `${key}: ${JSON.stringify(entry)}`).join(", ")}}`; @@ -195,9 +276,58 @@ function sanitizeSchemaForCcaValue( return out; } -/** Recursively drop schema keywords Cloud Code Assist rejects. */ +// The keys CCA's `parameters` dialect has accepted in practice — omp's +// Google/CCA dialect ships all of these through the same endpoint. Anything +// unknown that survives the denylist pass above is dropped by this final +// allowlist pass, so a novel schema keyword degrades the advertised schema +// instead of 400ing every request (fail-soft, like omp's validateAndFallback). +const CCA_WIRE_ALLOWED_KEYS: Record = { + type: true, + title: true, + description: true, + enum: true, + items: true, + properties: true, + required: true, + anyOf: true, + oneOf: true, + allOf: true, +}; + +function stripNonWireKeys( + value: unknown, + insidePropertiesMap: boolean, +): unknown { + if (Array.isArray(value)) { + return value.map((entry) => stripNonWireKeys(entry, false)); + } + if (value === null || typeof value !== "object") return value; + const out: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (insidePropertiesMap) { + out[key] = stripNonWireKeys(entry, false); + continue; + } + if (!Object.hasOwn(CCA_WIRE_ALLOWED_KEYS, key)) continue; + if (key === "type" && Array.isArray(entry)) { + // OpenAPI type is single-valued; keep the first non-null member. A + // null-only type array carries no OpenAPI type at all. + const scalar = entry.find((t) => t !== "null"); + if (typeof scalar === "string") out.type = scalar; + continue; + } + out[key] = stripNonWireKeys(entry, key === "properties"); + } + return out; +} + +/** + * Project a tool schema onto the dialect CCA's `parameters` field accepts: + * rewrite `const` literals as enum, collapse all-literal combinators into a + * flat enum, strip/lift known-rejected keywords, then keep only wire keys. + */ export function sanitizeSchemaForCca(value: unknown): unknown { - return sanitizeSchemaForCcaValue(value, false); + return stripNonWireKeys(sanitizeSchemaForCcaValue(value, false), false); } // --------------------------------------------------------------------------- diff --git a/tests/extensions/ai-providers/antigravity.test.ts b/tests/extensions/ai-providers/antigravity.test.ts index 4833746b0..4aed73aac 100644 --- a/tests/extensions/ai-providers/antigravity.test.ts +++ b/tests/extensions/ai-providers/antigravity.test.ts @@ -555,9 +555,11 @@ test("sanitizeSchemaForCca preserves property names that match schema keywords", ); }); -test("sanitizeSchemaForCca rewrites const literals to enum inside anyOf branches", () => { +test("sanitizeSchemaForCca collapses all-literal combinators into a flat enum", () => { // TypeBox serializes Type.Union([Type.Literal(...)]) this way; CCA 400s the - // whole request on "const" while accepting enum in the same payload. + // whole request on "const" while accepting enum in the same payload. The + // combinator collapses (omp normalize.ts "allHaveConst") so the model sees + // one enum instead of anyOf-of-singleton-enums. assert.deepEqual( sanitizeSchemaForCca({ type: "object", @@ -577,10 +579,53 @@ test("sanitizeSchemaForCca rewrites const literals to enum inside anyOf branches properties: { evidence: { description: 'Evidence to read: "transcript", "final", "structured".', + type: "string", + enum: ["transcript", "final", "structured"], + }, + }, + }, + ); + // Literals without an explicit type still collapse, inferring "string". + assert.deepEqual( + sanitizeSchemaForCca({ + oneOf: [{ const: "a" }, { const: "b" }], + }), + { type: "string", enum: ["a", "b"] }, + ); + // allOf of literals is degenerate as an intersection; widening it to a + // union (flat enum) only broadens what the model may pick. + assert.deepEqual( + sanitizeSchemaForCca({ + allOf: [ + { type: "string", const: "a" }, + { type: "string", const: "b" }, + ], + }), + { type: "string", enum: ["a", "b"] }, + ); +}); + +test("sanitizeSchemaForCca keeps mixed-type combinators as anyOf", () => { + // Not every variant carries `const` — the union must stay a union. + assert.deepEqual( + sanitizeSchemaForCca({ + type: "object", + properties: { + exclude: { + anyOf: [ + { type: "string" }, + { type: "array", items: { type: "string" } }, + ], + }, + }, + }), + { + type: "object", + properties: { + exclude: { anyOf: [ - { type: "string", enum: ["transcript"] }, - { type: "string", enum: ["final"] }, - { type: "string", enum: ["structured"] }, + { type: "string" }, + { type: "array", items: { type: "string" } }, ], }, }, @@ -596,6 +641,11 @@ test("sanitizeSchemaForCca merges a bare const into enum without duplicates", () enum: ["chromium"], }, ); + // A bare const infers its type when the node has none. + assert.deepEqual(sanitizeSchemaForCca({ const: "chromium" }), { + type: "string", + enum: ["chromium"], + }); assert.deepEqual( sanitizeSchemaForCca({ type: "string", @@ -623,6 +673,72 @@ test("sanitizeSchemaForCca preserves a property named const", () => { ); }); +test("sanitizeSchemaForCca strips omp-synced keywords (contains, additionalItems, $id)", () => { + assert.deepEqual( + sanitizeSchemaForCca({ + type: "array", + items: { type: "string" }, + contains: { type: "string" }, + minContains: 1, + additionalItems: { type: "string" }, + contentEncoding: "base64", + $id: "https://example.com/schema", + }), + { type: "array", items: { type: "string" } }, + ); +}); + +test("sanitizeSchemaForCca drops unknown keywords as a fail-soft final gate", () => { + // `title` rides omp's Google/CCA dialect through the same endpoint daily, + // so the allowlist keeps it. `if`/`then`/`nullable` have no such evidence: + // the gate drops them instead of letting an unknown keyword 400 the + // session. Structure survives. + assert.deepEqual( + sanitizeSchemaForCca({ + type: "object", + title: "Files to write", + if: { type: "object" }, + // biome-ignore lint/suspicious/noThenProperty: JSON Schema keyword under test + then: { type: "object" }, + properties: { + path: { type: "string", title: "Path", nullable: true }, + }, + required: ["path"], + }), + { + type: "object", + title: "Files to write", + properties: { + path: { type: "string", title: "Path" }, + }, + required: ["path"], + }, + ); +}); + +test("sanitizeSchemaForCca scalarizes type arrays", () => { + assert.deepEqual(sanitizeSchemaForCca({ type: ["string", "null"] }), { + type: "string", + }); +}); + +test("sanitizeSchemaForCca preserves a property named anyOf", () => { + assert.deepEqual( + sanitizeSchemaForCca({ + type: "object", + properties: { + anyOf: { type: "string", description: "Coin flip" }, + }, + }), + { + type: "object", + properties: { + anyOf: { type: "string", description: "Coin flip" }, + }, + }, + ); +}); + test("buildRequestBody strips CCA-rejected keywords and spills constraints into description", () => { const contextWithTools = { ...SIMPLE_CONTEXT, @@ -642,6 +758,15 @@ test("buildRequestBody strips CCA-rejected keywords and spills constraints into mode: { type: "string", readOnly: false }, pattern: { type: "string", description: "Search pattern" }, $id: { type: "string", description: "User-defined property" }, + // TypeBox Type.Union([Type.Literal(...)]) shape: must reach the + // wire as a flat enum, never as anyOf branches carrying const. + evidence: { + description: 'Evidence to read: "transcript", "final".', + anyOf: [ + { type: "string", const: "transcript" }, + { type: "string", const: "final" }, + ], + }, }, }, }, @@ -677,6 +802,11 @@ test("buildRequestBody strips CCA-rejected keywords and spills constraints into type: "string", description: "User-defined property", }); + assert.deepEqual(parameters.properties.evidence, { + type: "string", + description: 'Evidence to read: "transcript", "final".', + enum: ["transcript", "final"], + }); }); // --- request envelope ------------------------------------------------------