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
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# 050 — Gemini thought signature survives the Responses round trip (#1735)

## Problem

Gemini issues a `thoughtSignature` on the exact part that carries a function call, and the next
request is only valid when that signature returns on the part rebuilt from that same call.

Today the signature is observed on the response and then lost: the adapter event carries only
`id`/`name`, the Responses `function_call` item has nowhere to put it, and the parser rebuilds an
`OcxToolCall` without it. A same-process replay cache hides this for streaming turns, which is why
the bug reads as intermittent — history replay and `previous_response_id` have no cache to fall
back on.

PR #1772 repaired one web-search reconstruction. The roadmap's own instruction is that a
web-search-only patch is not acceptable, because every other tool loop rebuilds calls the same way.

## Approach

Carry the metadata with the individual tool call, at every hop it already travels:

1. `OcxProviderOpaqueToolCallMetadata` on `OcxToolCall` and on `tool_call_start`.
2. `src/responses/provider-opaque-metadata.ts` — the single seam that reads and writes the wire
shape `extra_content.google.thought_signature`.
3. Google adapter attaches metadata from the originating part (streaming and buffered), and on
the outbound side prefers it over the legacy field.
4. Responses schema models the bounded nested shape; parser reads it back.
5. Bridge emits it on the `function_call` item so a client can round-trip it.

Values are opaque: never parsed, merged, re-encoded, or synthesized. One upstream part maps to one
event, one Responses item, one internal call, one rebuilt part.

## Deliberately out of scope for this cycle

The audit also found two adjacent defects that are **not** #1735 and must not ride along:

- Google-issued `functionCall.id` is discarded and replaced with a synthetic `call_*`. The
generated call/response ids still match each other, so pairing works; preserving Google's exact
id is a separate contract change with its own blast radius.
- `src/web-search/loop.ts` interleaves parallel calls as `FC1, FR1, FC2, FR2` where Google requires
`FC1, FC2, FR1, FR2`. That is a message-ordering fix in the sidecar loop, independent of whether
a signature is carried.

Both are recorded here so the next cycle can pick them up deliberately rather than by accident.

## Acceptance

