fix(gemini): preserve structured tool calls for antigravity

This commit is contained in:
OpenClaw
2026-05-25 12:04:44 +03:00
parent ebe0b6607c
commit e62fbb7a75
5 changed files with 234 additions and 16 deletions

View File

@@ -34,6 +34,20 @@ function firstPositiveNumber(...values: unknown[]): number {
return 0;
}
function parseTextualToolCall(text: unknown): { name: string; args: unknown } | null {
if (typeof text !== "string") return null;
const match = text.match(/^\s*\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*([\s\S]+?)\s*$/);
if (!match) return null;
const name = match[1]?.trim();
const rawArgs = match[2]?.trim();
if (!name || !rawArgs) return null;
try {
return { name, args: JSON.parse(rawArgs) };
} catch {
return null;
}
}
function extractMessageOutputText(item: JsonRecord): string {
if (!Array.isArray(item.content)) return "";
let text = "";
@@ -302,8 +316,21 @@ export function translateNonStreamingResponse(
}
if (typeof partObj.text === "string") {
textContent += partObj.text;
contentParts.push({ type: "text", text: partObj.text });
const textualToolCall = parseTextualToolCall(partObj.text);
if (textualToolCall) {
const toolCallId = `call_${toString(textualToolCall.name, "unknown")}_${Date.now()}_${toolCalls.length}`;
toolCalls.push({
id: toolCallId,
type: "function",
function: {
name: textualToolCall.name,
arguments: JSON.stringify(textualToolCall.args || {}),
},
});
} else {
textContent += partObj.text;
contentParts.push({ type: "text", text: partObj.text });
}
}
const inlineData = toRecord(partObj.inlineData ?? partObj.inline_data);

View File

@@ -347,12 +347,18 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName
}
// Check if there are actual tool responses in the next messages
const hasSignaturelessTextResponses =
stringifySignaturelessToolCalls &&
msg.tool_calls.some(
(tc) =>
tc.type === "function" && !resolvedSignatures.has(tc.id) && toolResponses[tc.id]
);
const signaturelessToolCallIds = stringifySignaturelessToolCalls
? msg.tool_calls
.filter(
(tc) =>
tc.type === "function" &&
tc.id &&
!resolvedSignatures.has(tc.id) &&
toolResponses[tc.id]
)
.map((tc) => tc.id)
: [];
const hasSignaturelessTextResponses = signaturelessToolCallIds.length > 0;
const hasActualResponses =
toolCallIds.some((fid) => toolResponses[fid]) || hasSignaturelessTextResponses;
@@ -398,7 +404,7 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName
// functionResponse parts missing a matching thoughtSignature.
for (const tc of msg.tool_calls) {
if (tc.type !== "function" || !tc.id) continue;
if (!resolvedSignatures.has(tc.id) && toolResponses[tc.id]) {
if (signaturelessToolCallIds.includes(tc.id)) {
const name = tcID2Name[tc.id] || tc.function?.name || "unknown";
const resp = toolResponses[tc.id];
toolParts.push({
@@ -605,6 +611,16 @@ function getAntigravityClaudeOutputTokens(body: Record<string, unknown>): number
// OpenAI -> Antigravity (Sandbox Cloud Code with wrapper)
export function openaiToAntigravityRequest(model, body, stream, credentials = null) {
const isClaude = model.toLowerCase().includes("claude");
// All modern Gemini models (2.5+, 3.x, pro-agent, etc.) use thinking by default
// and require thought_signature for multi-turn tool calls.
// Safe default: all non-Claude models via Antigravity are thinking Gemini.
const modelLower = model.toLowerCase();
const isThinkingGemini =
!isClaude &&
(modelLower.includes("thinking") ||
modelLower.includes("gemini-3") ||
modelLower.includes("gemini-2.5") ||
modelLower.includes("gemini-pro"));
const signatureNamespace =
credentials &&
typeof credentials === "object" &&
@@ -613,7 +629,7 @@ export function openaiToAntigravityRequest(model, body, stream, credentials = nu
: null;
const geminiCLI = openaiToGeminiCLIRequest(model, body, stream, {
signatureNamespace,
signaturelessToolCallMode: isClaude ? "native" : "text",
signaturelessToolCallMode: isThinkingGemini ? "text" : "native",
});
if (isClaude) {

View File

@@ -23,6 +23,20 @@ type GeminiFunctionCallPart = {
};
};
function parseTextualToolCall(text: unknown): { name: string; args: unknown } | null {
if (typeof text !== "string") return null;
const match = text.match(/^\s*\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*([\s\S]+?)\s*$/);
if (!match) return null;
const name = match[1]?.trim();
const rawArgs = match[2]?.trim();
if (!name || !rawArgs) return null;
try {
return { name, args: JSON.parse(rawArgs) };
} catch {
return null;
}
}
function buildToolCallId(
functionCall: GeminiFunctionCallPart["functionCall"],
toolName: string,
@@ -169,6 +183,15 @@ export function geminiToOpenAIResponse(chunk, state) {
const hasTextContent = part.text !== undefined && part.text !== "";
const hasFunctionCall = !!part.functionCall;
// Gemini/Antigravity can emit thoughtSignature as a standalone part
// immediately before the functionCall part. Keep it pending so the
// following functionCall is cached and can be re-attached on later
// turns; otherwise OpenAI-format clients lose the signature and the
// next Gemini request has to stringify historical tool calls.
if (hasThoughtSig && !hasTextContent && !hasFunctionCall) {
continue;
}
if (hasTextContent) {
results.push({
id: `chatcmpl-${state.messageId}`,
@@ -191,8 +214,27 @@ export function geminiToOpenAIResponse(chunk, state) {
continue;
}
// Text content (non-thinking)
// Text content (non-thinking). Some Gemini/Antigravity turns can imitate
// the request-side signatureless history fallback and emit a textual
// "[Tool call: ...]" block instead of native functionCall. Convert that
// back to a structured OpenAI tool call so clients/tools do not see it as
// assistant prose.
if (part.text !== undefined && part.text !== "") {
const textualToolCall = parseTextualToolCall(part.text);
if (textualToolCall) {
emitFunctionCallPart(
{
functionCall: {
name: textualToolCall.name,
args: textualToolCall.args,
},
},
state,
results
);
continue;
}
results.push({
id: `chatcmpl-${state.messageId}`,
object: "chat.completion.chunk",

View File

@@ -689,6 +689,61 @@ test("OpenAI -> Antigravity Gemini stringifies signature-less historical tool ca
);
});
test("OpenAI -> Antigravity preserves multiple signature-less historical tool responses as text", () => {
const result = openaiToAntigravityRequest(
"gemini-3.5-flash-low",
{
messages: [
{ role: "user", content: "Inspect OmniRoute config" },
{
role: "assistant",
tool_calls: [
{
id: "call_missing_db",
type: "function",
function: { name: "terminal", arguments: '{"command":"cat data/db.json"}' },
},
{
id: "call_list_dir",
type: "function",
function: { name: "terminal", arguments: '{"command":"ls ~/.omniroute"}' },
},
],
},
{ role: "tool", tool_call_id: "call_missing_db", content: "data/db.json: No such file" },
{ role: "tool", tool_call_id: "call_list_dir", content: "storage.sqlite" },
],
tools: [
{
type: "function",
function: {
name: "terminal",
parameters: { type: "object", properties: {} },
},
},
],
},
false,
{ projectId: "proj-antigravity-gemini" } as any
);
const text = JSON.stringify(result.request.contents);
assert.ok(text.includes("[Tool call: terminal]"), "expected signature-less calls as text");
assert.ok(
text.includes("data/db.json: No such file"),
"expected first signature-less tool response as text"
);
assert.ok(
text.includes("storage.sqlite"),
"expected second signature-less tool response as text"
);
assert.equal(
result.request.contents.some((content) => content.parts.some((part) => part.functionResponse)),
false,
"signature-less historical responses must not be emitted as native functionResponse"
);
});
test("OpenAI -> Antigravity maps Claude-family models to Gemini-compatible schema", () => {
const result = openaiToAntigravityRequest(
"claude-3-7-sonnet",

View File

@@ -64,7 +64,9 @@ test("Gemini non-stream: multiple candidates keep multimodal content, reasoning
{ thought: true, text: "Plan first." },
{ text: "Answer:" },
{ inlineData: { mimeType: "image/png", data: "abc123" } },
{ functionCall: { id: "native-read-1", name: "read_file", args: { path: "/tmp/a" } } },
{
functionCall: { id: "native-read-1", name: "read_file", args: { path: "/tmp/a" } },
},
],
},
finishReason: "STOP",
@@ -283,10 +285,7 @@ test("Gemini stream: reasoning, tool call, image and MAX_TOKENS finish are conve
);
assert.equal(result[1].choices[0].delta.reasoning_content, "Need a plan.");
assert.equal(
result[2].choices[0].delta.tool_calls[0].id,
"native-call-1"
);
assert.equal(result[2].choices[0].delta.tool_calls[0].id, "native-call-1");
assert.equal(
result[2].choices[0].delta.tool_calls[0].function.name,
"mcp__filesystem__read_multiple_files_with_validation_and_metadata_bundle_v2"
@@ -303,6 +302,85 @@ test("Gemini stream: reasoning, tool call, image and MAX_TOKENS finish are conve
assert.equal(result[4].usage.completion_tokens_details.reasoning_tokens, 2);
});
test("Gemini stream: stores thoughtSignature when signature-only part precedes functionCall", async () => {
const { resolveGeminiThoughtSignature } =
await import("../../open-sse/services/geminiThoughtSignatureStore.ts");
const state = {
...createStreamingState(),
signatureNamespace: "conn-antigravity-1",
};
const result = geminiToOpenAIResponse(
{
responseId: "resp-split-signature",
modelVersion: "gemini-3-flash-agent",
candidates: [
{
content: {
parts: [
{ thoughtSignature: "sig-split-1" },
{
functionCall: {
id: "call_split_1",
name: "read_file",
args: { path: "/tmp/a" },
},
},
],
},
finishReason: "STOP",
},
],
},
state
);
const toolCall = result.find((event: any) => event.choices?.[0]?.delta?.tool_calls)?.choices[0]
.delta.tool_calls[0];
assert.equal(toolCall.id, "call_split_1");
assert.equal(state.pendingThoughtSignature, null);
assert.equal(resolveGeminiThoughtSignature("conn-antigravity-1:call_split_1"), "sig-split-1");
});
test("Gemini stream: converts textual Tool call block to structured tool_calls", () => {
const state = createStreamingState();
const result = geminiToOpenAIResponse(
{
responseId: "resp-textual-tool",
modelVersion: "gemini-3.5-flash-low",
candidates: [
{
content: {
parts: [
{
text: '[Tool call: terminal]\nArguments: {"command":"sqlite3 ~/.omniroute/storage.sqlite \\"SELECT name FROM sqlite_master WHERE type=\'table\';\\""}',
},
],
},
finishReason: "STOP",
},
],
},
state
);
const toolCall = result.find((event: any) => event.choices?.[0]?.delta?.tool_calls)?.choices[0]
.delta.tool_calls[0];
assert.ok(toolCall.id.startsWith("terminal-"));
assert.equal(toolCall.function.name, "terminal");
assert.equal(
toolCall.function.arguments,
JSON.stringify({
command:
"sqlite3 ~/.omniroute/storage.sqlite \"SELECT name FROM sqlite_master WHERE type='table';\"",
})
);
assert.equal(
result.some((event: any) => event.choices?.[0]?.delta?.content?.includes("[Tool call:")),
false
);
assert.equal(result.at(-1).choices[0].finish_reason, "tool_calls");
});
test("Gemini stream: tool calls without native IDs keep deterministic fallback shape", () => {
const state = createStreamingState();
const result = geminiToOpenAIResponse(