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
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/ja/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ HTTP リトライ ループの対象外です。

- システムプロンプト → `systemInstruction`;メッセージ → `contents[]`(assistant → `model`);ツール →
`functionDeclarations`。data URL 画像 → `inline_data`。
- Gemini が tool-call id を省略すると合成します。Antigravity では実際の `thoughtSignature` 値を保存・再利用し、次のターンでも reasoning の連続性を保ちます。
- Gemini が tool-call id を省略すると合成します。Vertex と Antigravity では実際の `thoughtSignature` 値を保存・再利用し、tool-result の継続ターンでも reasoning の連続性を保ちます。

## `kiro`

Expand Down
4 changes: 2 additions & 2 deletions docs-site/src/content/docs/ko/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@ interface ProviderAdapter {

- 시스템 프롬프트 → `systemInstruction`; 메시지 → `contents[]`(assistant → `model`); 툴 →
`functionDeclarations`. data URL 이미지 → `inline_data`.
- Gemini가 tool-call id를 생략하면 합성합니다. Antigravity에서는 실제 `thoughtSignature` 값을
보존하고 재사용해 다음 턴에서도 reasoning 연속성을 유지합니다.
- Gemini가 tool-call id를 생략하면 합성합니다. Vertex와 Antigravity에서는 실제
`thoughtSignature` 값을 보존하고 재사용해 tool-result 후속 턴에서도 reasoning 연속성을 유지합니다.

## `kiro`

Expand Down
4 changes: 2 additions & 2 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ of the HTTP retry loop.

- System prompt → `systemInstruction`; messages → `contents[]` (assistant → `model`); tools →
`functionDeclarations`. Data-URL images → `inline_data`.
- Tool-call ids are synthesized when Gemini omits them. Antigravity preserves and replays real
`thoughtSignature` values so reasoning continuity survives later turns.
- Tool-call ids are synthesized when Gemini omits them. Vertex and Antigravity preserve and replay
real `thoughtSignature` values so tool-result continuations retain Gemini reasoning continuity.
- **Inline image output:** when the model is one of the explicit image-capable chat IDs
(`gemini-3.1-flash-image`, `gemini-2.0-flash-preview-image-generation`, or
`gemini-3-pro-image-preview`), the adapter sends `responseModalities: ["TEXT", "IMAGE"]`.
Expand Down
6 changes: 3 additions & 3 deletions docs-site/src/content/docs/ru/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,9 @@ interface ProviderAdapter {

- Системный промпт → `systemInstruction`; сообщения → `contents[]` (assistant → `model`);
инструменты → `functionDeclarations`. Изображения из data-URL → `inline_data`.
- Идентификаторы вызовов инструментов синтезируются, когда Gemini их опускает. Antigravity
сохраняет и повторно передаёт настоящие значения `thoughtSignature`, чтобы непрерывность
рассуждений сохранялась в последующих ходах.
- Идентификаторы вызовов инструментов синтезируются, когда Gemini их опускает. Vertex и Antigravity
сохраняют и повторно передают настоящие значения `thoughtSignature`, чтобы непрерывность
рассуждений сохранялась после возврата результата инструмента.

## `kiro`

Expand Down
4 changes: 2 additions & 2 deletions docs-site/src/content/docs/zh-cn/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ interface ProviderAdapter {

- 系统提示词 → `systemInstruction`;消息 → `contents[]`(assistant → `model`);工具 →
`functionDeclarations`;data URL 图像 → `inline_data`。
- Gemini 省略 tool-call id 时会合成 id。Antigravity 会保留并重放真实 `thoughtSignature`,使
reasoning continuity 延续到后续 turn。
- Gemini 省略 tool-call id 时会合成 id。Vertex 与 Antigravity 会保留并重放真实
`thoughtSignature`,使 tool-result 后续 turn 保持 reasoning continuity

## `kiro`

Expand Down
5 changes: 3 additions & 2 deletions src/adapters/google-antigravity-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import { createHash } from "node:crypto";
import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";

/**
* Antigravity (Cloud Code Assist) thoughtSignature reasoning-replay cache.
* Google-family thoughtSignature reasoning-replay cache.
*
* Gemini-3 interleaved thinking is stateless upstream: each model content part carries a
* `thoughtSignature` that MUST be echoed back on the matching part in the next request, or the
* upstream rejects the turn (HTTP 400). We observe signatures on the response stream, cache them
* per `model + session`, and re-inject them into the outgoing `request.contents` on the next turn.
*
* Mirrors CLIProxyAPI `internal/runtime/executor/antigravity_reasoning_replay.go`. Gemini-only;
* Mirrors CLIProxyAPI `internal/runtime/executor/antigravity_reasoning_replay.go` and is also used
* by Vertex with a transport/project/location-prefixed model identity. Gemini-only;
* Claude-on-Antigravity uses inline signature sanitization instead (see google-antigravity-wire).
*/

Expand Down
53 changes: 45 additions & 8 deletions src/adapters/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
);
Comment on lines +459 to +463

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 Preserve signatures for repeated identical Vertex calls

When a Vertex conversation invokes the same compiled function with identical arguments more than once (for example, repeating pwd or a status check), observeAntigravityReplay stores 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 historical functionCall, 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 👍 / 👎.

}
// Vertex AI: project/location endpoint with GCP ADC, or x-goog-api-key fast path.
const apiKey = resolveVertexApiKey(provider.apiKey);
if (apiKey) {
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 chunk.error. A non-streaming raw.error returns at line 778 without clearing state. An HTTP 400 is formatted through formatErrorBody at lines 327-329 without clearing state.

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 formatErrorBody, the SSE error branch, and the non-streaming raw.error branch. Add regression coverage for a non-streaming or HTTP 400 rejection followed by a clean continuation.

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/adapters/google.ts` around lines 540 - 545, Centralize replay cleanup in
a helper that selects the Antigravity or Vertex model/session from
provider.googleMode and matches signature or invalid-argument errors, then
invoke it from formatErrorBody, the SSE chunk.error branch, and the
non-streaming raw.error branch. Remove the duplicated inline selection/match
logic while preserving existing behavior, and add regression coverage verifying
a non-streaming or HTTP 400 rejection clears replay state before a clean
continuation.

}
yield { type: "error", message: err?.message ?? "upstream error" };
return "terminate";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 });
Expand Down
20 changes: 20 additions & 0 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,26 @@ pre-compaction checkpoint is not persisted for later carry-forward.
- 장점, 단점 및 영향: Active-context reporting stays monotonic within an uncompacted Cursor conversation; no-checkpoint turns remain estimated; a process restart loses the numeric cache, and when neither a checkpoint nor a carry-forward is available the turn reports a request-local estimate derived from the same pruned payload sent to Cursor (#373 — reporting output-only usage made Codex read the context as nearly empty). Estimates are never persisted or promoted into checkpoint carry-forward; only live checkpoint frames update the cache.
```

## Google tool-call thought-signature replay

Gemini may attach an opaque `thoughtSignature` to a `functionCall` and requires that exact value on
the matching model turn when its tool result is submitted. Antigravity and Vertex share the existing
bounded TTL/LRU replay store, keyed by compiled function-call name plus canonical arguments. Vertex
prefixes its cache model key with the transport, project, and location identity, so a signature
minted by Vertex cannot be sent to Antigravity even when both routes expose the same public model id.
Vertex prefers Codex's opaque `prompt_cache_key` for session identity and falls back to the existing
first-user-message derivation for clients that omit it; only the fixed hash is retained.
Both streaming and non-streaming responses feed the store; request compilation happens before replay
so matching uses the provider-visible tool name.

[Decision Log]
- 목적과 의도: Preserve Vertex Gemini tool-call continuation without exposing opaque signatures to Codex or another Google backend.
- 기존 구현 및 제약 조건: Responses history does not carry a safe Gemini signature field; Antigravity already used a bounded in-process replay cache, while Vertex bypassed it and received HTTP 400 after the first tool call.
- 검토한 주요 대안: Serialize the signature into Responses item ids or reasoning content; create an unbounded Vertex map; reuse the bounded cache with or without a transport namespace.
- 선택한 방식: Reuse the bounded cache for Vertex, observe both response shapes, apply after wire-name compilation, and scope Vertex by transport/project/location plus the opaque client session key when available.
- 다른 대안 대신 이 방식을 선택한 이유: Responses ids are not Gemini signatures and previously caused Base64/TYPE_BYTES failures; a second cache duplicates limits; an unscoped cache could send provider-private state across destinations.
- 장점, 단점 및 영향: Tool loops continue with exact opaque state and bounded memory while cross-transport reuse fails closed. Replay remains process-local, matching the existing Antigravity contract.

## OpenRouter provider routing

The canonical OpenRouter `openai-chat` transport may carry optional provider-routing preferences
Expand Down
123 changes: 123 additions & 0 deletions tests/google-vertex-thought-signature.test.ts
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Test both Vertex thought-signature field spellings.

vertexResponseBody() only emits thoughtSignature. The stated replay contract also accepts thought_signature. A regression in the snake_case response parser would pass both continuation tests and fail against affected Vertex responses.

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/google-vertex-thought-signature.test.ts` around lines 58 - 71, Update
the vertexResponseBody fixture to accept a parameter selecting thoughtSignature
or thought_signature, then parameterize the continuation tests over both
response spellings. Parse each variant and assert that the subsequent request
replays the exact SIGNATURE value.

}

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 project or location stay isolated. If either identifier is removed from the replay key, this suite still passes and can replay provider-private state across GCP tenants or regions.

Record a signature with one Vertex project or location. Build the same continuation with a different value. Assert that its functionCall has no thoughtSignature.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/google-vertex-thought-signature.test.ts` around lines 110 - 121, Extend
the test around applyAntigravityReplay to record a Vertex signature using one
project or location, then build the same continuation with a different value and
assert its functionCall has no thoughtSignature. Ensure the assertions
specifically verify isolation across Vertex configuration identifiers, not only
between Vertex and Antigravity namespaces.

});
});
Loading