diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index c97bd7bae4..e11b31055f 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -125,6 +125,10 @@ of the HTTP retry loop. frame, and a buffered response that carries no candidate at all returns `google response contained no candidates`. A root `data: null` keepalive frame is still skipped as padding. +- Tool-call batches are closed by one immediately adjacent user turn containing one ordered + `functionResponse` per representable call. Interrupted histories receive an explicit missing-result marker; + duplicate or standalone results are preserved as marked text (and image siblings) rather than + emitted as invalid unpaired `functionResponse` parts. - **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/src/adapters/google.ts b/src/adapters/google.ts index a24cb33b39..eed951b89f 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -126,6 +126,7 @@ function toolResultImageParts(content: string | OcxContentPart[]): unknown[] { */ const GEMINI_EMPTY_PLACEHOLDER = "(empty)"; const GEMINI_EMPTY_TOOL_OUTPUT_PLACEHOLDER = "(empty tool output)"; +const GEMINI_MISSING_TOOL_RESULT = "[missing tool_result for this tool_use in history]"; /** A Gemini text part, or undefined when the value cannot form a valid non-empty text block. */ function geminiTextPart(text: unknown): { text: string } | undefined { @@ -144,6 +145,42 @@ function geminiToolResultText(content: string | OcxContentPart[]): string { return hasContent ? contentPartsToText(content) : GEMINI_EMPTY_TOOL_OUTPUT_PLACEHOLDER; } +function geminiToolResultParts( + msg: OcxToolResultMessage, + wireName: string, + wireCallId: string, +): unknown[] { + const functionResponse: Record = { + name: wireName, + response: { result: geminiToolResultText(msg.content) }, + id: wireCallId, + }; + return [{ functionResponse }, ...toolResultImageParts(msg.content)]; +} + +function geminiMissingToolResultPart(wireName: string, wireCallId: string): unknown { + return { + functionResponse: { + name: wireName, + response: { result: GEMINI_MISSING_TOOL_RESULT }, + id: wireCallId, + }, + }; +} + +function geminiUnrepresentableToolCallPart(tc: OcxToolCall, wireName: string): unknown { + const args = typeof tc.arguments === "string" ? tc.arguments : JSON.stringify(tc.arguments); + return { text: `[tool_use without a usable id: ${wireName}]\n${args}` }; +} + +function geminiOrphanToolResultParts(msg: OcxToolResultMessage): unknown[] { + const label = msg.toolName ? `${msg.toolName} (${msg.toolCallId})` : msg.toolCallId; + return [ + { text: `[tool_result without adjacent tool_use: ${label}]\n${geminiToolResultText(msg.content)}` }, + ...toolResultImageParts(msg.content), + ]; +} + function messagesToGeminiFormat( parsed: OcxParsedRequest, identityModelId: string, @@ -170,7 +207,8 @@ function messagesToGeminiFormat( callIds.reserve((msg as OcxToolResultMessage).toolCallId); } } - for (const msg of parsed.context.messages) { + for (let i = 0; i < parsed.context.messages.length; i++) { + const msg = parsed.context.messages[i]; switch (msg.role) { case "user": case "developer": { @@ -197,6 +235,7 @@ function messagesToGeminiFormat( case "assistant": { const aMsg = msg as OcxAssistantMessage; const parts: unknown[] = []; + const toolCalls: Array<{ wireCallId: string; wireName: string }> = []; for (const p of aMsg.content) { if (p.type === "text") { const textPart = geminiTextPart((p as OcxTextContent).text); @@ -209,10 +248,19 @@ function messagesToGeminiFormat( // Responses parser also stashes synthetic item ids (`fc_...`) on this field, and sending // those as a thoughtSignature breaks continuity (the replay cache supplies the real one). const callId = callIds.allocate(tc.id); - const functionCall: Record = { name: namespacedToolName(tc.namespace, tc.name), args: tc.arguments }; + const wireName = namespacedToolName(tc.namespace, tc.name); + if (callId === undefined) { + // Claude-on-Antigravity requires a usable id for every translated tool_use. An empty + // source id cannot be paired safely, so preserve the call as text and let its result + // follow the same orphan-text path instead of emitting an invalid functionCall. + parts.push(geminiUnrepresentableToolCallPart(tc, wireName)); + continue; + } + const functionCall: Record = { name: wireName, args: tc.arguments }; // Claude-on-Antigravity maps this id to Anthropic `tool_use.id`; without it the upstream // conversion 400s. Gemini accepts the optional id and pairs call/response by it. - if (callId !== undefined) functionCall.id = callId; + functionCall.id = callId; + toolCalls.push({ wireCallId: callId, wireName }); const part: Record = { functionCall }; // 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. @@ -232,22 +280,44 @@ function messagesToGeminiFormat( // adapter does for its own empty assistant content. if (parts.length === 0) break; contents.push({ role: "model", parts }); + if (toolCalls.length > 0) { + // Gemini/Claude-on-Antigravity requires one adjacent response batch for the whole + // function-call turn. Replayed histories can be interrupted, reversed, duplicated, or + // contain an orphan result; repair only this wire boundary without inventing success. + const requiredIds = new Set(toolCalls.map(call => call.wireCallId)); + const resultsById = new Map(); + const orphanResults: OcxToolResultMessage[] = []; + let j = i + 1; + while (j < parsed.context.messages.length && parsed.context.messages[j].role === "toolResult") { + const result = parsed.context.messages[j] as OcxToolResultMessage; + const wireResultId = callIds.lookup(result.toolCallId); + if (wireResultId !== undefined && requiredIds.has(wireResultId) && !resultsById.has(wireResultId)) { + resultsById.set(wireResultId, result); + } else { + orphanResults.push(result); + } + j++; + } + + const responseParts: unknown[] = []; + for (const call of toolCalls) { + const result = resultsById.get(call.wireCallId); + if (result) responseParts.push(...geminiToolResultParts(result, call.wireName, call.wireCallId)); + else responseParts.push(geminiMissingToolResultPart(call.wireName, call.wireCallId)); + } + for (const orphan of orphanResults) { + responseParts.push(...geminiOrphanToolResultParts(orphan)); + } + contents.push({ role: "user", parts: responseParts }); + i = j - 1; + } break; } case "toolResult": { - // The functionResponse part carries the textual result. Gemini cannot embed images inside a - // functionResponse, but it does accept sibling inline_data parts in the same user turn, so - // tool-result screenshots (e.g. Computer Use) ride along as inline_data instead of being - // flattened to a "[image]" marker the model can't actually see. - // lookup(), not allocate(): a response must reuse its call's id and must never mint a new one. - const responseId = callIds.lookup(msg.toolCallId); - const functionResponse: Record = { name: namespacedToolName(msg.toolNamespace, msg.toolName), response: { result: geminiToolResultText(msg.content) } }; - // Mirror the matching functionCall id so Claude-on-Antigravity can pair this result with its - // `tool_use` block (-> Anthropic `tool_result.tool_use_id`). - if (responseId !== undefined) functionResponse.id = responseId; - const parts: unknown[] = [{ functionResponse }]; - for (const part of toolResultImageParts(msg.content)) parts.push(part); - contents.push({ role: "user", parts }); + // A standalone functionResponse is invalid without an immediately preceding matching + // functionCall batch. Preserve the result as explicit user text (plus any representable + // image siblings) rather than manufacturing a successful call or sending a 400-prone shape. + contents.push({ role: "user", parts: geminiOrphanToolResultParts(msg as OcxToolResultMessage) }); break; } } diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index d63e594b05..a39a2d005b 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -572,6 +572,33 @@ so matching uses the provider-visible tool name. - 다른 대안 대신 이 방식을 선택한 이유: 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. +## Google tool-result adjacency repair + +Google-family requests serialize a model tool-call turn and its results as one adjacent +`model -> user` pair. The user turn contains exactly one `functionResponse` for every representable +call in original call order. Missing results use an explicit unknown-history marker; duplicate, +mismatched, and standalone results become marked text instead of unpaired function responses. +Representable data-URL images remain sibling `inline_data` parts in either case. + +[Decision Log] +- 목적과 의도: prevent interrupted or replayed Claude-on-Antigravity histories from reaching the + Google wire with unanswered `functionCall` or unpaired `functionResponse` parts. +- 기존 구현 및 제약 조건: `messagesToGeminiFormat` emitted every internal message independently; + Antigravity translates the resulting Gemini shape back into strict Anthropic tool-use blocks, and + rejects malformed adjacency with HTTP 400. Tool-result images cannot live inside a + `functionResponse` and already rely on sibling `inline_data` parts. +- 검토한 주요 대안: repair the shared internal history; synthesize fake calls for orphan results; + repair only the Google adapter serialization boundary. +- 선택한 방식: group only consecutive results after a model call batch, match by the normalized + request-scoped call id, emit responses in call order, synthesize an explicit missing result, and + degrade remaining results to marked text while retaining image siblings. +- 다른 대안 대신 이 방식을 선택한 이유: shared-history mutation could change other adapters, + while fabricating a successful call would invent model behavior. The adapter boundary owns the + strict upstream wire contract and can repair it without changing client-visible history. +- 장점, 단점 및 영향: normal histories remain byte-shape equivalent, parallel and interrupted + histories become provider-valid, and orphan data is not lost. A result separated by a non-tool + barrier is intentionally not reattached across that boundary. + ## OpenRouter provider routing The canonical OpenRouter `openai-chat` transport may carry optional provider-routing preferences diff --git a/tests/google-tool-result-adjacency.test.ts b/tests/google-tool-result-adjacency.test.ts new file mode 100644 index 0000000000..796136e96b --- /dev/null +++ b/tests/google-tool-result-adjacency.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, test } from "bun:test"; +import { createGoogleAdapter } from "../src/adapters/google"; +import type { OcxContentPart, OcxMessage, OcxParsedRequest } from "../src/types"; + +const provider = { adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "key" }; + +interface GeminiTurn { + role: "user" | "model"; + parts: Array>; +} + +function assistant(calls: Array<{ id: string; name: string }>): OcxMessage { + return { + role: "assistant", + content: calls.map(call => ({ type: "toolCall", id: call.id, name: call.name, arguments: {} })), + timestamp: 0, + }; +} + +function result(id: string, name: string, content: string | OcxContentPart[] = "ok"): OcxMessage { + return { + role: "toolResult", + toolCallId: id, + toolName: name, + content, + isError: false, + timestamp: 0, + } as OcxMessage; +} + +function user(text: string): OcxMessage { + return { role: "user", content: text, timestamp: 0 }; +} + +async function wire(messages: OcxMessage[]): Promise { + const parsed = { + modelId: "claude-opus-4.8", + stream: false, + options: {}, + context: { messages }, + } as OcxParsedRequest; + const built = await createGoogleAdapter(provider).buildRequest(parsed); + return (JSON.parse(built.body) as { contents: GeminiTurn[] }).contents; +} + +function functionResponses(turn: GeminiTurn): Array> { + return turn.parts + .filter(part => "functionResponse" in part) + .map(part => part.functionResponse as Record); +} + +describe("Google adapter tool-result adjacency repair (#2199)", () => { + test("normal call/result history stays one adjacent model/user pair", async () => { + const contents = await wire([ + assistant([{ id: "call_1", name: "bash" }]), + result("call_1", "bash", "done"), + ]); + + expect(contents).toHaveLength(2); + expect(contents.map(turn => turn.role)).toEqual(["model", "user"]); + expect(functionResponses(contents[1])).toEqual([ + { name: "bash", response: { result: "done" }, id: "call_1" }, + ]); + }); + + test("missing results are synthesized before the next non-tool turn", async () => { + const contents = await wire([ + assistant([{ id: "call_missing", name: "exec_command" }]), + user("continue"), + ]); + + expect(contents.map(turn => turn.role)).toEqual(["model", "user", "user"]); + expect(functionResponses(contents[1])).toEqual([ + { + name: "exec_command", + response: { result: "[missing tool_result for this tool_use in history]" }, + id: "call_missing", + }, + ]); + expect(contents[2].parts).toEqual([{ text: "continue" }]); + }); + + test("parallel responses are emitted in call order even when history is reversed", async () => { + const contents = await wire([ + assistant([{ id: "call_1", name: "first" }, { id: "call_2", name: "second" }]), + result("call_2", "second", "two"), + result("call_1", "first", "one"), + ]); + + expect(contents).toHaveLength(2); + expect(functionResponses(contents[1])).toEqual([ + { name: "first", response: { result: "one" }, id: "call_1" }, + { name: "second", response: { result: "two" }, id: "call_2" }, + ]); + }); + + test("duplicate and mismatched results become marked text after the complete response batch", async () => { + const contents = await wire([ + assistant([{ id: "call_1", name: "bash" }, { id: "call_2", name: "read" }]), + result("call_1", "bash", "first result"), + result("call_1", "bash", "duplicate result"), + result("call_orphan", "mystery", "orphan result"), + ]); + + const responseTurn = contents[1]; + expect(functionResponses(responseTurn)).toEqual([ + { name: "bash", response: { result: "first result" }, id: "call_1" }, + { + name: "read", + response: { result: "[missing tool_result for this tool_use in history]" }, + id: "call_2", + }, + ]); + const text = responseTurn.parts.filter(part => "text" in part).map(part => part.text); + expect(text).toEqual([ + "[tool_result without adjacent tool_use: bash (call_1)]\nduplicate result", + "[tool_result without adjacent tool_use: mystery (call_orphan)]\norphan result", + ]); + }); + + test("standalone image-bearing results stay visible without a fabricated functionResponse", async () => { + const contents = await wire([ + result("call_orphan", "snapshot", [ + { type: "text", text: "screen" }, + { type: "image", imageUrl: "data:image/png;base64,aGVsbG8=" }, + ]), + ]); + + expect(contents).toHaveLength(1); + expect(functionResponses(contents[0])).toEqual([]); + expect(contents[0].parts[0]).toEqual({ + text: "[tool_result without adjacent tool_use: snapshot (call_orphan)]\nscreen[image]", + }); + expect(contents[0].parts[1]).toEqual({ + inline_data: { mime_type: "image/png", data: "aGVsbG8=" }, + }); + }); + + test("standalone remote-image results keep their marker without fabricating inline data", async () => { + const contents = await wire([ + result("call_orphan", "snapshot", [ + { type: "image", imageUrl: "https://example.com/screenshot.png" }, + ]), + ]); + + expect(contents).toHaveLength(1); + expect(functionResponses(contents[0])).toEqual([]); + expect(contents[0].parts).toEqual([ + { text: "[tool_result without adjacent tool_use: snapshot (call_orphan)]\n[image]" }, + ]); + expect(JSON.stringify(contents[0].parts)).not.toContain("inline_data"); + expect(JSON.stringify(contents[0].parts)).not.toContain("(empty tool output)"); + }); + + test("a non-tool barrier closes the call before a later result becomes orphan text", async () => { + const contents = await wire([ + assistant([{ id: "call_1", name: "bash" }]), + user("barrier"), + result("call_1", "bash", "late"), + ]); + + expect(contents.map(turn => turn.role)).toEqual(["model", "user", "user", "user"]); + expect(functionResponses(contents[1])[0]).toMatchObject({ id: "call_1" }); + expect(contents[2].parts).toEqual([{ text: "barrier" }]); + expect(contents[3].parts).toEqual([ + { text: "[tool_result without adjacent tool_use: bash (call_1)]\nlate" }, + ]); + }); + + test("a tool call without a usable id degrades to text with its result", async () => { + const contents = await wire([ + assistant([{ id: "", name: "bash" }]), + result("", "bash", "done"), + ]); + + expect(contents).toHaveLength(2); + expect(contents[0]).toEqual({ + role: "model", + parts: [{ text: "[tool_use without a usable id: bash]\n{}" }], + }); + expect(functionResponses(contents[1])).toEqual([]); + expect(contents[1].parts).toEqual([ + { text: "[tool_result without adjacent tool_use: bash ()]\ndone" }, + ]); + }); +});