Skip to content
Merged
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
73 changes: 71 additions & 2 deletions src/adapters/google-antigravity-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,10 +512,10 @@ export function antigravityReplaySessionKeysForTests(): string[] {

function extractSignature(part: Record<string, unknown>): string | undefined {
const direct = part.thoughtSignature ?? part.thought_signature;
if (typeof direct === "string" && direct.length >= MIN_SIGNATURE_LEN) return direct;
if (typeof direct === "string" && direct.length >= MIN_SIGNATURE_LEN && direct !== THOUGHT_SIGNATURE_BYPASS) return direct;
const extra = part.extra_content as { google?: { thought_signature?: unknown } } | undefined;
const nested = extra?.google?.thought_signature;
if (typeof nested === "string" && nested.length >= MIN_SIGNATURE_LEN) return nested;
if (typeof nested === "string" && nested.length >= MIN_SIGNATURE_LEN && nested !== THOUGHT_SIGNATURE_BYPASS) return nested;
return undefined;
}

Expand Down Expand Up @@ -624,6 +624,75 @@ export function antigravityUsesReplayCache(model: string): boolean {
return !/claude/i.test(model);
}

/**
* Gemini 3 rejects a turn whose FIRST functionCall part carries no thought signature. When
* neither the wire metadata nor the replay cache can supply a real one, this is the official
* validator-bypass token.
*/
const THOUGHT_SIGNATURE_BYPASS = "skip_thought_signature_validator";

/**
* True when the model speaks the Gemini wire dialect that requires a thought signature on the
* first functionCall of a turn — and therefore accepts the validator-bypass sentinel.
*
* Deliberately NOT `antigravityUsesReplayCache`. That predicate is broad on purpose (every
* non-Claude model participates in signature replay), and reusing it for the sentinel is how a
* Gemini-only control token was observed being injected into `gpt-oss-120b-medium`. Replaying a
* signature upstream gave us is harmless for any model; *fabricating* a Gemini token is not.
*
* The identity must be REDUCED to its model component before matching, not scanned whole. The
* Vertex replay key is built in `src/adapters/google.ts` as
* `vertex:<project>:<location>:<modelId>`, and the project id is operator-chosen: a project
* named `gemini-prod` made a whole-string scan return true for
* `vertex:gemini-prod:global:gpt-oss-120b`, arming the Gemini-only sentinel for a non-Gemini
* model — the exact class of defect this predicate exists to prevent, reintroduced one layer up.
*
* So: take the last `:` segment for a Vertex identity, then the last `/` segment for a
* namespaced id (`google/gemini-3-pro`), and match only that. The trailing `[-.\d]` keeps
* `geminibot` and `my-gemini-clone` out. A model outside this set that genuinely needs the
* sentinel must arrive with a captured accepted CCA contract, not by widening this predicate.
*/
export function antigravitySupportsThoughtSignatureSentinel(model: string): boolean {
const afterTransport = model.slice(model.lastIndexOf(":") + 1);
const wireModel = afterTransport.slice(afterTransport.lastIndexOf("/") + 1);
return /^gemini[-.\d]/i.test(wireModel);
}

/**
* Ensure every model turn's FIRST functionCall carries a thought signature, injecting the
* validator-bypass sentinel only where one is genuinely absent.
*
* Split out of `applyAntigravityReplay` on purpose. Replay answers "what did upstream already
* tell us about this call", and its absence of a signature is meaningful — 18 assertions in the
* suite read `thoughtSignature === undefined` as "the cache did not match", covering eviction,
* TTL expiry, oversize refusal and clear-on-invalid. Folding a fabricated token into that
* function would overwrite the very signal those tests read. Keeping the sentinel as its own
* pass means a cache miss still looks like a cache miss.
*
* Three properties this must hold, each of which a naive presence-check gets wrong:
* - it decides from `extractSignature`, so a valid NESTED
* `extra_content.google.thought_signature` counts as signed (no competing sentinel) and a
* present-but-too-short value does not (the fallback still fires);
* - it looks at the FIRST functionCall only, so a later sibling receiving a cached signature
* cannot vote away the sentinel the first call requires;
* - it is gated on the Gemini wire dialect, not on replay-cache participation.
*/
export function applyAntigravityThoughtSignatureFallback(model: string, contents: unknown[]): unknown[] {
if (!antigravitySupportsThoughtSignatureSentinel(model) || !Array.isArray(contents)) return contents;
for (const rawContent of contents as { role?: string; parts?: unknown[] }[]) {
if (!rawContent || typeof rawContent !== "object" || rawContent.role !== "model") continue;
if (!Array.isArray(rawContent.parts)) continue;
for (const rawPart of rawContent.parts) {
if (!rawPart || typeof rawPart !== "object") continue;
const part = rawPart as Record<string, unknown>;
if (!part.functionCall) continue;
if (!extractSignature(part)) part.thoughtSignature = THOUGHT_SIGNATURE_BYPASS;
break;
}
}
return contents;
}

/**
* Observe a parsed CCA chunk's `candidates[0].content.parts` and record thought signatures keyed by
* the functionCall identity (name + args). Accumulates across the whole session so a sequential
Expand Down
5 changes: 5 additions & 0 deletions src/adapters/google-antigravity-wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export const ANTIGRAVITY_REQUEST_UA = antigravityUserAgent();
*/
export function isLikelyRealThoughtSignature(sig: string | undefined): boolean {
if (typeof sig !== "string" || sig.length < 16) return false;
// The validator-bypass sentinel is something WE fabricate for outbound requests when no real
// signature exists. It is alphanumeric with underscores, so it would otherwise satisfy every
// check below and be re-ingested as genuine — cached, replayed, and eventually treated as
// evidence that a turn was signed. It is never a real signature.
if (sig === "skip_thought_signature_validator") return false;
// Reject synthetic Responses/tool-call ids and Anthropic tool-use ids (`_` or `-` separators).
if (/^(fc|ctc|tsc|call|msg|rs|resp|reasoning|item|ws|toolu|tool|func|function)[-_]/i.test(sig)) return false;
// Real Gemini thought signatures are opaque base64/base64url blobs: only [A-Za-z0-9+/_=-].
Expand Down
16 changes: 15 additions & 1 deletion src/adapters/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ import { isVertexTruncatedTurn, vertexTruncationErrorMessage } from "./google-tr
import { ANTIGRAVITY_REQUEST_UA, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire";
import { compileGoogleWireBody } from "./google-wire-compiler";
import { identifyRoutedModel } from "./identity";
import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay";
import {
antigravityUsesReplayCache,
applyAntigravityReplay,
applyAntigravityThoughtSignatureFallback,
clearAntigravityReplay,
observeAntigravityReplay,
} from "./google-antigravity-replay";
import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models";
import { googleVertexLocationConfigError } from "../providers/google-vertex-location";
import { forgetThoughtSignatureForReplay, lookupReplayThoughtSignature } from "../responses/thought-signature-replay";
Expand Down Expand Up @@ -826,6 +832,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
} else {
sanitizeAntigravityClaudeSignatures(contents);
}
// After replay, not instead of it: a real signature always wins, and the sentinel only
// fills a first functionCall that replay could not sign. Outside the cache branch too,
// because the turn still needs a signature when no session was ever recorded.
applyAntigravityThoughtSignatureFallback(wireModelId, contents);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the existing adapter tests for the new fallback

Update the pre-existing wire-path assertions when enabling this call: tests/google-antigravity-wire.test.ts lines 780 and 800 still require thoughtSignature to be undefined for synthetic fc_/ctc_ values, but a gemini-3-pro request now replaces either value with the bypass sentinel here. The Vertex namespace-isolation assertion at tests/google-vertex-thought-signature.test.ts:129 likewise receives the sentinel rather than undefined. Consequently the repository-wide adapter suite is red even though the newly added focused replay tests pass; assert that the synthetic or cross-namespace signature was not forwarded while accounting for the intentional fallback.

AGENTS.md reference: AGENTS.md:L284-L286

Useful? React with 👍 / 👎.

// Claude-on-Antigravity rejects assistant-tail (model-tail in Gemini terms) histories
// as prefill: "This model does not support assistant message prefill. The conversation
// must end with a user message." Context compaction, previous_response_id expansion,
Expand Down Expand Up @@ -870,6 +880,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
vertexReplaySession,
(compiled.body as { contents: unknown[] }).contents,
);
applyAntigravityThoughtSignatureFallback(
vertexReplayModel,
(compiled.body as { contents: unknown[] }).contents,
);
}
// Vertex AI: project/location endpoint with GCP ADC, or x-goog-api-key fast path.
const apiKey = resolveVertexApiKey(provider.apiKey);
Expand Down
128 changes: 128 additions & 0 deletions tests/google-antigravity-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
antigravityReplaySessionKeysForTests,
antigravityUsesReplayCache,
applyAntigravityReplay,
antigravitySupportsThoughtSignatureSentinel,
applyAntigravityThoughtSignatureFallback,
clearAntigravityReplay,
evictOldestAntigravityReplayForBudget,
flushAntigravityReplay,
Expand Down Expand Up @@ -1001,3 +1003,129 @@ describe("durable antigravity replay snapshot", () => {
}
});
});

