fix(stream): normalize split textual tool calls

This commit is contained in:
OpenClaw
2026-05-25 16:32:40 +03:00
parent 5901a27224
commit f6b140a6ed
2 changed files with 112 additions and 27 deletions

View File

@@ -202,18 +202,31 @@ function stripZeroWidth(value: unknown): unknown {
function parseTextualToolCallFromContent(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();
const toolCallIndex = normalized.lastIndexOf("[Tool call:");
const candidate = toolCallIndex >= 0 ? normalized.slice(toolCallIndex) : normalized;
const headerMatch = candidate.match(/^\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*/);
if (!headerMatch) return null;
const name = headerMatch[1]?.trim();
const rawArgs = candidate.slice(headerMatch[0].length).trim();
if (!name || !rawArgs) return null;
try {
return { name, args: stripZeroWidth(JSON.parse(rawArgs)) };
} catch {
return null;
const decoders = [
(value: string) => value,
(value: string) => {
if (value.startsWith('"') && value.endsWith('"')) {
const decoded = JSON.parse(value);
return typeof decoded === "string" ? decoded : value;
}
return value;
},
];
for (const decode of decoders) {
try {
const decoded = decode(rawArgs);
const parsed = JSON.parse(decoded);
return { name, args: stripZeroWidth(parsed) };
} catch {}
}
return null;
}
function collectPassthroughTextualToolCall(
@@ -1679,7 +1692,11 @@ export function createSSEStream(options: StreamOptions = {}) {
const u = usage as Record<string, unknown> | null;
const prompt = Number(u?.prompt_tokens ?? u?.input_tokens ?? 0);
const completion = Number(u?.completion_tokens ?? u?.output_tokens ?? 0);
const content = passthroughAccumulatedContent.trim() || "";
let content = passthroughAccumulatedContent.trim() || "";
if (content && collectPassthroughTextualToolCall(content, passthroughToolCalls)) {
passthroughHasToolCalls = true;
content = "";
}
const message: Record<string, unknown> = {
role: "assistant",
content: content || null,
@@ -1892,27 +1909,42 @@ export function createSSEStream(options: StreamOptions = {}) {
const u = state?.usage as Record<string, unknown> | null | undefined;
const prompt = Number(u?.prompt_tokens ?? u?.input_tokens ?? 0);
const completion = Number(u?.completion_tokens ?? u?.output_tokens ?? 0);
const content = (state?.accumulatedContent ?? "").trim() || "";
let content = (state?.accumulatedContent ?? "").trim() || "";
const normalizedToolCalls: ToolCall[] = state?.toolCalls?.size
? [...state.toolCalls.values()]
.map(
(tc: Record<string, unknown>): ToolCall => ({
id: (tc.id as string) ?? null,
index: (tc.index as number) ?? (tc.blockIndex as number) ?? 0,
type: (tc.type as string) ?? "function",
function: (tc.function as ToolCall["function"]) ?? {
name: (tc.name as string) ?? "",
arguments: "",
},
})
)
.sort((a, b) => a.index - b.index)
: [];
const textualToolCall = parseTextualToolCallFromContent(content);
if (textualToolCall) {
normalizedToolCalls.push({
id: `call_${Date.now()}_${normalizedToolCalls.length}`,
index: normalizedToolCalls.length,
type: "function",
function: {
name: textualToolCall.name,
arguments: JSON.stringify(textualToolCall.args || {}),
},
});
content = "";
}
const message: Record<string, unknown> = {
role: "assistant",
content: content || null,
};
const hasToolCalls = state?.toolCalls?.size > 0;
const hasToolCalls = normalizedToolCalls.length > 0;
if (hasToolCalls) {
// Normalize shape — translators may store different structures
message.tool_calls = [...state.toolCalls.values()]
.map(
(tc: Record<string, unknown>): ToolCall => ({
id: (tc.id as string) ?? null,
index: (tc.index as number) ?? (tc.blockIndex as number) ?? 0,
type: (tc.type as string) ?? "function",
function: (tc.function as ToolCall["function"]) ?? {
name: (tc.name as string) ?? "",
arguments: "",
},
})
)
.sort((a, b) => a.index - b.index);
message.tool_calls = normalizedToolCalls;
}
const responseBody = {
choices: [

View File

@@ -173,6 +173,59 @@ test("createSSEStream passthrough converts textual tool-call content into struct
assert.doesNotMatch(text, /\[Tool call: terminal\]/);
});
test("createSSEStream passthrough converts split textual tool-call content at completion", async () => {
let onCompletePayload = null;
const splitToolArgs = JSON.stringify({
command: 'sqlite3 ~/.o\u200dmniroute/o\u200dmniroute.db ".tables"',
});
const chunks = ["[Tool call: terminal]\n", `Arguments: ${splitToolArgs}`];
const text = await readTransformed(
[
`data: ${JSON.stringify({
id: "chatcmpl_split_textual_tool",
object: "chat.completion.chunk",
created: 1,
model: "antigravity/gemini-3.5-flash-low",
choices: [{ index: 0, delta: { role: "assistant", content: chunks[0] } }],
})}\n\n`,
`data: ${JSON.stringify({
id: "chatcmpl_split_textual_tool",
object: "chat.completion.chunk",
created: 1,
model: "antigravity/gemini-3.5-flash-low",
choices: [{ index: 0, delta: { content: chunks[1] } }],
})}\n\n`,
`data: ${JSON.stringify({
id: "chatcmpl_split_textual_tool",
object: "chat.completion.chunk",
created: 1,
model: "antigravity/gemini-3.5-flash-low",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
})}\n\n`,
],
{
mode: "passthrough",
sourceFormat: FORMATS.OPENAI,
provider: "antigravity",
model: "antigravity/gemini-3.5-flash-low",
body: { messages: [{ role: "user", content: "inspect db" }] },
onComplete(payload) {
onCompletePayload = payload;
},
}
);
const choice = onCompletePayload.responseBody.choices[0];
assert.equal(choice.finish_reason, "tool_calls");
assert.equal(choice.message.content, null);
assert.equal(choice.message.tool_calls[0].function.name, "terminal");
assert.deepEqual(JSON.parse(choice.message.tool_calls[0].function.arguments), {
command: 'sqlite3 ~/.omniroute/omniroute.db ".tables"',
});
assert.match(text, /\[Tool call: terminal\]/);
});
test("createSSEStream passthrough flushes a buffered final line without a trailing newline", async () => {
const text = await readTransformed(
[