mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 05:00:42 +00:00
fix: strip legacy tool-call text from replies
This commit is contained in:
@@ -179,6 +179,41 @@ describe("stripAssistantInternalScaffolding", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("strips legacy uppercase TOOL_CALL blocks with hash-style payloads", () => {
|
||||
expectVisibleText(
|
||||
[
|
||||
"Before",
|
||||
'[TOOL_CALL]{tool => "web_search", args => {"query":"NET stock price"}}[/TOOL_CALL]',
|
||||
"After",
|
||||
].join("\n"),
|
||||
"Before\n\nAfter",
|
||||
);
|
||||
});
|
||||
|
||||
it("hides dangling legacy uppercase TOOL_CALL blocks to end-of-string", () => {
|
||||
expectVisibleText(
|
||||
'Before\n[TOOL_CALL]{tool => "web_search", args => {"query":"NET stock price"}',
|
||||
"Before\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves literal legacy TOOL_CALL examples without tool args payloads", () => {
|
||||
expectVisibleText(
|
||||
"Use `[TOOL_CALL]` only when describing legacy logs.",
|
||||
"Use `[TOOL_CALL]` only when describing legacy logs.",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves legacy uppercase TOOL_CALL blocks inside fenced code", () => {
|
||||
const input = [
|
||||
"```text",
|
||||
'[TOOL_CALL]{tool => "web_search", args => {"query":"x"}}[/TOOL_CALL]',
|
||||
"```",
|
||||
"Visible",
|
||||
].join("\n");
|
||||
expectVisibleText(input, input);
|
||||
});
|
||||
|
||||
it("strips Qwen-style <tool_call> with nested <function=...> XML", () => {
|
||||
expectVisibleText(
|
||||
"prefix\n<tool_call><function=read><parameter=path>/home/user</parameter></function></tool_call>\nsuffix",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
|
||||
const MEMORY_TAG_RE = /<\s*(\/?)\s*relevant[-_]memories\b[^<>]*>/gi;
|
||||
const MEMORY_TAG_QUICK_RE = /<\s*\/?\s*relevant[-_]memories\b/i;
|
||||
const LEGACY_BRACKET_TOOL_CALL_QUICK_RE = /\[\s*\/?\s*TOOL_CALL\s*\]/i;
|
||||
|
||||
/**
|
||||
* Strip XML-style tool call tags that models sometimes emit as plain text.
|
||||
@@ -353,6 +354,55 @@ export function stripMinimaxToolCallXml(text: string): string {
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function isLegacyBracketToolCallPayload(value: string): boolean {
|
||||
return (
|
||||
/\btool\s*=>\s*["'][A-Za-z_][A-Za-z0-9_.:-]{0,119}["']/i.test(value) &&
|
||||
/\bargs\s*=>/i.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function stripLegacyBracketToolCallBlocks(text: string): string {
|
||||
if (!text || !LEGACY_BRACKET_TOOL_CALL_QUICK_RE.test(text)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const codeRegions = findCodeRegions(text);
|
||||
let result = "";
|
||||
let cursor = 0;
|
||||
while (cursor < text.length) {
|
||||
const openMatch = /\[\s*TOOL_CALL\s*\]/gi.exec(text.slice(cursor));
|
||||
if (!openMatch?.[0]) {
|
||||
result += text.slice(cursor);
|
||||
break;
|
||||
}
|
||||
const openStart = cursor + (openMatch.index ?? 0);
|
||||
const payloadStart = openStart + openMatch[0].length;
|
||||
if (isInsideCode(openStart, codeRegions)) {
|
||||
result += text.slice(cursor, payloadStart);
|
||||
cursor = payloadStart;
|
||||
continue;
|
||||
}
|
||||
|
||||
const closeMatch = /\[\s*\/\s*TOOL_CALL\s*\]/gi.exec(text.slice(payloadStart));
|
||||
const closeStart =
|
||||
closeMatch?.[0] && !isInsideCode(payloadStart + (closeMatch.index ?? 0), codeRegions)
|
||||
? payloadStart + (closeMatch.index ?? 0)
|
||||
: -1;
|
||||
const payloadEnd = closeStart >= 0 ? closeStart : text.length;
|
||||
const payload = text.slice(payloadStart, payloadEnd);
|
||||
if (!isLegacyBracketToolCallPayload(payload)) {
|
||||
result += text.slice(cursor, payloadStart);
|
||||
cursor = payloadStart;
|
||||
continue;
|
||||
}
|
||||
|
||||
result += text.slice(cursor, openStart);
|
||||
cursor = closeStart >= 0 ? closeStart + (closeMatch?.[0].length ?? 0) : text.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip downgraded tool call text representations that leak into user-visible
|
||||
* text content when replaying history across providers.
|
||||
@@ -621,6 +671,7 @@ function applyAssistantVisibleTextStagePipeline(
|
||||
cleaned = stripToolCallXmlTags(cleaned, {
|
||||
stripFunctionCallsXmlPayloads: options.stripFunctionCallsXmlPayloads,
|
||||
});
|
||||
cleaned = stripLegacyBracketToolCallBlocks(cleaned);
|
||||
cleaned = stripPlainTextToolCallBlocks(cleaned);
|
||||
if (!options.preserveDowngradedToolText) {
|
||||
cleaned = stripDowngradedToolCallText(cleaned);
|
||||
|
||||
@@ -29,6 +29,14 @@ describe("detectToolCallShapedText", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("detects legacy uppercase TOOL_CALL assistant text", () => {
|
||||
expect(
|
||||
detectToolCallShapedText(
|
||||
'[TOOL_CALL]{tool => "web_search", args => {"query":"NET stock price"}}[/TOOL_CALL]',
|
||||
),
|
||||
).toEqual({ kind: "bracketed_tool_call", toolName: "web_search" });
|
||||
});
|
||||
|
||||
it("ignores normal JSON and prose mentions", () => {
|
||||
expect(detectToolCallShapedText('{"status":"ok","message":"done"}')).toBeNull();
|
||||
expect(detectToolCallShapedText("Use tool_call tags only in examples.")).toBeNull();
|
||||
|
||||
@@ -199,6 +199,14 @@ function detectXmlToolCall(text: string): ToolCallShapedTextDetection | null {
|
||||
}
|
||||
|
||||
function detectBracketedToolCall(text: string): ToolCallShapedTextDetection | null {
|
||||
const legacyMatch =
|
||||
/\[\s*TOOL_CALL\s*\]\s*{[\s\S]{0,8000}?\btool\s*=>\s*["']([A-Za-z_][A-Za-z0-9_.:-]{0,119})["'][\s\S]{0,8000}?\bargs\s*=>[\s\S]*?(?:\[\s*\/\s*TOOL_CALL\s*\]|$)/i.exec(
|
||||
text,
|
||||
);
|
||||
if (legacyMatch?.[1]) {
|
||||
return { kind: "bracketed_tool_call", toolName: legacyMatch[1] };
|
||||
}
|
||||
|
||||
const match =
|
||||
/^\s*\[([A-Za-z_][A-Za-z0-9_.:-]{0,119})\]\s+[\s\S]*?\[END_TOOL_REQUEST\]\s*$/i.exec(text);
|
||||
if (!match?.[1]) {
|
||||
|
||||
Reference in New Issue
Block a user