mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user