-
Notifications
You must be signed in to change notification settings - Fork 664
fix(google): replay Vertex thought signatures #1266
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -51,6 +51,13 @@ function resolveVertexApiKey(optKey?: string): string | undefined { | |
| return realKey || process.env.GOOGLE_CLOUD_API_KEY; | ||
| } | ||
|
|
||
| /** Prefer Codex's stable opaque thread key; retain the existing deterministic fallback for clients | ||
| * that omit it. The replay store hashes this value and never retains the raw session identifier. */ | ||
| function vertexReplaySessionId(parsed: OcxParsedRequest): string { | ||
| const promptCacheKey = parsed.options.promptCacheKey?.trim(); | ||
| return promptCacheKey || antigravitySessionId(parsed); | ||
| } | ||
|
|
||
| /** | ||
| * Stable tool-call id for the Gemini wire `functionCall.id` / `functionResponse.id` fields. | ||
| * | ||
|
|
@@ -302,6 +309,11 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte | |
| // can stash the CCA model/session for parseStream's reasoning-replay observation. | ||
| let antigravityModel: string | undefined; | ||
| let antigravitySession: string | undefined; | ||
| // Vertex returns the same opaque Gemini thought signatures as CCA, but its replay namespace | ||
| // must stay transport-scoped: a signature minted by one Google backend must never be sent to | ||
| // another merely because the public model id and first prompt happen to match. | ||
| let vertexReplayModel: string | undefined; | ||
| let vertexReplaySession: string | undefined; | ||
| let restoreGoogleToolName = (name: string): string => name; | ||
| return { | ||
| name: "google", | ||
|
|
@@ -436,6 +448,20 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte | |
| if (provider.googleMode === "vertex") { | ||
| const compiled = compileGoogleWireBody(body); | ||
| restoreGoogleToolName = compiled.restoreToolName; | ||
| const vertexProject = provider.project || process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT || "api-key"; | ||
| const vertexLocation = provider.location || process.env.GOOGLE_CLOUD_LOCATION || "global"; | ||
| vertexReplayModel = `vertex:${vertexProject}:${vertexLocation}:${parsed.modelId}`; | ||
| vertexReplaySession = vertexReplaySessionId(parsed); | ||
| // Compile names before replay so the cache matches the exact provider-visible | ||
| // functionCall identity. This is the same bounded TTL/LRU store used by CCA, with the | ||
| // transport prefix above preventing cross-backend signature reuse (#1254). | ||
| if (Array.isArray((compiled.body as { contents?: unknown[] }).contents)) { | ||
| applyAntigravityReplay( | ||
| vertexReplayModel, | ||
| vertexReplaySession, | ||
| (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); | ||
| if (apiKey) { | ||
|
|
@@ -511,9 +537,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte | |
| const err = chunk.error as { message?: string } | undefined; | ||
| // Clear-on-invalid: a signature rejection means our replayed thoughtSignatures are stale. | ||
| // Drop the cache entry so the next turn starts clean instead of re-injecting a bad sig. | ||
| if (provider.googleMode === "cloud-code-assist" && antigravityModel && antigravitySession | ||
| const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel; | ||
| const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession; | ||
| if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex") | ||
| && replayModel && replaySession | ||
| && /signature|invalid_argument|invalid argument/i.test(err?.message ?? "")) { | ||
| clearAntigravityReplay(antigravityModel, antigravitySession); | ||
| clearAntigravityReplay(replayModel, replaySession); | ||
|
Comment on lines
+540
to
+545
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Clear replay state for all signature rejection paths. Lines 540-545 clear state only for an in-band SSE The stale signature is replayed on the next continuation until the TTL expires. Centralize the model/session selection and signature-error match in a helper. Call it from Proposed fix+ const clearReplayOnSignatureError = (message: string): void => {
+ const replayModel = provider.googleMode === "cloud-code-assist"
+ ? antigravityModel
+ : vertexReplayModel;
+ const replaySession = provider.googleMode === "cloud-code-assist"
+ ? antigravitySession
+ : vertexReplaySession;
+ if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex")
+ && replayModel && replaySession
+ && /signature|invalid_argument|invalid argument/i.test(message)) {
+ clearAntigravityReplay(replayModel, replaySession);
+ }
+ };
+
...(provider.googleMode === "vertex" || provider.googleMode === "cloud-code-assist"
? {
fetchResponse: ...,
- formatErrorBody: (status, _headers, payloadText) =>
- ...,
+ formatErrorBody: (status, _headers, payloadText) => {
+ clearReplayOnSignatureError(payloadText);
+ return ...;
+ },
}
: {}),🤖 Prompt for AI Agents |
||
| } | ||
| yield { type: "error", message: err?.message ?? "upstream error" }; | ||
| return "terminate"; | ||
|
|
@@ -547,9 +576,13 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte | |
| } | ||
|
|
||
| const parts = candidates[0].content?.parts as { text?: string; functionCall?: { name: string; args: unknown } }[] | undefined; | ||
| // Antigravity reasoning-replay: record thoughtSignatures from the model parts for the next turn. | ||
| if (provider.googleMode === "cloud-code-assist" && parts && antigravityModel && antigravitySession) { | ||
| observeAntigravityReplay(antigravityModel, antigravitySession, parts as unknown[]); | ||
| // Record Gemini thought signatures for the next stateless tool-result turn. Vertex and | ||
| // Antigravity use separate model namespaces so opaque provider state cannot cross routes. | ||
| const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel; | ||
| const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession; | ||
| if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex") | ||
| && parts && replayModel && replaySession) { | ||
| observeAntigravityReplay(replayModel, replaySession, parts as unknown[]); | ||
| } | ||
| if (parts) { | ||
| for (const part of parts) { | ||
|
|
@@ -764,9 +797,13 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte | |
| let toolCallsStarted = 0; | ||
| const imageBudget = createImageBudget(); | ||
| if (candidates?.[0]?.content?.parts) { | ||
| // Non-streaming CCA: observe thoughtSignatures for the next turn, same as the stream path. | ||
| if (provider.googleMode === "cloud-code-assist" && antigravityModel && antigravitySession) { | ||
| observeAntigravityReplay(antigravityModel, antigravitySession, candidates[0].content.parts as unknown[]); | ||
| // Non-streaming Google-family response: observe thought signatures for the next turn, | ||
| // using the same transport-scoped namespace as the streaming path. | ||
| const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel; | ||
| const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession; | ||
| if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex") | ||
| && replayModel && replaySession) { | ||
| observeAntigravityReplay(replayModel, replaySession, candidates[0].content.parts as unknown[]); | ||
| } | ||
| for (const part of candidates[0].content.parts) { | ||
| if (part.text) events.push({ type: "text_delta", text: part.text }); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| import { beforeEach, describe, expect, test } from "bun:test"; | ||
| import { createGoogleAdapter as createGoogleAdapterProduction } from "../src/adapters/google"; | ||
| import { | ||
| __resetAntigravityReplayCache, | ||
| applyAntigravityReplay, | ||
| } from "../src/adapters/google-antigravity-replay"; | ||
| import { antigravitySessionId } from "../src/adapters/google-antigravity-wire"; | ||
| import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types"; | ||
| import { withTestTranslatorBudget } from "./helpers/translator-budget"; | ||
|
|
||
| const createGoogleAdapter = (...args: Parameters<typeof createGoogleAdapterProduction>) => | ||
| withTestTranslatorBudget(createGoogleAdapterProduction(...args)); | ||
|
|
||
| const SIGNATURE = "CiQAx-vertex-thought-signature-0123456789abcdef"; | ||
| const MODEL = "gemini-3.6-flash"; | ||
|
|
||
| const provider = { | ||
| adapter: "google", | ||
| googleMode: "vertex", | ||
| baseUrl: "https://aiplatform.googleapis.com", | ||
| apiKey: "vertex-test-key", | ||
| } as OcxProviderConfig; | ||
|
|
||
| function request(messages: OcxParsedRequest["context"]["messages"], stream: boolean): OcxParsedRequest { | ||
| return { | ||
| modelId: MODEL, | ||
| stream, | ||
| context: { | ||
| messages, | ||
| systemPrompt: [], | ||
| tools: [{ name: "shell_command", description: "run a command", parameters: { type: "object" } }], | ||
| }, | ||
| options: {}, | ||
| } as unknown as OcxParsedRequest; | ||
| } | ||
|
|
||
| const firstTurn = (stream: boolean) => request([{ role: "user", content: "run pwd" }], stream); | ||
|
|
||
| const continuation = () => request([ | ||
| { role: "user", content: "run pwd" }, | ||
| { | ||
| role: "assistant", | ||
| content: [{ | ||
| type: "toolCall", | ||
| id: "call_shell_1", | ||
| name: "shell_command", | ||
| arguments: { command: "pwd" }, | ||
| }], | ||
| }, | ||
| { | ||
| role: "toolResult", | ||
| toolCallId: "call_shell_1", | ||
| toolName: "shell_command", | ||
| content: "/workspace", | ||
| }, | ||
| ], false); | ||
|
|
||
| function vertexResponseBody(): Record<string, unknown> { | ||
| return { | ||
| candidates: [{ | ||
| content: { | ||
| role: "model", | ||
| parts: [{ | ||
| functionCall: { name: "shell_command", args: { command: "pwd" } }, | ||
| thoughtSignature: SIGNATURE, | ||
| }], | ||
| }, | ||
| finishReason: "STOP", | ||
| }], | ||
| usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 2 }, | ||
| }; | ||
|
Comment on lines
+58
to
+71
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win Test both Vertex thought-signature field spellings.
Parameterize this fixture for both field names. Parse each response form, then assert that the next request replays the exact signature value. 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| function replayedFunctionCall(body: string): Record<string, unknown> { | ||
| const parsed = JSON.parse(body) as { contents: Array<{ role?: string; parts?: Record<string, unknown>[] }> }; | ||
| const model = parsed.contents.find(content => content.role === "model"); | ||
| const part = model?.parts?.find(candidate => "functionCall" in candidate); | ||
| if (!part) throw new Error("compiled Vertex request omitted the replayed functionCall"); | ||
| return part; | ||
| } | ||
|
|
||
| describe("Vertex thought-signature continuation (#1254)", () => { | ||
| beforeEach(() => __resetAntigravityReplayCache()); | ||
|
|
||
| test("streaming functionCall signature is replayed on the next tool-result turn", async () => { | ||
| const firstAdapter = createGoogleAdapter(provider); | ||
| await firstAdapter.buildRequest(firstTurn(true)); | ||
| const response = new Response(`data: ${JSON.stringify(vertexResponseBody())}\n\n`, { | ||
| headers: { "content-type": "text/event-stream" }, | ||
| }); | ||
| const events: AdapterEvent[] = []; | ||
| for await (const event of firstAdapter.parseStream(response)) events.push(event); | ||
| expect(events.some(event => event.type === "tool_call_start")).toBe(true); | ||
| expect(events.at(-1)?.type).toBe("done"); | ||
|
|
||
| const followup = await createGoogleAdapter(provider).buildRequest(continuation()); | ||
| expect(replayedFunctionCall(followup.body as string).thoughtSignature).toBe(SIGNATURE); | ||
| }); | ||
|
|
||
| test("non-streaming functionCall signature is replayed unchanged", async () => { | ||
| const firstAdapter = createGoogleAdapter(provider); | ||
| await firstAdapter.buildRequest(firstTurn(false)); | ||
| const events = await firstAdapter.parseResponse!(new Response(JSON.stringify(vertexResponseBody()))); | ||
| expect(events.some(event => event.type === "tool_call_start")).toBe(true); | ||
|
|
||
| const followup = await createGoogleAdapter(provider).buildRequest(continuation()); | ||
| expect(replayedFunctionCall(followup.body as string).thoughtSignature).toBe(SIGNATURE); | ||
| }); | ||
|
|
||
| test("Vertex signatures cannot enter the Antigravity replay namespace", async () => { | ||
| const first = firstTurn(false); | ||
| const adapter = createGoogleAdapter(provider); | ||
| await adapter.buildRequest(first); | ||
| await adapter.parseResponse!(new Response(JSON.stringify(vertexResponseBody()))); | ||
|
|
||
| const contents = [{ | ||
| role: "model", | ||
| parts: [{ functionCall: { name: "shell_command", args: { command: "pwd" } } }], | ||
| }]; | ||
| applyAntigravityReplay(MODEL, antigravitySessionId(first), contents); | ||
| expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined(); | ||
|
Comment on lines
+110
to
+121
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win Test Vertex project and location isolation. This test proves that a Vertex entry does not enter the Antigravity namespace. It does not prove that two Vertex configurations with the same conversation but different Record a signature with one Vertex 🤖 Prompt for AI Agents |
||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a Vertex conversation invokes the same compiled function with identical arguments more than once (for example, repeating
pwdor a status check),observeAntigravityReplaystores both occurrences under the same name-plus-arguments key, so the newer signature overwrites the older one. This newly added call then applies that single newest signature to every matching historicalfunctionCall, rather than replaying each model turn's exact opaque value, causing a later tool-result continuation to be rejected by Vertex. Store signatures per occurrence/order or stable call identity, and add a regression test with two identical calls followed by a third turn.AGENTS.md reference: src/AGENTS.md:L19-L19
Useful? React with 👍 / 👎.