Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/design/ANTIGRAVITY_LOCAL_SCHEMA_REFS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Antigravity Local Schema References

Status: validated
Created: 2026-09-08
Verified: 2026-09-08
Issue: [#465](https://github.com/openpi-dev/openpi/issues/465)

The Antigravity boundary expands only same-document JSON Pointer references
before applying Cloud Code Assist's unsupported-keyword sanitizer. External,
unresolved, recursive, or over-limit references fail before a model request.
Expansion is bounded by depth, nodes, and serialized bytes. The original Pi
schema remains the authority for local validation; this only preserves its
meaning in the provider declaration.

The ablation is explicit: removing expansion reproduces the original empty
`{}` property for a `$ref` result contract, while allowing references without
bounds could make provider preparation unbounded. Both the expansion and
limits are retained.

Validation: `node --test --experimental-strip-types tests/extensions/ai-providers/antigravity.test.ts` (39/39) and `bun run check` passed.
84 changes: 83 additions & 1 deletion extensions/ai-providers/antigravity/google-conversion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ const JSON_SCHEMA_META_DECLARATIONS = new Set([
"definitions",
]);

const MAX_SCHEMA_REF_DEPTH = 16;
const MAX_SCHEMA_REF_NODES = 512;
const MAX_SCHEMA_REF_BYTES = 256 * 1024;

interface GoogleFunctionCall {
id?: string;
name: string;
Expand Down Expand Up @@ -394,6 +398,80 @@ export function convertMessages(
return contents;
}

function expandLocalSchemaRefs(schema: unknown): unknown {
if (typeof schema !== "object" || schema === null || Array.isArray(schema)) {
return schema;
}
const root = schema as Record<string, unknown>;
let nodes = 0;
let bytes = 0;
const active = new Set<string>();

const pointer = (ref: string) => {
if (!ref.startsWith("#/") && ref !== "#") {
throw new Error(
`Antigravity tool schema has unsupported external $ref: ${ref}`,
);
}
let value: unknown = root;
if (ref !== "#") {
for (const segment of ref.slice(2).split("/")) {
if (value === null || typeof value !== "object") value = undefined;
else
value = (value as Record<string, unknown>)[
segment.replaceAll("~1", "/").replaceAll("~0", "~")
];
}
}
if (value === undefined)
throw new Error(`Antigravity tool schema has unresolved $ref: ${ref}`);
return value;
};

const visit = (value: unknown, depth: number, path: string): unknown => {
if (++nodes > MAX_SCHEMA_REF_NODES || depth > MAX_SCHEMA_REF_DEPTH) {
throw new Error(
"Antigravity tool schema exceeds local $ref expansion limits",
);
}
bytes += Buffer.byteLength(JSON.stringify(value) ?? "");
if (bytes > MAX_SCHEMA_REF_BYTES) {
throw new Error(
"Antigravity tool schema exceeds local $ref expansion byte limit",
);
}
if (Array.isArray(value))
return value.map((entry, index) =>
visit(entry, depth + 1, `${path}/${index}`),
);
if (value === null || typeof value !== "object") return value;
const object = value as Record<string, unknown>;
if (typeof object.$ref === "string") {
const ref = object.$ref;
if (active.has(ref))
throw new Error(`Antigravity tool schema has recursive $ref: ${ref}`);
active.add(ref);
const target = visit(pointer(ref), depth + 1, ref) as Record<
string,
unknown
>;
active.delete(ref);
const siblings = Object.fromEntries(
Object.entries(object).filter(([key]) => key !== "$ref"),
);
return visit({ ...target, ...siblings }, depth + 1, path);
}
return Object.fromEntries(
Object.entries(object).map(([key, entry]) => [
key,
visit(entry, depth + 1, `${path}/${key}`),
]),
);
};

return visit(schema, 0, "#");
}

function sanitizeForOpenApi(
schema: unknown,
insidePropertiesMap = false,
Expand Down Expand Up @@ -425,7 +503,11 @@ export function convertTools(
name: tool.name,
description: tool.description,
...(useParameters
? { parameters: sanitizeForOpenApi(tool.parameters) }
? {
parameters: sanitizeForOpenApi(
expandLocalSchemaRefs(tool.parameters),
),
}
: { parametersJsonSchema: tool.parameters }),
})),
},
Expand Down
72 changes: 72 additions & 0 deletions tests/extensions/ai-providers/antigravity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import { fetchAntigravityModels } from "../../../extensions/ai-providers/antigravity/discovery.ts";
import {
convertMessages,
convertTools,
isThinkingPart,
mapStopReasonString,
retainThoughtSignature,
Expand Down Expand Up @@ -442,6 +443,77 @@ test("sanitizeSchemaForCca preserves property names that match schema keywords",
);
});

test("convertTools expands bounded local refs before CCA sanitization", () => {
const declarations = convertTools(
[
{
name: "structured_output",
description: "Return the result",
parameters: {
type: "object",
properties: { answer: { $ref: "#/$defs/Answer" } },
required: ["answer"],
$defs: {
Answer: {
type: "object",
properties: {
verdict: { type: "string", enum: ["pass", "fail"] },
},
required: ["verdict"],
},
},
},
} as never,
],
true,
);
assert.deepEqual(declarations?.[0]?.functionDeclarations[0]?.parameters, {
type: "object",
properties: {
answer: {
type: "object",
properties: { verdict: { type: "string", enum: ["pass", "fail"] } },
required: ["verdict"],
},
},
required: ["answer"],
});
});

test("convertTools rejects unsafe local refs before the request", () => {
assert.throws(
() =>
convertTools(
[
{
name: "bad",
description: "bad",
parameters: { $ref: "https://example.test/schema" },
} as never,
],
true,
),
/unsupported external \$ref/,
);
assert.throws(
() =>
convertTools(
[
{
name: "loop",
description: "loop",
parameters: {
$defs: { Node: { $ref: "#/$defs/Node" } },
$ref: "#/$defs/Node",
},
} as never,
],
true,
),
/recursive \$ref/,
);
});

test("buildRequestBody strips CCA-rejected keywords and spills constraints into description", () => {
const contextWithTools = {
...SIMPLE_CONTEXT,
Expand Down
Loading