mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-17 12:11:20 +00:00
fix(Gateway ): strip tool_call and tool_result XML blocks from assistant visible text
This commit is contained in:
committed by
Peter Steinberger
parent
f00c8c1b87
commit
980439b9e6
@@ -437,6 +437,116 @@ File contents here`,
|
||||
expect(result).toBe("Here's what I found:\nDone checking.");
|
||||
});
|
||||
|
||||
it("strips raw <tool_call> XML blocks from assistant text", () => {
|
||||
const msg = makeAssistantMessage({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: 'Let me check.\n\n<tool_call> {"name": "read", "arguments": {"file_path": "test.md"}} </tool_call> Done.',
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
expect(extractAssistantText(msg)).toBe("Let me check.\n\n Done.");
|
||||
});
|
||||
|
||||
it("strips raw <tool_result> XML blocks from assistant text", () => {
|
||||
const msg = makeAssistantMessage({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: 'Prefix\n<tool_result> {"output": "file contents"} </tool_result>\nSuffix',
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
expect(extractAssistantText(msg)).toBe("Prefix\n\nSuffix");
|
||||
});
|
||||
|
||||
it("strips dangling <tool_call> XML content to end-of-string", () => {
|
||||
const msg = makeAssistantMessage({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: 'Let me run.\n<tool_call>\n{"name": "find", "arguments": {}}\n',
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
expect(extractAssistantText(msg)).toBe("Let me run.");
|
||||
});
|
||||
|
||||
it("strips mixed <tool_call> and <tool_result> XML blocks from assistant text", () => {
|
||||
const msg = makeAssistantMessage({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"I will read the file.",
|
||||
'<tool_call>{"name":"read","arguments":{"path":"/tmp/x"}}</tool_call>',
|
||||
'<tool_result>{"output":"hello world"}</tool_result>',
|
||||
"The file contains: hello world",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
expect(extractAssistantText(msg)).toBe(
|
||||
"I will read the file.\n\n\nThe file contains: hello world",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips <tool_result> closed with mismatched </tool_call> tag and preserves trailing text", () => {
|
||||
// Issue #61688: gateway sometimes emits <tool_result>...</tool_call>
|
||||
const msg = makeAssistantMessage({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: 'Prefix\n<tool_result> {"output": "data"} </tool_call>\nSuffix',
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const result = extractAssistantText(msg);
|
||||
// The mismatched closing tag should still exit the block, stripping the
|
||||
// tool XML while preserving legitimate trailing prose.
|
||||
expect(result).not.toContain("<tool_result>");
|
||||
expect(result).not.toContain("output");
|
||||
expect(result).toContain("Prefix");
|
||||
expect(result).toContain("Suffix");
|
||||
});
|
||||
|
||||
it("does not let </tool_result> close a <tool_call> block (prevents payload leak)", () => {
|
||||
const msg = makeAssistantMessage({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: 'Prefix\n<tool_call>{"name":"x"}</tool_result>LEAK</tool_call>\nSuffix',
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const result = extractAssistantText(msg);
|
||||
// </tool_result> must NOT exit a <tool_call> block; the block should
|
||||
// continue until the matching </tool_call>, preventing payload leaks.
|
||||
expect(result).not.toContain("LEAK");
|
||||
expect(result).not.toContain("<tool_call>");
|
||||
expect(result).toContain("Prefix");
|
||||
expect(result).toContain("Suffix");
|
||||
});
|
||||
|
||||
it("strips reasoning/thinking tag variants", () => {
|
||||
const cases = [
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
parseAssistantTextSignature,
|
||||
type AssistantPhase,
|
||||
} from "../shared/chat-message-content.js";
|
||||
import { stripToolCallXmlTags } from "../shared/text/assistant-visible-text.js";
|
||||
import { stripReasoningTagsFromText } from "../shared/text/reasoning-tags.js";
|
||||
import { sanitizeUserFacingText } from "./pi-embedded-helpers.js";
|
||||
import { formatToolDetail, resolveToolDisplay } from "./tool-display.js";
|
||||
@@ -240,7 +241,9 @@ export function stripThinkingTagsFromText(text: string): string {
|
||||
|
||||
function sanitizeAssistantText(text: string): string {
|
||||
return stripThinkingTagsFromText(
|
||||
stripDowngradedToolCallText(stripModelSpecialTokens(stripMinimaxToolCallXml(text))),
|
||||
stripToolCallXmlTags(
|
||||
stripDowngradedToolCallText(stripModelSpecialTokens(stripMinimaxToolCallXml(text))),
|
||||
),
|
||||
).trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,31 @@ describe("stripAssistantInternalScaffolding", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("strips closed <tool_result> blocks", () => {
|
||||
expectVisibleText(
|
||||
'Prefix\n<tool_result> {"output": "file contents"} </tool_result>\nSuffix',
|
||||
"Prefix\n\nSuffix",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips dangling <tool_result> content to end-of-string", () => {
|
||||
expectVisibleText('Result:\n<tool_result>\n{"output": "data"}\n', "Result:\n");
|
||||
});
|
||||
|
||||
it("strips <tool_result> closed with mismatched </tool_call> and preserves trailing text", () => {
|
||||
expectVisibleText(
|
||||
'Prefix\n<tool_result> {"output": "data"} </tool_call>\nSuffix',
|
||||
"Prefix\n\nSuffix",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not let </tool_result> close a <tool_call> block", () => {
|
||||
expectVisibleText(
|
||||
'Prefix\n<tool_call>{"name":"x"}</tool_result>LEAK</tool_call>\nSuffix',
|
||||
"Prefix\n\nSuffix",
|
||||
);
|
||||
});
|
||||
|
||||
it("hides dangling <tool_call> content to end-of-string", () => {
|
||||
expectVisibleText(
|
||||
'Let me run.\n<tool_call>\n{"name": "find", "arguments": {}}\n',
|
||||
|
||||
@@ -10,8 +10,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|function_calls?|tool_calls)\b/i;
|
||||
const TOOL_CALL_TAG_NAMES = new Set(["tool_call", "function_call", "function_calls", "tool_calls"]);
|
||||
const TOOL_CALL_QUICK_RE = /<\s*\/?\s*(?:tool_call|tool_result|function_calls?|tool_calls)\b/i;
|
||||
const TOOL_CALL_TAG_NAMES = new Set([
|
||||
"tool_call",
|
||||
"tool_result",
|
||||
"function_call",
|
||||
"function_calls",
|
||||
"tool_calls",
|
||||
]);
|
||||
const TOOL_CALL_JSON_PAYLOAD_START_RE =
|
||||
/^(?:\s+[A-Za-z_:][-A-Za-z0-9_:.]*\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))*\s*(?:\r?\n\s*)?[[{]/;
|
||||
|
||||
@@ -151,7 +157,7 @@ function parseToolCallTagAt(text: string, start: number): ParsedToolCallTag | nu
|
||||
};
|
||||
}
|
||||
|
||||
function stripToolCallXmlTags(text: string): string {
|
||||
export function stripToolCallXmlTags(text: string): string {
|
||||
if (!text || !TOOL_CALL_QUICK_RE.test(text)) {
|
||||
return text;
|
||||
}
|
||||
@@ -224,7 +230,8 @@ function stripToolCallXmlTags(text: string): string {
|
||||
}
|
||||
} else if (
|
||||
tag.isClose &&
|
||||
tag.tagName === toolCallBlockTagName &&
|
||||
(tag.tagName === toolCallBlockTagName ||
|
||||
(toolCallBlockTagName === "tool_result" && tag.tagName === "tool_call")) &&
|
||||
!endsInsideQuotedString(text, toolCallContentStart, idx)
|
||||
) {
|
||||
inToolCallBlock = false;
|
||||
|
||||
Reference in New Issue
Block a user