fix: strip standalone <function> tool call tags from visible text (#67318) (thanks @joelnishanth)

* fix: strip standalone <function> tool call tags from visible text (#67093)

Models like Gemma emit tool calls as standalone <function> blocks with
nested <parameter> XML instead of wrapping them in <tool_call>. The
existing stripToolCallXmlTags only recognized tool_call, tool_result,
function_call, function_calls, and tool_calls — so bare <function> and
</function> tags leaked through to the user as raw syntax on Discord
and other channels.

Add "function" to TOOL_CALL_TAG_NAMES and extend the payload detection
for <function> tags to check XML payloads (not just JSON), matching the
same behavior already applied to <tool_call>. Other tag types keep the
more conservative JSON-only check to avoid stripping prose examples.

Made-with: Cursor

* Text: harden standalone <function> stripping

* fix: strip standalone <function> tool call tags from visible text (#67318) (thanks @joelnishanth)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
OfflynAI
2026-04-15 21:23:35 -07:00
committed by GitHub
parent 898fd0482a
commit 78df859e15
3 changed files with 117 additions and 11 deletions

View File

@@ -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 `<function>...</function>` tool-call payloads from visible assistant text without truncating prose examples or trailing replies. (#67318) Thanks @joelnishanth.
## 2026.4.15-beta.1

View File

@@ -234,12 +234,67 @@ describe("stripAssistantInternalScaffolding", () => {
it("strips lone closing tool-call tags", () => {
expectVisibleText("prefix </tool_call> suffix", "prefix suffix");
expectVisibleText("prefix </function_calls> suffix", "prefix suffix");
expectVisibleText("prefix </function> suffix", "prefix suffix");
});
it("strips standalone <function> blocks with nested <parameter> XML (#67093)", () => {
expectVisibleText(
'prefix\n<function name="sessions_spawn"><parameter name="sessionKey">agent:main</parameter><parameter name="timeout">0</parameter></function>\nsuffix',
"prefix\n\nsuffix",
);
});
it("strips Gemma-style <function> with newlines between parameters (#67093)", () => {
expectVisibleText(
[
"Let me check that.",
'<function name="read">',
'<parameter name="file_path">/home/user/test.md</parameter>',
"</function>",
"After the call.",
].join("\n"),
"Let me check that.\n\nAfter the call.",
);
});
it("strips inline standalone <function> blocks after sentence lead-ins", () => {
expectVisibleText(
'Let me check that. <function name="read"><parameter name="file_path">/tmp/test.md</parameter></function> Done.',
"Let me check that. Done.",
);
});
it("strips standalone <function> blocks with apostrophes in XML payloads (#67093)", () => {
expectVisibleText(
[
"prefix",
'<function name="spawn">',
'<parameter name="message">what\'s up</parameter>',
"</function>",
"suffix",
].join("\n"),
"prefix\n\nsuffix",
);
});
it("preserves dangling <function> blocks instead of hiding the tail", () => {
expectVisibleText(
'prefix\n<function name="spawn">\n<parameter name="key">value</parameter>',
'prefix\n<function name="spawn">\n<parameter name="key">value</parameter>',
);
});
it("preserves XML-style explanations after lone <tool_call> tags", () => {
expectVisibleText("Use <tool_call><arg> literally.", "Use <tool_call><arg> literally.");
});
it("preserves lone <function> mentions in normal prose", () => {
expectVisibleText(
"Use <function> declarations in your WASM text format.",
"Use <function> declarations in your WASM text format.",
);
});
it("preserves literal XML-style paired tool_call examples in prose", () => {
expectVisibleText(
"prefix <tool_call><arg>secret</arg></tool_call> suffix",
@@ -247,6 +302,13 @@ describe("stripAssistantInternalScaffolding", () => {
);
});
it("preserves inline bare <function> XML examples in prose", () => {
expectVisibleText(
'Use <function name="read"><parameter name="path">/tmp</parameter></function> in docs.',
'Use <function name="read"><parameter name="path">/tmp</parameter></function> in docs.',
);
});
it("preserves machine-style XML payload examples in prose", () => {
expectVisibleText(
'prefix <function_calls><invoke name="find">secret</invoke></function_calls> suffix',

View File

@@ -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<string, number>();
@@ -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;