fix(antigravity): preserve textual SSE tool calls

This commit is contained in:
OpenClaw
2026-05-25 13:36:41 +03:00
parent b89faf1e4d
commit beaa7267b6
2 changed files with 131 additions and 3 deletions

View File

@@ -123,10 +123,67 @@ function serializeAntigravityRequest(
type AntigravityCollectedStream = {
textContent: string;
finishReason: string;
toolCalls: Array<{
id: string;
index: number;
type: "function";
function: { name: string; arguments: string };
}>;
usage: Record<string, unknown> | null;
remainingCredits: Array<{ creditType: string; creditAmount: string }> | null;
};
function stripZeroWidth(value: unknown): unknown {
if (typeof value === "string") {
return value.replace(/[\u200B-\u200D\uFEFF]/g, "");
}
if (Array.isArray(value)) {
return value.map((item) => stripZeroWidth(item));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
key,
stripZeroWidth(item),
])
);
}
return value;
}
function parseAntigravityTextualToolCall(text: unknown): { name: string; args: unknown } | null {
if (typeof text !== "string") return null;
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();
if (!name || !rawArgs) return null;
try {
return { name, args: stripZeroWidth(JSON.parse(rawArgs)) };
} catch {
return null;
}
}
function addAntigravityTextualToolCall(
collected: AntigravityCollectedStream,
parsed: { name: string; args: unknown }
): void {
collected.toolCalls.push({
id: `${parsed.name}-${Date.now()}-${collected.toolCalls.length}`,
index: collected.toolCalls.length,
type: "function",
function: {
name: parsed.name,
arguments: JSON.stringify(parsed.args || {}),
},
});
collected.finishReason = "tool_calls";
}
type AntigravityRequestEnvelope = Record<string, unknown> & {
project: string;
model: string;
@@ -233,7 +290,12 @@ function processAntigravitySSEPayload(
if (candidate?.content?.parts) {
for (const part of candidate.content.parts) {
if (typeof part.text === "string" && !part.thought && !part.thoughtSignature) {
collected.textContent += part.text;
const textualToolCall = parseAntigravityTextualToolCall(part.text);
if (textualToolCall) {
addAntigravityTextualToolCall(collected, textualToolCall);
} else {
collected.textContent += part.text;
}
}
}
}
@@ -717,6 +779,7 @@ export class AntigravityExecutor extends BaseExecutor {
const collected: AntigravityCollectedStream = {
textContent: "",
finishReason: "stop",
toolCalls: [],
usage: null,
remainingCredits: null,
};
@@ -761,8 +824,19 @@ export class AntigravityExecutor extends BaseExecutor {
choices: [
{
index: 0,
message: { role: "assistant", content: collected.textContent },
finish_reason: timedOut ? "length" : collected.finishReason,
message:
collected.toolCalls.length > 0
? {
role: "assistant",
content: collected.textContent || null,
tool_calls: collected.toolCalls,
}
: { role: "assistant", content: collected.textContent },
finish_reason: timedOut
? "length"
: collected.toolCalls.length > 0
? "tool_calls"
: collected.finishReason,
},
],
...(collected.usage && { usage: collected.usage }),

View File

@@ -476,6 +476,60 @@ test("AntigravityExecutor.collectStreamToResponse turns SSE Gemini chunks into a
});
});
test("AntigravityExecutor.collectStreamToResponse converts textual tool call SSE to structured tool_calls", async () => {
const executor = new AntigravityExecutor();
const response = new Response(
[
`data: ${JSON.stringify({
response: {
candidates: [
{
content: {
parts: [
{
text: '[Tool call: search_files]\nArguments: {"file_glob":"*gemini*","output_mode":"files_only","path":"/opt/O\\u200dmniRoute","target":"files"}',
},
],
},
finishReason: "STOP",
},
],
usageMetadata: {
promptTokenCount: 7,
candidatesTokenCount: 4,
totalTokenCount: 11,
},
},
})}\n\n`,
].join(""),
{
status: 200,
headers: { "Content-Type": "text/event-stream" },
}
);
const result = await executor.collectStreamToResponse(
response,
"gemini-3.5-flash-low",
"https://example.com",
{ Authorization: "Bearer ag-token" },
{ request: {} }
);
const payload = await result.response.json();
const choice = payload.choices[0];
assert.equal(choice.message.content, null);
assert.equal(choice.finish_reason, "tool_calls");
assert.equal(choice.message.tool_calls.length, 1);
assert.equal(choice.message.tool_calls[0].function.name, "search_files");
assert.deepEqual(JSON.parse(choice.message.tool_calls[0].function.arguments), {
file_glob: "*gemini*",
output_mode: "files_only",
path: "/opt/OmniRoute",
target: "files",
});
});
test("AntigravityExecutor.collectStreamToResponse parses fragmented SSE lines incrementally", async () => {
const executor = new AntigravityExecutor();
const encoder = new TextEncoder();