diff --git a/docs-site/src/content/docs/ja/reference/adapters.md b/docs-site/src/content/docs/ja/reference/adapters.md index 0bd13fbc5..1bf9f9bcf 100644 --- a/docs-site/src/content/docs/ja/reference/adapters.md +++ b/docs-site/src/content/docs/ja/reference/adapters.md @@ -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` diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index bddf944b7..0ed83cc09 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -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` diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index dd6224d6c..7119ba7d7 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -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"]`. diff --git a/docs-site/src/content/docs/ru/reference/adapters.md b/docs-site/src/content/docs/ru/reference/adapters.md index d84c86693..a1f3950fa 100644 --- a/docs-site/src/content/docs/ru/reference/adapters.md +++ b/docs-site/src/content/docs/ru/reference/adapters.md @@ -91,9 +91,9 @@ interface ProviderAdapter { - Системный промпт → `systemInstruction`; сообщения → `contents[]` (assistant → `model`); инструменты → `functionDeclarations`. Изображения из data-URL → `inline_data`. -- Идентификаторы вызовов инструментов синтезируются, когда Gemini их опускает. Antigravity - сохраняет и повторно передаёт настоящие значения `thoughtSignature`, чтобы непрерывность - рассуждений сохранялась в последующих ходах. +- Идентификаторы вызовов инструментов синтезируются, когда Gemini их опускает. Vertex и Antigravity + сохраняют и повторно передают настоящие значения `thoughtSignature`, чтобы непрерывность + рассуждений сохранялась после возврата результата инструмента. ## `kiro` diff --git a/docs-site/src/content/docs/zh-cn/reference/adapters.md b/docs-site/src/content/docs/zh-cn/reference/adapters.md index 032df8806..9bde3ab29 100644 --- a/docs-site/src/content/docs/zh-cn/reference/adapters.md +++ b/docs-site/src/content/docs/zh-cn/reference/adapters.md @@ -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` diff --git a/src/adapters/google-antigravity-replay.ts b/src/adapters/google-antigravity-replay.ts index 319f9cbb6..0334b930a 100644 --- a/src/adapters/google-antigravity-replay.ts +++ b/src/adapters/google-antigravity-replay.ts @@ -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). */ diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 9f97b6ac1..57c9a2ce3 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -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); } 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 }); diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 55f8c5a2c..359ba7383 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -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 diff --git a/tests/google-vertex-thought-signature.test.ts b/tests/google-vertex-thought-signature.test.ts new file mode 100644 index 000000000..f24a33f7c --- /dev/null +++ b/tests/google-vertex-thought-signature.test.ts @@ -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) => + 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 { + return { + candidates: [{ + content: { + role: "model", + parts: [{ + functionCall: { name: "shell_command", args: { command: "pwd" } }, + thoughtSignature: SIGNATURE, + }], + }, + finishReason: "STOP", + }], + usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 2 }, + }; +} + +function replayedFunctionCall(body: string): Record { + const parsed = JSON.parse(body) as { contents: Array<{ role?: string; parts?: Record[] }> }; + 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(); + }); +});