mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:50:43 +00:00
fix: strip heartbeat tool marker replies
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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]]",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user