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.
145 changes: 144 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 = 24;
const MAX_SCHEMA_REF_NODES = 10_000;
const MAX_SCHEMA_REF_BYTES = 256 * 1024;

interface GoogleFunctionCall {
id?: string;
name: string;
Expand Down Expand Up @@ -394,6 +398,141 @@ 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>;
const hasRef = (value: unknown) => {
const pending = [value];
const seen = new WeakSet<object>();
while (pending.length > 0) {
const entry = pending.pop();
if (!entry || typeof entry !== "object" || seen.has(entry)) continue;
seen.add(entry);
if (
Object.prototype.hasOwnProperty.call(entry, "$ref") &&
typeof (entry as Record<string, unknown>).$ref === "string"
) {
return true;
}
pending.push(...Object.values(entry));
}
return false;
};
if (!hasRef(schema)) return schema;

const pointer = (ref: string) => {
if (!ref.startsWith("#/") && ref !== "#") {
throw new Error(`Antigravity tool schema has unsupported external $ref: ${ref}`);
}
let value: unknown = root;
for (const segment of ref === "#" ? [] : ref.slice(2).split("/")) {
const key = segment.replaceAll("~1", "/").replaceAll("~0", "~");
if (
!value ||
typeof value !== "object" ||
!Object.prototype.hasOwnProperty.call(value, key)
) {
throw new Error(`Antigravity tool schema has unresolved $ref: ${ref}`);
}
value = (value as Record<string, unknown>)[key];
}
return value;
};
const assertDepth = (depth: number) => {
if (depth > MAX_SCHEMA_REF_DEPTH) {
throw new Error("Antigravity tool schema exceeds local $ref expansion limits");
}
};
const siblingsOf = (object: Record<string, unknown>) =>
Object.entries(object).filter(([key]) => key !== "$ref");

// Count the JSON that expansion will emit before cloning any referenced tree.
let bytes = 0;
const append = (text: string) => {
bytes += Buffer.byteLength(text);
if (bytes > MAX_SCHEMA_REF_BYTES) {
throw new Error("Antigravity tool schema exceeds local $ref expansion byte limit");
}
};
const measure = (value: unknown, depth: number, active: Set<string>): void => {
assertDepth(depth);
if (value === null || typeof value !== "object") {
append(JSON.stringify(value) ?? "null");
return;
}
if (Array.isArray(value)) {
append("[");
value.forEach((entry, index) => {
if (index > 0) append(",");
measure(entry, depth + 1, active);
});
append("]");
return;
}
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 siblings = siblingsOf(object);
if (siblings.length === 0) measure(pointer(ref), depth + 1, active);
else {
append('{"allOf":[');
measure(pointer(ref), depth + 1, active);
append(",{");
siblings.forEach(([key, entry], index) => {
if (index > 0) append(",");
append(`${JSON.stringify(key)}:`);
measure(entry, depth + 2, active);
});
append("}]}");
}
active.delete(ref);
return;
}
append("{");
Object.entries(object).forEach(([key, entry], index) => {
if (index > 0) append(",");
append(`${JSON.stringify(key)}:`);
measure(entry, depth + 1, active);
});
append("}");
};
measure(schema, 0, new Set());

let nodes = 0;
const active = new Set<string>();
const visit = (value: unknown, depth: number): unknown => {
assertDepth(depth);
if (++nodes > MAX_SCHEMA_REF_NODES) {
throw new Error("Antigravity tool schema exceeds local $ref expansion limits");
}
if (Array.isArray(value)) return value.map((entry) => visit(entry, depth + 1));
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);
try {
const target = visit(pointer(ref), depth + 1);
const siblings = Object.fromEntries(
siblingsOf(object).map(([key, entry]) => [key, visit(entry, depth + 2)]),
);
return Object.keys(siblings).length === 0 ? target : { allOf: [target, siblings] };
} finally {
active.delete(ref);
}
}
return Object.fromEntries(
Object.entries(object).map(([key, entry]) => [key, visit(entry, depth + 1)]),
);
};
return visit(schema, 0);
}

function sanitizeForOpenApi(
schema: unknown,
insidePropertiesMap = false,
Expand Down Expand Up @@ -425,7 +564,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
196 changes: 196 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,201 @@ 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 counts each expanded ref subtree once", () => {
const fields = Object.fromEntries(
Array.from({ length: 60 }, (_, index) => [
`field${index}`,
{ type: "string" },
]),
);
const declarations = convertTools(
[
{
name: "structured_output",
description: "Return the result",
parameters: {
type: "object",
properties: {
first: { $ref: "#/$defs/Result" },
second: { $ref: "#/$defs/Result" },
},
$defs: {
Result: { type: "object", properties: fields },
},
},
} as never,
],
true,
);
const parameters = declarations?.[0]?.functionDeclarations[0]?.parameters as {
properties?: Record<string, { properties?: Record<string, unknown> }>;
};
assert.deepEqual(parameters.properties?.first, parameters.properties?.second);
assert.equal(
parameters.properties?.first?.properties?.field59 !== undefined,
true,
);
});

test("convertTools bounds the serialized expanded schema size", () => {
assert.throws(
() =>
convertTools(
[
{
name: "oversized",
description: "oversized",
parameters: {
type: "object",
properties: { value: { $ref: "#/$defs/Value" } },
$defs: {
Value: { type: "string", description: "x".repeat(256 * 1024) },
},
},
} as never,
],
true,
),
/expansion byte limit/,
);
});

test("convertTools preserves ref sibling conjunctions and boolean schemas", () => {
const declarations = convertTools(
[
{
name: "conjunction",
description: "conjunction",
parameters: {
type: "object",
properties: {
value: {
$ref: "#/$defs/Value",
properties: { extra: { type: "string" } },
},
denied: { $ref: "#/$defs/Never" },
},
$defs: {
Value: { type: "object", properties: { required: { type: "string" } } },
Never: false,
},
},
} as never,
],
true,
);
const parameters = declarations?.[0]?.functionDeclarations[0]?.parameters as {
properties?: Record<string, unknown>;
};
assert.deepEqual(parameters.properties?.value, {
allOf: [
{ type: "object", properties: { required: { type: "string" } } },
{ properties: { extra: { type: "string" } } },
],
});
assert.equal(parameters.properties?.denied, false);
});

test("convertTools keeps ordinary no-ref schemas outside ref expansion limits", () => {
const properties = Object.fromEntries(
Array.from({ length: 600 }, (_, index) => [`field${index}`, { type: "string" }]),
);
assert.doesNotThrow(() =>
convertTools(
[{ name: "ordinary", description: "ordinary", parameters: { type: "object", properties } } as never],
true,
),
);
});

test("convertTools rejects inherited local ref targets", () => {
assert.throws(
() =>
convertTools(
[
{
name: "inherited",
description: "inherited",
parameters: { $defs: {}, $ref: "#/$defs/toString" },
} as never,
],
true,
),
/unresolved \$ref/,
);
});

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