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;