describe("thought-signature validator-bypass fallback (#2693)", () => {
const BYPASS = "skip_thought_signature_validator";
const sigOf = (part: unknown) => (part as { thoughtSignature?: string }).thoughtSignature;
const modelTurn = (parts: unknown[]) => [{ role: "model", parts }];

test("the FIRST functionCall gets the sentinel even when a later sibling is signed", () => {
// The original attempt tracked a turn-wide "any part is signed" boolean, so a second call
// matching the cache voted away the sentinel the first call still required. Gemini rejects
// that turn: the requirement is about the first functionCall, not about the turn.
observeAntigravityReplay(MODEL, SESSION, [fcPart("second", {}, SIG)]);
const contents = modelTurn([
{ functionCall: { name: "first", args: {} } },
{ functionCall: { name: "second", args: {} } },
]);
applyAntigravityReplay(MODEL, SESSION, contents);
applyAntigravityThoughtSignatureFallback(MODEL, contents);
expect(sigOf(contents[0].parts[1])).toBe(SIG);
expect(sigOf(contents[0].parts[0])).toBe(BYPASS);
});

test("a valid NESTED signature counts as signed and gets no competing sentinel", () => {
// extra_content.google.thought_signature is a wire shape this module already supports, but a
// bare key-presence check cannot see it, so it added a second, conflicting signature.
const contents = modelTurn([fcPart("get_x", {}, SIG, true)]);
applyAntigravityThoughtSignatureFallback(MODEL, contents);
expect(sigOf(contents[0].parts[0])).toBeUndefined();
expect((contents[0].parts[0] as { extra_content?: { google?: { thought_signature?: string } } })
.extra_content?.google?.thought_signature).toBe(SIG);
});

test("a present but too-short signature still gets the sentinel", () => {
// "short" is below MIN_SIGNATURE_LEN, so extractSignature rejects it. A presence check reads
// the key as set and suppresses the fallback on a turn that genuinely needs it.
const contents = modelTurn([fcPart("get_x", {}, "short")]);
applyAntigravityThoughtSignatureFallback(MODEL, contents);
expect(sigOf(contents[0].parts[0])).toBe(BYPASS);
});

test("a non-Gemini model never receives the Gemini-only sentinel", () => {
// antigravityUsesReplayCache is !/claude/i, so gating on it injected this token into
// gpt-oss-120b-medium. Replay scope is broad by design; sentinel scope must not be.
const contents = modelTurn([{ functionCall: { name: "get_x", args: {} } }]);
applyAntigravityThoughtSignatureFallback("gpt-oss-120b-medium", contents);
expect(sigOf(contents[0].parts[0])).toBeUndefined();
});

test("a Gemini turn with no cache entry at all still gets the sentinel", () => {
// The feature's whole point: nothing was recorded for this session, so replay cannot help.
const contents = modelTurn([{ functionCall: { name: "never_seen", args: {} } }]);
applyAntigravityReplay(MODEL, "session-with-no-entry", contents);
applyAntigravityThoughtSignatureFallback(MODEL, contents);
expect(sigOf(contents[0].parts[0])).toBe(BYPASS);
});

test("the Vertex transport-prefixed model id is recognised as Gemini", () => {
// src/adapters/google.ts builds vertex:<project>:<location>:<modelId>. A predicate matching
// only "/" would skip every Vertex Gemini request while looking correct on CCA ids.
expect(antigravitySupportsThoughtSignatureSentinel("vertex:proj:global:gemini-3-pro")).toBe(true);
expect(antigravitySupportsThoughtSignatureSentinel("google/gemini-3-pro")).toBe(true);
expect(antigravitySupportsThoughtSignatureSentinel("gemini-3-pro")).toBe(true);
expect(antigravitySupportsThoughtSignatureSentinel("vertex:proj:global:gpt-oss-120b")).toBe(false);
expect(antigravitySupportsThoughtSignatureSentinel("geminibot")).toBe(false);
});


test("a later sibling signed ON THE WIRE does not vote away the first call's sentinel", () => {
// Sibling arm of defect 2, with NO cache involved. A patch that only ignores cache-set
// signatures would still pass the cache-hit case above while leaving this open, so bind it
// explicitly: the decision reads the FIRST functionCall, never the turn.
const contents = [{
role: "model",
parts: [
{ functionCall: { name: "first", args: {} } },
{ functionCall: { name: "second", args: {} }, thoughtSignature: SIG },
],
}];
applyAntigravityThoughtSignatureFallback(MODEL, contents);
expect(sigOf(contents[0].parts[0])).toBe(BYPASS);
expect(sigOf(contents[0].parts[1])).toBe(SIG);
});

test("Vertex-prefixed Gemini turns still receive the sentinel end to end", () => {
// The Vertex replay key is vertex:<project>:<location>:<modelId>. Asserting only the
// predicate would let a regex that matches "/" but not ":" look correct; drive the real
// function with the real identity instead.
const vertexModel = "vertex:api-key:global:gemini-3-pro";
const contents = [{ role: "model", parts: [{ functionCall: { name: "get_x", args: {} } }] }];
applyAntigravityThoughtSignatureFallback(vertexModel, contents);
expect(sigOf(contents[0].parts[0])).toBe(BYPASS);

const vertexNonGemini = "vertex:api-key:global:gpt-oss-120b-medium";
const other = [{ role: "model", parts: [{ functionCall: { name: "get_x", args: {} } }] }];
applyAntigravityThoughtSignatureFallback(vertexNonGemini, other);
expect(sigOf(other[0].parts[0])).toBeUndefined();
});

test("a gemini-named Vertex PROJECT does not arm the sentinel for a non-Gemini model", () => {
// The Vertex replay key is vertex:<project>:<location>:<modelId> and the project id is
// operator-chosen. Scanning the whole identity meant a project called "gemini-prod" armed
// the Gemini-only sentinel for gpt-oss-120b — the same class of defect the predicate exists
// to prevent, one layer up. Reduce to the model component before matching.
expect(antigravitySupportsThoughtSignatureSentinel("vertex:gemini-prod:global:gpt-oss-120b"))
.toBe(false);
expect(antigravitySupportsThoughtSignatureSentinel("vertex:gemini-team:us:claude-fable-5"))
.toBe(false);

const contents = [{
role: "model",
parts: [{ functionCall: { name: "get_x", args: {} } }],
}];
applyAntigravityThoughtSignatureFallback("vertex:gemini-prod:global:gpt-oss-120b", contents);
expect(sigOf(contents[0].parts[0])).toBeUndefined();

// The positive control still holds under the same parsing.
const gemini = [{ role: "model", parts: [{ functionCall: { name: "get_x", args: {} } }] }];
applyAntigravityThoughtSignatureFallback("vertex:gemini-prod:global:gemini-3-pro", gemini);
expect(sigOf(gemini[0].parts[0])).toBe(BYPASS);
});

test("a user-role turn is untouched", () => {
const contents = [{ role: "user", parts: [{ functionCall: { name: "get_x", args: {} } }] }];
applyAntigravityThoughtSignatureFallback(MODEL, contents);
expect(sigOf(contents[0].parts[0])).toBeUndefined();
});
});
8 changes: 6 additions & 2 deletions tests/google-antigravity-wire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -777,7 +777,10 @@ describe("antigravity history preserves tool-call thoughtSignature", () => {
const env = JSON.parse(req.body);
const modelTurn = (env.request.contents as { role: string; parts: Record<string, unknown>[] }[]).find(c => c.role === "model");
const fcPart = modelTurn?.parts.find(part => "functionCall" in part);
expect(fcPart?.thoughtSignature).toBeUndefined();
// The synthetic fc_ id is still stripped — what lands is the constant bypass sentinel,
// fabricated here rather than forwarded from the client. isLikelyRealThoughtSignature
// rejects both, so neither can be cached or replayed as a genuine signature.
expect(fcPart?.thoughtSignature).toBe("skip_thought_signature_validator");
});

