From b8a991a6652af7920479949dee1d1da9d28a379a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 02:51:35 +0100 Subject: [PATCH] fix: strip heartbeat tool marker replies --- CHANGELOG.md | 1 + ...ded-helpers.sanitizeuserfacingtext.test.ts | 8 ++++++ .../reply/agent-runner-payloads.test.ts | 20 +++++++++++++++ src/auto-reply/reply/agent-runner-payloads.ts | 16 +++++++++++- src/auto-reply/reply/reply-utils.test.ts | 9 +++++++ .../text/assistant-visible-text.test.ts | 7 ++++++ src/shared/text/assistant-visible-text.ts | 25 +++++++++++++++---- 7 files changed, 80 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c006332a433..2f0032142e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts index f877b9e8150..5e6aa755224 100644 --- a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts +++ b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts @@ -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?", diff --git a/src/auto-reply/reply/agent-runner-payloads.test.ts b/src/auto-reply/reply/agent-runner-payloads.test.ts index 91caf41ac71..fd98c5a70ad 100644 --- a/src/auto-reply/reply/agent-runner-payloads.test.ts +++ b/src/auto-reply/reply/agent-runner-payloads.test.ts @@ -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, diff --git a/src/auto-reply/reply/agent-runner-payloads.ts b/src/auto-reply/reply/agent-runner-payloads.ts index d62f7844180..627e43f993a 100644 --- a/src/auto-reply/reply/agent-runner-payloads.ts +++ b/src/auto-reply/reply/agent-runner-payloads.ts @@ -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; diff --git a/src/auto-reply/reply/reply-utils.test.ts b/src/auto-reply/reply/reply-utils.test.ts index 0e386e7fba4..91b438462b3 100644 --- a/src/auto-reply/reply/reply-utils.test.ts +++ b/src/auto-reply/reply/reply-utils.test.ts @@ -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]]", diff --git a/src/shared/text/assistant-visible-text.test.ts b/src/shared/text/assistant-visible-text.test.ts index 4b63e9221b8..f55e93da8f9 100644 --- a/src/shared/text/assistant-visible-text.test.ts +++ b/src/shared/text/assistant-visible-text.test.ts @@ -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.", diff --git a/src/shared/text/assistant-visible-text.ts b/src/shared/text/assistant-visible-text.ts index f6c3be0a8c4..96bf4ce6894 100644 --- a/src/shared/text/assistant-visible-text.ts +++ b/src/shared/text/assistant-visible-text.ts @@ -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;