fix(gemini): parse prefixed textual tool calls

This commit is contained in:
OpenClaw
2026-05-25 12:25:13 +03:00
parent e62fbb7a75
commit b89faf1e4d
3 changed files with 57 additions and 2 deletions

View File

@@ -36,7 +36,15 @@ function firstPositiveNumber(...values: unknown[]): number {
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*$/);
// Gemini/Antigravity sometimes imitates the request-side fallback with small
// variations, e.g. a leading "(empty)" marker or zero-width chars inserted
// into argument strings. Normalize those variants before parsing so the
// response is still surfaced as a structured OpenAI tool call.
const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, "");
const match = normalized.match(
/^[\s\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();

View File

@@ -25,7 +25,15 @@ 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*$/);
// Gemini/Antigravity sometimes imitates the request-side fallback with small
// variations, e.g. a leading "(empty)" marker or zero-width chars inserted
// into argument strings. Normalize those variants before parsing so the
// response is still surfaced as a structured OpenAI tool call.
const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, "");
const match = normalized.match(
/^[\s\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();

View File

@@ -381,6 +381,45 @@ test("Gemini stream: converts textual Tool call block to structured tool_calls",
assert.equal(result.at(-1).choices[0].finish_reason, "tool_calls");
});
test("Gemini stream: converts prefixed textual Tool call block with zero-width chars", () => {
const state = createStreamingState();
const result = geminiToOpenAIResponse(
{
responseId: "resp-textual-tool-prefixed",
modelVersion: "gemini-3.5-flash-low",
candidates: [
{
content: {
parts: [
{
text: '(empty)[Tool call: terminal]\nArguments: {"command":"sqlite3 ~/.o\u200dmniroute/storage.sqlite"}',
},
],
},
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",
})
);
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(