A signature observed on a Google function-call part is present on the Responses `function_call`
item, survives parsing back into the tool call, and is re-attached to the rebuilt part — with no
dependency on the replay cache. Legacy callers that still set `thoughtSignature` keep working.
34 changes: 31 additions & 3 deletions src/adapters/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
OcxContentPart,
OcxParsedRequest,
OcxProviderConfig,
OcxProviderOpaqueToolCallMetadata,
OcxTextContent,
OcxToolCall,
OcxUsage,
Expand Down Expand Up @@ -209,7 +210,10 @@ function messagesToGeminiFormat(
// conversion 400s. Gemini accepts the optional id and pairs call/response by it.
if (callId !== undefined) functionCall.id = callId;
const part: Record<string, unknown> = { functionCall };
if (isLikelyRealThoughtSignature(tc.thoughtSignature)) part.thoughtSignature = tc.thoughtSignature;
// Prefer the metadata that travelled with this exact call; fall back to the legacy
// field for callers that have not been migrated. Never merge or synthesize.
const signature = tc.providerMetadata?.google?.thoughtSignature ?? tc.thoughtSignature;
if (isLikelyRealThoughtSignature(signature)) part.thoughtSignature = signature;
parts.push(part);
}
}
Expand Down Expand Up @@ -326,9 +330,23 @@ function artifactMarkdownUrl(filePath: string): string {
interface GoogleResponsePart {
text?: string;
thought?: boolean;
thoughtSignature?: string;
functionCall?: { name: string; args: unknown };
}

/**
* Carry a Gemini thought signature with the exact function-call part that produced it. Google
* validates the signature against that specific part, so it must ride the individual tool call
* rather than be re-matched by name/arguments later (issue #1735).
*/
function googleToolCallMetadataFromPart(
part: GoogleResponsePart,
): { providerMetadata: OcxProviderOpaqueToolCallMetadata } | undefined {
const signature = part.thoughtSignature;
if (!isLikelyRealThoughtSignature(signature)) return undefined;
return { providerMetadata: { google: { thoughtSignature: signature } } };
}

/**
* Google marks model-internal reasoning as a normal text-bearing part plus `thought: true`.
* Keep that provider visibility bit authoritative here so the streaming and buffered parsers
Expand Down Expand Up @@ -674,7 +692,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
const id = `call_${crypto.randomUUID().slice(0, 8)}`;
toolCallsStarted++;
emittedContentEvent = true;
yield { type: "tool_call_start", id, name: restoreGoogleToolName(part.functionCall.name) };
yield {
type: "tool_call_start",
id,
name: restoreGoogleToolName(part.functionCall.name),
...googleToolCallMetadataFromPart(part),
};
yield { type: "tool_call_delta", arguments: JSON.stringify(part.functionCall.args ?? {}) };
yield { type: "tool_call_end" };
}
Expand Down Expand Up @@ -890,7 +913,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
if (part.functionCall) {
const id = `call_${crypto.randomUUID().slice(0, 8)}`;
toolCallsStarted++;
events.push({ type: "tool_call_start", id, name: restoreGoogleToolName(part.functionCall.name) });
events.push({
type: "tool_call_start",
id,
name: restoreGoogleToolName(part.functionCall.name),
...googleToolCallMetadataFromPart(part),
});
events.push({ type: "tool_call_delta", arguments: JSON.stringify(part.functionCall.args ?? {}) });
events.push({ type: "tool_call_end" });
}
Expand Down
17 changes: 15 additions & 2 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
AdapterEvent,
OcxMessagePhase,
OcxProviderContinuationState,
OcxProviderOpaqueToolCallMetadata,
OcxReasoningReplayScopeRef,
OcxUsage,
} from "./types";
Expand All @@ -10,6 +11,7 @@ import { adapterFailureFromMessage, classifyError, CYBER_POLICY_ERROR_CODE, isCy
import { encodeCompactionSummary } from "./responses/compaction";
import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope";
import { rememberReasoningForCall } from "./responses/reasoning-replay-cache";
import { responsesExtraContentFromProviderMetadata } from "./responses/provider-opaque-metadata";
import { resolveStallTimeoutSec } from "./stall-timeout";
import { usageDisplayTotalTokens } from "./usage/totals";
import {
Expand Down Expand Up @@ -496,7 +498,7 @@ export function bridgeToResponsesSSE(
// synthetic compaction item's payload on done.
let compactionText = "";
let compactionTextBytes = 0;
let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; argsBytes: number; namespace?: string; freeform?: boolean; toolSearch?: boolean; inputEmitted?: string } | null = null;
let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; argsBytes: number; namespace?: string; freeform?: boolean; toolSearch?: boolean; inputEmitted?: string; providerMetadata?: OcxProviderOpaqueToolCallMetadata } | null = null;
// Open native web-search cell (between begin and end). Holds the output index allocated on
// begin so the matching done reuses it; closed as `failed` if the stream terminates early.
let currentWebSearch: { itemId: string; eventId: string; outputIndex: number } | null = null;
Expand Down Expand Up @@ -621,6 +623,9 @@ export function bridgeToResponsesSSE(
call_id: currentToolCall.callId, name: currentToolCall.name,
arguments: argsStr, status: "completed",
...(currentToolCall.namespace ? { namespace: currentToolCall.namespace } : {}),
// Provider-opaque metadata (issue #1735) rides the item so a client that replays
// this history can hand the signature back on the part it belongs to.
...(responsesExtraContentFromProviderMetadata(currentToolCall.providerMetadata) ?? {}),

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 on custom and tool-search calls

When Gemini invokes a declared custom tool such as apply_patch, or the client-side tool_search tool, the bridge selects one of the preceding branches, so this metadata spread never runs; the buffered path has the same omission. These tools are exposed to Gemini as function declarations and their calls can therefore carry a required thoughtSignature, but the resulting custom_tool_call/tool_search_call item and parser discard it. A direct-Gemini follow-up or cold history replay consequently sends the call back unsigned and can be rejected upstream. Attach the bounded metadata to these item variants as well and restore it when parsing them into OcxToolCall.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

};
emit("response.output_item.done", { output_index: currentToolCall.outputIndex, item });
retainFinishedItem(item as OutputItem);
Expand Down Expand Up @@ -655,6 +660,10 @@ export function bridgeToResponsesSSE(
call_id: currentToolCall.callId, name: currentToolCall.name,
arguments: argsStr, status: "incomplete",
...(currentToolCall.namespace ? { namespace: currentToolCall.namespace } : {}),
// An incomplete call can still be persisted and replayed (max_output_tokens), so it
// carries the same metadata as the completed item — otherwise SSE and buffered JSON
// would disagree about whether the signature survives.
...(responsesExtraContentFromProviderMetadata(currentToolCall.providerMetadata) ?? {}),
};
emit("response.output_item.done", { output_index: currentToolCall.outputIndex, item });
retainFinishedItem(item as OutputItem);
Expand Down Expand Up @@ -1013,7 +1022,7 @@ export function bridgeToResponsesSSE(
? { type: "custom_tool_call", id: itemId, call_id: event.id, name: realName, input: "", status: "in_progress" }
: { type: "function_call", id: itemId, call_id: event.id, name: realName, arguments: "", status: "in_progress", ...(ns ? { namespace: ns } : {}) };
emit("response.output_item.added", { output_index: outputIndex, item });
currentToolCall = { itemId, outputIndex, callId: event.id, name: realName, args: "", argsBytes: 0, namespace: ns, freeform, toolSearch };
currentToolCall = { itemId, outputIndex, callId: event.id, name: realName, args: "", argsBytes: 0, namespace: ns, freeform, toolSearch, providerMetadata: event.providerMetadata };
budget?.openCall(event.id);
break;
}
Expand Down Expand Up @@ -1476,6 +1485,7 @@ function buildResponseJSONWithBudget(
let currentToolCallId = "";
let currentToolCallName = "";
let currentToolCallArgs = "";
let currentToolCallProviderMetadata: OcxProviderOpaqueToolCallMetadata | undefined;
let currentToolCallArgsBytes = 0;
// Web-search citations awaiting the next assistant message (attached as url_citation annotations).
let pendingWebSources: { url: string; title?: string }[] = [];
Expand Down Expand Up @@ -1584,11 +1594,13 @@ function buildResponseJSONWithBudget(
call_id: currentToolCallId, name: realName,
arguments: coercedArgs || "{}", status,
...(ns ? { namespace: ns } : {}),
...(responsesExtraContentFromProviderMetadata(currentToolCallProviderMetadata) ?? {}),
});
}
budget?.closeCall(currentToolCallId);
currentToolCallId = "";
currentToolCallName = "";
currentToolCallProviderMetadata = undefined;
currentToolCallArgs = "";
currentToolCallArgsBytes = 0;
};
Expand Down Expand Up @@ -1703,6 +1715,7 @@ function buildResponseJSONWithBudget(
currentToolCallName = e.name;
currentToolCallArgs = "";
currentToolCallArgsBytes = 0;
currentToolCallProviderMetadata = e.providerMetadata;
break;
case "tool_call_delta":
{
Expand Down
20 changes: 15 additions & 5 deletions src/images/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "../adapters/
import { existsSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { createAdapterEventQueue } from "../adapters/run-turn-queue";
import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderContinuationState, OcxRequestOptions, OcxThinkingContent, OcxUsage, RateLimitRetryPolicy } from "../types";
import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderContinuationState, OcxProviderOpaqueToolCallMetadata, OcxRequestOptions, OcxThinkingContent, OcxUsage, RateLimitRetryPolicy } from "../types";
import { namespacedToolName, toolChoiceToolPredicate } from "../types";
import { cloneProviderOpaqueToolCallMetadata } from "../responses/provider-opaque-metadata";
import type { AttemptRecoveryKind } from "../usage/log";
import { bridgeToResponsesSSE } from "../bridge";
import { clearableDeadline, idleDeadline } from "../lib/abort";
Expand Down Expand Up @@ -93,6 +94,11 @@ interface ImageCall {
id: string;
name: string;
args: string;
/**
* Provider-opaque metadata from the originating part (issue #1735). Stored PER CALL: a
* signature belongs to one specific part, so parallel calls must not share one value.
*/
providerMetadata?: OcxProviderOpaqueToolCallMetadata;
}