test("custom_tool_call item ids (ctc_...) from Claude/mixed history are NOT forwarded (issue #174)", async () => {
Expand All @@ -797,7 +800,8 @@ describe("antigravity history preserves tool-call thoughtSignature", () => {
const env = JSON.parse(req.body);
const modelTurn = (env.request.contents as { role: string; parts: Record<string, unknown>[] }[]).find(c => c.role === "model");
const fcPart = modelTurn?.parts.find(part => "functionCall" in part);
expect(fcPart?.thoughtSignature).toBeUndefined();
// Same contract for ctc_ ids: not forwarded; the sentinel is injected in their place.
expect(fcPart?.thoughtSignature).toBe("skip_thought_signature_validator");
});
});

Expand Down
7 changes: 5 additions & 2 deletions tests/google-signature-history-roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,9 @@ describe("#1735 thought signature survives history replay", () => {
});
const request = await createGoogleAdapter(provider).buildRequest(parsed);
const part = modelParts(request.body as string).find(candidate => "functionCall" in candidate);
expect(part?.thoughtSignature).toBeUndefined();
// The sentinel, not a borrowed signature: nothing was inherited from another call. The
// property this guards is anti-borrowing, and a constant carries no other call's identity.
expect(part?.thoughtSignature).toBe("skip_thought_signature_validator");
});

