diff --git a/CHANGELOG.md b/CHANGELOG.md index ca4962f941a..57767511f8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai - Control UI/Overview: fix false-positive "missing" alerts on the Model Auth status card for aliased providers, env-backed OAuth with auth.profiles, and unresolvable env SecretRefs. (#67253) Thanks @omarshahine. - Dashboard: constrain exec approval modal overflow on desktop so long command content no longer pushes action buttons out of view. (#67082) Thanks @Ziy1-Tan. - Agents/CLI transcripts: persist successful CLI-backed turns into the OpenClaw session transcript so google-gemini-cli replies appear in session history and the Control UI again. (#67490) Thanks @obviyus. +- Discord/tool-call text: strip standalone Gemma-style `...` tool-call payloads from visible assistant text without truncating prose examples or trailing replies. (#67318) Thanks @joelnishanth. ## 2026.4.15-beta.1 diff --git a/src/shared/text/assistant-visible-text.test.ts b/src/shared/text/assistant-visible-text.test.ts index fcd88d4469b..b599b83f03b 100644 --- a/src/shared/text/assistant-visible-text.test.ts +++ b/src/shared/text/assistant-visible-text.test.ts @@ -234,12 +234,67 @@ describe("stripAssistantInternalScaffolding", () => { it("strips lone closing tool-call tags", () => { expectVisibleText("prefix suffix", "prefix suffix"); expectVisibleText("prefix suffix", "prefix suffix"); + expectVisibleText("prefix suffix", "prefix suffix"); + }); + + it("strips standalone blocks with nested XML (#67093)", () => { + expectVisibleText( + 'prefix\nagent:main0\nsuffix', + "prefix\n\nsuffix", + ); + }); + + it("strips Gemma-style with newlines between parameters (#67093)", () => { + expectVisibleText( + [ + "Let me check that.", + '', + '/home/user/test.md', + "", + "After the call.", + ].join("\n"), + "Let me check that.\n\nAfter the call.", + ); + }); + + it("strips inline standalone blocks after sentence lead-ins", () => { + expectVisibleText( + 'Let me check that. /tmp/test.md Done.', + "Let me check that. Done.", + ); + }); + + it("strips standalone blocks with apostrophes in XML payloads (#67093)", () => { + expectVisibleText( + [ + "prefix", + '', + 'what\'s up', + "", + "suffix", + ].join("\n"), + "prefix\n\nsuffix", + ); + }); + + it("preserves dangling blocks instead of hiding the tail", () => { + expectVisibleText( + 'prefix\n\nvalue', + 'prefix\n\nvalue', + ); }); it("preserves XML-style explanations after lone tags", () => { expectVisibleText("Use literally.", "Use literally."); }); + it("preserves lone mentions in normal prose", () => { + expectVisibleText( + "Use declarations in your WASM text format.", + "Use declarations in your WASM text format.", + ); + }); + it("preserves literal XML-style paired tool_call examples in prose", () => { expectVisibleText( "prefix secret suffix", @@ -247,6 +302,13 @@ describe("stripAssistantInternalScaffolding", () => { ); }); + it("preserves inline bare XML examples in prose", () => { + expectVisibleText( + 'Use /tmp in docs.', + 'Use /tmp in docs.', + ); + }); + it("preserves machine-style XML payload examples in prose", () => { expectVisibleText( 'prefix secret suffix', diff --git a/src/shared/text/assistant-visible-text.ts b/src/shared/text/assistant-visible-text.ts index 6254336e6ae..fe63abbc8e7 100644 --- a/src/shared/text/assistant-visible-text.ts +++ b/src/shared/text/assistant-visible-text.ts @@ -15,12 +15,14 @@ const MEMORY_TAG_QUICK_RE = /<\s*\/?\s*relevant[-_]memories\b/i; * This stateful pass hides content from an opening tag through the matching * closing tag, or to end-of-string if the stream was truncated mid-tag. */ -const TOOL_CALL_QUICK_RE = /<\s*\/?\s*(?:tool_call|tool_result|function_calls?|tool_calls)\b/i; +const TOOL_CALL_QUICK_RE = + /<\s*\/?\s*(?:tool_call|tool_result|function_calls?|function|tool_calls)\b/i; const TOOL_CALL_TAG_NAMES = new Set([ "tool_call", "tool_result", "function_call", "function_calls", + "function", "tool_calls", ]); const TOOL_CALL_JSON_PAYLOAD_START_RE = @@ -28,6 +30,8 @@ const TOOL_CALL_JSON_PAYLOAD_START_RE = const TOOL_CALL_XML_PAYLOAD_START_RE = /^\s*(?:\r?\n\s*)?<(?:function|invoke|parameters?|arguments?)\b/i; +type ToolCallPayloadKind = "json" | "xml" | null; + function endsInsideQuotedString(text: string, start: number, end: number): boolean { let quoteChar: "'" | '"' | null = null; let isEscaped = false; @@ -108,9 +112,36 @@ function findTagCloseIndex(text: string, start: number): number { return -1; } -function looksLikeToolCallPayloadStart(text: string, start: number): boolean { +function detectToolCallPayloadKind(text: string, start: number): ToolCallPayloadKind { const rest = text.slice(start); - return TOOL_CALL_JSON_PAYLOAD_START_RE.test(rest) || TOOL_CALL_XML_PAYLOAD_START_RE.test(rest); + if (TOOL_CALL_JSON_PAYLOAD_START_RE.test(rest)) { + return "json"; + } + if (TOOL_CALL_XML_PAYLOAD_START_RE.test(rest)) { + return "xml"; + } + return null; +} + +function isLikelyStandaloneFunctionToolCall( + text: string, + tagStart: number, + tag: ParsedToolCallTag, +): boolean { + if (tag.tagName !== "function" || tag.isClose || tag.isSelfClosing || tag.isTruncated) { + return false; + } + + if (!/\bname\s*=/.test(text.slice(tag.contentStart, tag.end))) { + return false; + } + + let idx = tagStart - 1; + while (idx >= 0 && (text[idx] === " " || text[idx] === "\t")) { + idx -= 1; + } + + return idx < 0 || text[idx] === "\n" || text[idx] === "\r" || /[.!?:]/.test(text[idx]); } function parseToolCallTagAt(text: string, start: number): ParsedToolCallTag | null { @@ -174,7 +205,9 @@ export function stripToolCallXmlTags(text: string): string { let result = ""; let lastIndex = 0; let inToolCallBlock = false; - let toolCallContentStart = 0; + let toolCallBlockContentStart = 0; + let toolCallBlockNeedsQuoteBalance = false; + let toolCallBlockStart = 0; let toolCallBlockTagName: string | null = null; const visibleTagBalance = new Map(); @@ -216,13 +249,19 @@ export function stripToolCallXmlTags(text: string): string { continue; } const payloadStart = tag.isTruncated ? tag.contentStart : tag.end; - const hasToolCallPayloadStart = - tag.tagName === "tool_call" - ? looksLikeToolCallPayloadStart(text, payloadStart) - : TOOL_CALL_JSON_PAYLOAD_START_RE.test(text.slice(payloadStart)); - if (!tag.isClose && hasToolCallPayloadStart) { + const payloadKind = + tag.tagName === "tool_call" || tag.tagName === "function" + ? detectToolCallPayloadKind(text, payloadStart) + : TOOL_CALL_JSON_PAYLOAD_START_RE.test(text.slice(payloadStart)) + ? "json" + : null; + const shouldStripStandaloneFunction = + tag.tagName !== "function" || isLikelyStandaloneFunctionToolCall(text, idx, tag); + if (!tag.isClose && payloadKind && shouldStripStandaloneFunction) { inToolCallBlock = true; - toolCallContentStart = tag.end; + toolCallBlockContentStart = tag.end; + toolCallBlockNeedsQuoteBalance = payloadKind === "json"; + toolCallBlockStart = idx; toolCallBlockTagName = tag.tagName; if (tag.isTruncated) { lastIndex = text.length; @@ -242,9 +281,11 @@ export function stripToolCallXmlTags(text: string): string { tag.isClose && (tag.tagName === toolCallBlockTagName || (toolCallBlockTagName === "tool_result" && tag.tagName === "tool_call")) && - !endsInsideQuotedString(text, toolCallContentStart, idx) + (!toolCallBlockNeedsQuoteBalance || + !endsInsideQuotedString(text, toolCallBlockContentStart, idx)) ) { inToolCallBlock = false; + toolCallBlockNeedsQuoteBalance = false; toolCallBlockTagName = null; } @@ -254,6 +295,8 @@ export function stripToolCallXmlTags(text: string): string { if (!inToolCallBlock) { result += text.slice(lastIndex); + } else if (toolCallBlockTagName === "function") { + result += text.slice(toolCallBlockStart); } return result;