/**
Expand All @@ -109,13 +115,13 @@ function scanEventsForImageCall(events: AdapterEvent[], toolNames: Set<string>):
const calls: ImageCall[] = [];
const passthrough: AdapterEvent[] = [];
let hasRealToolCall = false;
let pending: { name: string; id: string; argsBuf: string; events: AdapterEvent[] } | null = null;
let pending: { name: string; id: string; argsBuf: string; events: AdapterEvent[]; providerMetadata?: OcxProviderOpaqueToolCallMetadata } | null = null;
const flushPending = (): void => {
if (!pending) return;
if (toolNames.has(pending.name)) {
// Unterminated image call still carries buffered args — fulfill so malformed JSON
// becomes a normal tool_result error instead of silently vanishing.
calls.push({ id: pending.id, name: pending.name, args: pending.argsBuf });
calls.push({ id: pending.id, name: pending.name, args: pending.argsBuf, providerMetadata: pending.providerMetadata });
} else {
passthrough.push(...pending.events);
hasRealToolCall = true;
Expand All @@ -125,14 +131,14 @@ function scanEventsForImageCall(events: AdapterEvent[], toolNames: Set<string>):
for (const e of events) {
if (e.type === "tool_call_start") {
flushPending();
pending = { name: e.name, id: e.id, argsBuf: "", events: [e] };
pending = { name: e.name, id: e.id, argsBuf: "", events: [e], providerMetadata: e.providerMetadata };
} else if (e.type === "tool_call_delta" && pending) {
pending.argsBuf += e.arguments;
pending.events.push(e);
} else if (e.type === "tool_call_end" && pending) {
pending.events.push(e);
if (toolNames.has(pending.name)) {
calls.push({ id: pending.id, name: pending.name, args: pending.argsBuf });
calls.push({ id: pending.id, name: pending.name, args: pending.argsBuf, providerMetadata: pending.providerMetadata });
} else {
passthrough.push(...pending.events);
hasRealToolCall = true;
Expand Down Expand Up @@ -871,6 +877,10 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
id: call.id,
name: call.name,
arguments: args,
// Clone per call: parallel media calls each keep their own signature.
...(cloneProviderOpaqueToolCallMetadata(call.providerMetadata)
? { providerMetadata: cloneProviderOpaqueToolCallMetadata(call.providerMetadata) }
: {}),
})),
],
timestamp: now,
Expand Down
8 changes: 7 additions & 1 deletion src/responses/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
} from "../types";
import { namespacedToolName } from "../types";
import { responsesRequestSchema } from "./schema";
import { providerMetadataFromResponsesFunctionCall } from "./provider-opaque-metadata";
import { compactionItemToText } from "./compaction";
import { previousResponseReplayPrefixLength } from "./state";
import { decodeReasoningEnvelope } from "./reasoning-envelope";
Expand Down Expand Up @@ -498,7 +499,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
}

if (effectiveType === "function_call") {
const call = item as { id?: string; call_id: string; name: string; arguments?: string; namespace?: string };
const call = item as { id?: string; call_id: string; name: string; arguments?: string; namespace?: string; extra_content?: unknown };
// Tolerate empty/non-JSON arguments (e.g. a no-arg tool call serialized as "") instead of
// throwing — a single poisoned history item would otherwise 400 every subsequent turn.
let args: Record<string, unknown> = {};
Expand All @@ -519,6 +520,11 @@ export function parseRequest(body: unknown): OcxParsedRequest {
type: "toolCall", id: call.call_id, name: call.name, arguments: args,
...(call.namespace ? { namespace: call.namespace } : {}),
};
// Provider-opaque metadata (e.g. a Gemini thought signature) travels with the call so a
// history-replayed or previous_response_id turn rebuilds the same signed part instead of
// depending on the same-process replay cache (issue #1735).
const providerMetadata = providerMetadataFromResponsesFunctionCall(call);
if (providerMetadata) toolCall.providerMetadata = providerMetadata;
assistantHolderWithReasoning().content.push(toolCall);
continue;
}
Expand Down
Loading
Loading