fix: strip heartbeat tool marker replies

This commit is contained in:
Peter Steinberger
2026-05-02 02:51:35 +01:00
parent bdda14e170
commit b8a991a665
7 changed files with 80 additions and 6 deletions

View File

@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Heartbeat: strip legacy `[TOOL_CALL]...[/TOOL_CALL]` and `[TOOL_RESULT]...[/TOOL_RESULT]` pseudo-call blocks from heartbeat replies before channel delivery. Fixes #54138. Thanks @Deniable9570.
- Slack: recover full inbound DM text from top-level rich-text blocks when Slack sends a shortened message preview, so long direct messages still reach the agent intact. Fixes #55358. Thanks @tonyjwinter.
- Replies: strip legacy `[TOOL_CALL]{tool => ..., args => ...}[/TOOL_CALL]` pseudo-call text from user-facing replies and flag it in tool-call diagnostics instead of showing raw tool syntax in channels. Fixes #63610. Thanks @canh0chua.
- WhatsApp: close long-lived web sockets through Baileys `end(error)` before falling back to raw websocket close, so listener teardown runs Baileys cleanup instead of leaving zombie sockets. Fixes #52442. Thanks @essendigitalgroup-cyber.

View File

@@ -227,6 +227,14 @@ describe("sanitizeUserFacingText", () => {
expect(sanitizeUserFacingText(input)).toBe("Before\n\nAfter");
});
it("strips legacy uppercase TOOL_RESULT blocks before user-facing delivery", () => {
const input = ["Before", '[TOOL_RESULT]{"output":"secret result"}[/TOOL_RESULT]', "After"].join(
"\n",
);
expect(sanitizeUserFacingText(input)).toBe("Before\n\nAfter");
});
it("keeps ordinary inline mentions of the replay placeholder", () => {
expect(sanitizeUserFacingText("What does [tool calls omitted] mean?")).toBe(
"What does [tool calls omitted] mean?",

View File

@@ -26,6 +26,26 @@ async function expectSameTargetRepliesSuppressed(params: { provider: string; to:
}
describe("buildReplyPayloads media filter integration", () => {
it("strips legacy bracket tool blocks from heartbeat replies", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
isHeartbeat: true,
payloads: [
{
text: [
"Before",
'[TOOL_CALL]{tool => "exec", args => {"command":"ls"}}[/TOOL_CALL]',
'[TOOL_RESULT]{"output":"secret result"}[/TOOL_RESULT]',
"After",
].join("\n"),
},
],
});
expect(replyPayloads).toHaveLength(1);
expect(replyPayloads[0]?.text).toBe("Before\n\n\nAfter");
});
it("strips media URL from payload when in messagingToolSentMediaUrls", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,

View File

@@ -2,6 +2,7 @@ import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-pay
import type { MessagingToolSend } from "../../agents/pi-embedded-messaging.types.js";
import type { ReplyToMode } from "../../config/types.js";
import { logVerbose } from "../../globals.js";
import { stripLegacyBracketToolCallBlocks } from "../../shared/text/assistant-visible-text.js";
import { stripHeartbeatToken } from "../heartbeat.js";
import type { OriginatingChannelType } from "../templating.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
@@ -91,6 +92,19 @@ function shouldKeepPayloadDuringSilentTurn(payload: ReplyPayload): boolean {
return payload.audioAsVoice === true && resolveSendableOutboundReplyParts(payload).hasMedia;
}
function sanitizeHeartbeatPayload(payload: ReplyPayload): ReplyPayload {
const text = payload.text;
if (!text) {
return payload;
}
const cleaned = stripLegacyBracketToolCallBlocks(text);
if (cleaned === text) {
return payload;
}
logVerbose("Stripped legacy tool-call block from heartbeat reply");
return { ...payload, text: cleaned };
}
export async function buildReplyPayloads(params: {
payloads: ReplyPayload[];
isHeartbeat: boolean;
@@ -116,7 +130,7 @@ export async function buildReplyPayloads(params: {
}): Promise<{ replyPayloads: ReplyPayload[]; didLogHeartbeatStrip: boolean }> {
let didLogHeartbeatStrip = params.didLogHeartbeatStrip;
const sanitizedPayloads = params.isHeartbeat
? params.payloads
? params.payloads.map((payload) => sanitizeHeartbeatPayload(payload))
: params.payloads.flatMap((payload) => {
let text = payload.text;

View File

@@ -221,6 +221,15 @@ describe("normalizeReplyPayload", () => {
expect(result!.text).toBe("Before\n\nAfter");
});
it("strips legacy uppercase TOOL_RESULT blocks from normalized replies", () => {
const result = normalizeReplyPayload({
text: ["Before", '[TOOL_RESULT]{"output":"secret result"}[/TOOL_RESULT]', "After"].join("\n"),
});
expect(result).not.toBeNull();
expect(result!.text).toBe("Before\n\nAfter");
});
it("does not compile Slack directives unless interactive replies are enabled", () => {
const result = normalizeReplyPayload({
text: "hello [[slack_buttons: Retry:retry, Ignore:ignore]]",

View File

@@ -197,6 +197,13 @@ describe("stripAssistantInternalScaffolding", () => {
);
});
it("strips legacy uppercase TOOL_RESULT blocks with object payloads", () => {
expectVisibleText(
["Before", '[TOOL_RESULT]{"output":"secret result"}[/TOOL_RESULT]', "After"].join("\n"),
"Before\n\nAfter",
);
});
it("preserves literal legacy TOOL_CALL examples without tool args payloads", () => {
expectVisibleText(
"Use `[TOOL_CALL]` only when describing legacy logs.",

View File

@@ -10,7 +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;
const LEGACY_BRACKET_TOOL_BLOCK_QUICK_RE = /\[\s*\/?\s*TOOL_(?:CALL|RESULT)\s*\]/i;
/**
* Strip XML-style tool call tags that models sometimes emit as plain text.
@@ -361,8 +361,16 @@ function isLegacyBracketToolCallPayload(value: string): boolean {
);
}
function isLegacyBracketToolResultPayload(value: string): boolean {
return (
/^\s*[{[]/.test(value) ||
/\b(?:tool|result|output|content)\s*=>/i.test(value) ||
/\b(?:tool|result|output|content)\s*:/i.test(value)
);
}
export function stripLegacyBracketToolCallBlocks(text: string): string {
if (!text || !LEGACY_BRACKET_TOOL_CALL_QUICK_RE.test(text)) {
if (!text || !LEGACY_BRACKET_TOOL_BLOCK_QUICK_RE.test(text)) {
return text;
}
@@ -370,11 +378,12 @@ export function stripLegacyBracketToolCallBlocks(text: string): string {
let result = "";
let cursor = 0;
while (cursor < text.length) {
const openMatch = /\[\s*TOOL_CALL\s*\]/gi.exec(text.slice(cursor));
const openMatch = /\[\s*TOOL_(CALL|RESULT)\s*\]/gi.exec(text.slice(cursor));
if (!openMatch?.[0]) {
result += text.slice(cursor);
break;
}
const blockKind = openMatch[1]?.toUpperCase();
const openStart = cursor + (openMatch.index ?? 0);
const payloadStart = openStart + openMatch[0].length;
if (isInsideCode(openStart, codeRegions)) {
@@ -383,14 +392,20 @@ export function stripLegacyBracketToolCallBlocks(text: string): string {
continue;
}
const closeMatch = /\[\s*\/\s*TOOL_CALL\s*\]/gi.exec(text.slice(payloadStart));
const closeRe =
blockKind === "RESULT" ? /\[\s*\/\s*TOOL_RESULT\s*\]/gi : /\[\s*\/\s*TOOL_CALL\s*\]/gi;
const closeMatch = closeRe.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)) {
const shouldStrip =
blockKind === "RESULT"
? isLegacyBracketToolResultPayload(payload)
: isLegacyBracketToolCallPayload(payload);
if (!shouldStrip) {
result += text.slice(cursor, payloadStart);
cursor = payloadStart;
continue;