test("a signature the proxy remembered re-signs a replay the client sent without extra_content", async () => {
Expand Down Expand Up @@ -430,7 +432,8 @@ describe("#1735 thought signature survives history replay", () => {
});
const request = await createGoogleAdapter(provider).buildRequest(parsed);
const part = modelParts(request.body as string).find(candidate => "functionCall" in candidate);
expect(part?.thoughtSignature).toBeUndefined();
// Unknown call_id borrows nothing; it receives the constant bypass sentinel instead.
expect(part?.thoughtSignature).toBe("skip_thought_signature_validator");
});

test("the same call_id in a different thread does not borrow the signature (#1823)", () => {
Expand Down
4 changes: 3 additions & 1 deletion tests/google-vertex-thought-signature.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,9 @@ describe("Vertex thought-signature continuation (#1254)", () => {
const otherThread = await createGoogleAdapter(provider).buildRequest(
scopedReplayRequest(continuation(), "thread-b", "shared-cache-cohort"),
);
expect(replayedFunctionCall(otherThread.body as string).thoughtSignature).toBeUndefined();
// #1312 isolation still holds: thread-b gets the CONSTANT sentinel, never thread-a's real
// signature. A genuine cross-namespace leak would surface the real value here and fail.
expect(replayedFunctionCall(otherThread.body as string).thoughtSignature).toBe("skip_thought_signature_validator");

const originalThread = await createGoogleAdapter(provider).buildRequest(
scopedReplayRequest(continuation(), "thread-a", "different-cache-cohort"),
Expand Down
Loading