From 212d0466e513b999cdeab802add7d5f324ffbb65 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Mon, 25 May 2026 18:02:28 +0300 Subject: [PATCH] fix(stream): suppress malformed textual tool calls --- open-sse/utils/stream.ts | 72 ++++++++++++++++++++++++++++----- tests/unit/stream-utils.test.ts | 43 +++++++++++++++++++- 2 files changed, 103 insertions(+), 12 deletions(-) diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index ae1ef920bb..2798c8e09e 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -199,16 +199,19 @@ function stripZeroWidth(value: unknown): unknown { return value; } -function parseTextualToolCallFromContent(text: unknown): { name: string; args: unknown } | null { +function parseTextualToolCallCandidate( + text: unknown +): { kind: "complete"; name: string; args: unknown } | { kind: "partial" } | null { if (typeof text !== "string") return null; const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); const toolCallIndex = normalized.lastIndexOf("[Tool call:"); - const candidate = toolCallIndex >= 0 ? normalized.slice(toolCallIndex) : normalized; + if (toolCallIndex < 0) return null; + const candidate = normalized.slice(toolCallIndex); const headerMatch = candidate.match(/^\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*/); - if (!headerMatch) return null; + if (!headerMatch) return { kind: "partial" }; const name = headerMatch[1]?.trim(); const rawArgs = candidate.slice(headerMatch[0].length).trim(); - if (!name || !rawArgs) return null; + if (!name || !rawArgs) return { kind: "partial" }; const decoders = [ (value: string) => value, (value: string) => { @@ -223,10 +226,19 @@ function parseTextualToolCallFromContent(text: unknown): { name: string; args: u try { const decoded = decode(rawArgs); const parsed = JSON.parse(decoded); - return { name, args: stripZeroWidth(parsed) }; + return { kind: "complete", name, args: stripZeroWidth(parsed) }; } catch {} } - return null; + return { kind: "partial" }; +} + +function parseTextualToolCallFromContent(text: unknown): { name: string; args: unknown } | null { + const candidate = parseTextualToolCallCandidate(text); + return candidate?.kind === "complete" ? { name: candidate.name, args: candidate.args } : null; +} + +function containsTextualToolCallCandidate(text: unknown): boolean { + return parseTextualToolCallCandidate(text) !== null; } function collectPassthroughTextualToolCall( @@ -659,6 +671,7 @@ export function createSSEStream(options: StreamOptions = {}) { // Passthrough: accumulate content and reasoning separately for call log response body let passthroughAccumulatedContent = ""; let passthroughAccumulatedReasoning = ""; + let passthroughBufferedTextualToolCallContent = ""; // Passthrough Responses SSE: snapshots of items seen via `response.output_item.done`, // used to backfill `response.completed.response.output` when upstream returns it // empty (which happens when `store: false` — see backfillResponsesCompletedOutput). @@ -1357,14 +1370,38 @@ export function createSSEStream(options: StreamOptions = {}) { totalContentLength += content.length; } if (typeof delta?.content === "string") { - if (collectPassthroughTextualToolCall(delta.content, passthroughToolCalls)) { - passthroughHasToolCalls = true; - textualToolCallConverted = true; - delta.content = ""; + const incomingContent = delta.content; + const bufferedCandidate = + passthroughBufferedTextualToolCallContent + incomingContent; + if ( + passthroughBufferedTextualToolCallContent || + containsTextualToolCallCandidate(incomingContent) + ) { + const parsedCandidate = parseTextualToolCallCandidate(bufferedCandidate); + if (parsedCandidate?.kind === "complete") { + collectPassthroughTextualToolCall(bufferedCandidate, passthroughToolCalls); + passthroughHasToolCalls = true; + textualToolCallConverted = true; + passthroughBufferedTextualToolCallContent = ""; + delta.content = ""; + } else if (parsedCandidate?.kind === "partial") { + passthroughBufferedTextualToolCallContent = appendBoundedText( + passthroughBufferedTextualToolCallContent, + incomingContent + ); + textualToolCallConverted = true; + delta.content = ""; + } else { + passthroughAccumulatedContent = appendBoundedText( + passthroughAccumulatedContent, + passthroughBufferedTextualToolCallContent + incomingContent + ); + passthroughBufferedTextualToolCallContent = ""; + } } else { passthroughAccumulatedContent = appendBoundedText( passthroughAccumulatedContent, - delta.content + incomingContent ); } } @@ -1693,6 +1730,19 @@ export function createSSEStream(options: StreamOptions = {}) { const prompt = Number(u?.prompt_tokens ?? u?.input_tokens ?? 0); const completion = Number(u?.completion_tokens ?? u?.output_tokens ?? 0); let content = passthroughAccumulatedContent.trim() || ""; + const finalBufferedTextualToolCall = + passthroughBufferedTextualToolCallContent.trim(); + if (finalBufferedTextualToolCall) { + if ( + collectPassthroughTextualToolCall( + finalBufferedTextualToolCall, + passthroughToolCalls + ) + ) { + passthroughHasToolCalls = true; + } + passthroughBufferedTextualToolCallContent = ""; + } if (content && collectPassthroughTextualToolCall(content, passthroughToolCalls)) { passthroughHasToolCalls = true; content = ""; diff --git a/tests/unit/stream-utils.test.ts b/tests/unit/stream-utils.test.ts index e5893be1f1..170c977f6f 100644 --- a/tests/unit/stream-utils.test.ts +++ b/tests/unit/stream-utils.test.ts @@ -223,7 +223,48 @@ test("createSSEStream passthrough converts split textual tool-call content at co assert.deepEqual(JSON.parse(choice.message.tool_calls[0].function.arguments), { command: 'sqlite3 ~/.omniroute/omniroute.db ".tables"', }); - assert.match(text, /\[Tool call: terminal\]/); + assert.doesNotMatch(text, /\[Tool call: terminal\]/); +}); + +test("createSSEStream passthrough suppresses malformed textual tool-call content", async () => { + let onCompletePayload = null; + const malformedToolText = `(empty)[Tool call: terminal]\nArguments: {"command":"sqlite3 /opt/O\u200dmniRoute/data/o\u200dmniroute.`; + + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + id: "chatcmpl_malformed_textual_tool", + object: "chat.completion.chunk", + created: 1, + model: "antigravity/gemini-3.5-flash-low", + choices: [{ index: 0, delta: { role: "assistant", content: malformedToolText } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_malformed_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, "stop"); + assert.equal(choice.message.content, null); + assert.equal(choice.message.tool_calls, undefined); + assert.doesNotMatch(text, /\[Tool call: terminal\]/); + assert.doesNotMatch(JSON.stringify(onCompletePayload.responseBody), /\[Tool call: terminal\]/); }); test("createSSEStream passthrough flushes a buffered final line without a trailing newline", async () => {