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
21 changes: 16 additions & 5 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ 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 {
rememberAndSerializeExtraContent,
rememberExtraContentForReplay,
} from "./responses/thought-signature-replay";
import { resolveStallTimeoutSec } from "./stall-timeout";
import { usageDisplayTotalTokens } from "./usage/totals";
import {
Expand Down Expand Up @@ -606,6 +609,9 @@ export function bridgeToResponsesSSE(
input: freeformInput(currentToolCall.args),
});
}
// Freeform tools serialize as custom_tool_call without extra_content; remember the
// signature server-side regardless so the replayed call can be re-signed (#1735).
void rememberExtraContentForReplay(currentToolCall.callId, currentToolCall.providerMetadata, replayCacheScope);
const item = currentToolCall.toolSearch
? {
type: "tool_search_call", id: currentToolCall.itemId,
Expand All @@ -624,8 +630,9 @@ export function bridgeToResponsesSSE(
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) ?? {}),
// this history can hand the signature back on the part it belongs to. The proxy
// also remembers it server-side for clients that never echo extra_content.
...(rememberAndSerializeExtraContent(currentToolCall.callId, currentToolCall.providerMetadata, replayCacheScope).extra ?? {}),
};
emit("response.output_item.done", { output_index: currentToolCall.outputIndex, item });
retainFinishedItem(item as OutputItem);
Expand All @@ -643,6 +650,7 @@ export function bridgeToResponsesSSE(
const failCurrentToolCall = () => {
if (!currentToolCall) return;
const argsStr = currentToolCall.args || "{}";
void rememberExtraContentForReplay(currentToolCall.callId, currentToolCall.providerMetadata, replayCacheScope);
const item = currentToolCall.toolSearch
? {
type: "tool_search_call", id: currentToolCall.itemId,
Expand All @@ -663,7 +671,7 @@ export function bridgeToResponsesSSE(
// 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) ?? {}),
...(rememberAndSerializeExtraContent(currentToolCall.callId, currentToolCall.providerMetadata, replayCacheScope).extra ?? {}),
};
emit("response.output_item.done", { output_index: currentToolCall.outputIndex, item });
retainFinishedItem(item as OutputItem);
Expand Down Expand Up @@ -1576,6 +1584,9 @@ function buildResponseJSONWithBudget(
currentToolCallArgs,
options?.toolParameterSchemas?.get(currentToolCallName),
);
// Freeform tools serialize as custom_tool_call without extra_content; remember the
// signature server-side regardless so the replayed call can be re-signed (#1735).
void rememberExtraContentForReplay(currentToolCallId, currentToolCallProviderMetadata, replayCacheScope);
if (toolSearch) {
pushOutput({
type: "tool_search_call", id: `tsc_${uuid()}`,
Expand All @@ -1594,7 +1605,7 @@ function buildResponseJSONWithBudget(
call_id: currentToolCallId, name: realName,
arguments: coercedArgs || "{}", status,
...(ns ? { namespace: ns } : {}),
...(responsesExtraContentFromProviderMetadata(currentToolCallProviderMetadata) ?? {}),
...(rememberAndSerializeExtraContent(currentToolCallId, currentToolCallProviderMetadata, replayCacheScope).extra ?? {}),
});
}
budget?.closeCall(currentToolCallId);
Expand Down
1 change: 1 addition & 0 deletions src/lib/config-ownership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const INITIAL_OWNED_PATHS = [
"service-state.json",
"service.log",
"system-env-port",
"thought-signature-replay.json",
"tray-heartbeat.json",
"tray-state.json",
"update-job.json",
Expand Down
37 changes: 34 additions & 3 deletions src/responses/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import type {
OcxThinkingContent,
OcxTool,
OcxToolCall,
OcxReasoningReplayScopeRef,
} from "../types";
import { namespacedToolName } from "../types";
import { responsesRequestSchema } from "./schema";
import { providerMetadataFromResponsesFunctionCall } from "./provider-opaque-metadata";
import { lookupReplayThoughtSignature } from "./thought-signature-replay";
import { compactionItemToText } from "./compaction";
import { previousResponseReplayPrefixLength } from "./state";
import { decodeReasoningEnvelope } from "./reasoning-envelope";
Expand All @@ -23,6 +25,21 @@ function isObj(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}

/**
* Wrap a remembered proxy-side signature as provider metadata for a replayed tool call.
*
* The scope is REQUIRED for a hit. `parseRequest` runs before the route and account are
* chosen, so a caller that has not yet bound a replay scope gets nothing rather than a
* signature belonging to some other thread that happened to reuse the same `call_id`.
*/
function replayThoughtSignatureMetadata(
callId: string,
scope: OcxReasoningReplayScopeRef | undefined,
): { google: { thoughtSignature: string } } | undefined {
const signature = lookupReplayThoughtSignature(callId, scope);
return signature ? { google: { thoughtSignature: signature } } : undefined;
}

type InputBlock =
| { type: "input_text"; text: string }
| { type: "text"; text: string }
Expand Down Expand Up @@ -294,7 +311,11 @@ function attachPendingReasoningToCallOwner(

const REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]);

export function parseRequest(body: unknown): OcxParsedRequest {
export function parseRequest(
body: unknown,
parseOptions?: { replayCacheScope?: OcxReasoningReplayScopeRef },
): OcxParsedRequest {
const replayCacheScope = parseOptions?.replayCacheScope;
const replayedInputPrefixLength = previousResponseReplayPrefixLength(body);
const parsed = responsesRequestSchema.safeParse(body);
if (!parsed.success) {
Expand Down Expand Up @@ -522,19 +543,25 @@ export function parseRequest(body: unknown): OcxParsedRequest {
};
// 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);
// depending on the same-process replay cache (issue #1735). Real clients do not echo
// extra_content on replay, so fall back to the proxy-side store keyed by call_id.
const providerMetadata = providerMetadataFromResponsesFunctionCall(call)
?? (typeof call.call_id === "string"
? replayThoughtSignatureMetadata(call.call_id, replayCacheScope)
: undefined);
if (providerMetadata) toolCall.providerMetadata = providerMetadata;
assistantHolderWithReasoning().content.push(toolCall);
continue;
}

if (effectiveType === "custom_tool_call") {
const call = item as { id?: string; call_id: string; name: string; input: string };
const remembered = typeof call.call_id === "string" ? replayThoughtSignatureMetadata(call.call_id, replayCacheScope) : undefined;
const toolCall: OcxToolCall = {
type: "toolCall", id: call.call_id, name: call.name,
arguments: { input: call.input ?? "" },
customWireName: call.name,
...(remembered ? { providerMetadata: remembered } : {}),
};
assistantHolderWithReasoning().content.push(toolCall);
continue;
Expand All @@ -547,9 +574,11 @@ export function parseRequest(body: unknown): OcxParsedRequest {
const callId = call.call_id ?? call.id;
if (callId) {
const command = Array.isArray(call.action?.command) ? call.action.command : [];
const remembered = replayThoughtSignatureMetadata(callId, replayCacheScope);
assistantHolderWithReasoning().content.push({
type: "toolCall", id: callId, name: "shell",
arguments: command.length > 0 ? { command } : {},
...(remembered ? { providerMetadata: remembered } : {}),
});
}
continue;
Expand All @@ -568,9 +597,11 @@ export function parseRequest(body: unknown): OcxParsedRequest {
// history stays complete (otherwise the model re-issues tool_search forever).
const call = item as { id?: string; call_id?: string; arguments?: unknown };
const callId = call.call_id ?? call.id ?? "";
const remembered = callId ? replayThoughtSignatureMetadata(callId, replayCacheScope) : undefined;
assistantHolderWithReasoning().content.push({
type: "toolCall", id: callId, name: "tool_search",
arguments: isObj(call.arguments) ? call.arguments : {},
...(remembered ? { providerMetadata: remembered } : {}),
});
continue;
}
Expand Down
2 changes: 1 addition & 1 deletion src/responses/provider-opaque-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function isObj(value: unknown): value is Record<string, unknown> {
*/
const MAX_SIGNATURE_BYTES = 64 * 1024;

function isCarryableSignature(value: unknown): value is string {
export function isCarryableSignature(value: unknown): value is string {
if (typeof value !== "string" || value.length === 0) return false;
// Cheap length pre-check: UTF-8 is at most 3 bytes per UTF-16 code unit for the BMP, so this
// skips the encode for the overwhelmingly common short case.
Expand Down
Loading
Loading