From 652f34103a4d4873046475808ea91438b55f8aa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Cuevas?= Date: Wed, 29 Apr 2026 00:43:25 -0400 Subject: [PATCH] fix(whatsapp): sanitize tool XML and hide configured error text (#71830) Merged via squash. Prepared head SHA: 184d4a25e41744752225d947640af45db343556a Co-authored-by: rubencu <4742789+rubencu@users.noreply.github.com> Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com> Reviewed-by: @mcaxtr --- CHANGELOG.md | 1 + docs/.generated/config-baseline.sha256 | 8 +- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/channels/whatsapp.md | 18 ++- extensions/whatsapp/src/accounts.ts | 2 + .../src/auto-reply/deliver-reply.test.ts | 72 ++++++++++++ .../whatsapp/src/auto-reply/deliver-reply.ts | 14 ++- extensions/whatsapp/src/auto-reply/monitor.ts | 1 + .../monitor/inbound-dispatch.test.ts | 104 +++++++++++++++++ .../auto-reply/monitor/inbound-dispatch.ts | 39 ++++++- .../whatsapp/src/channel-outbound.test.ts | 46 ++++++++ extensions/whatsapp/src/channel-outbound.ts | 4 +- extensions/whatsapp/src/config-schema.test.ts | 14 +++ extensions/whatsapp/src/config-ui-hints.ts | 4 + .../src/outbound-adapter.sendpayload.test.ts | 74 ++++++++++++ extensions/whatsapp/src/outbound-adapter.ts | 9 +- extensions/whatsapp/src/outbound-base.ts | 11 +- .../whatsapp/src/outbound-media-contract.ts | 51 +++++++-- src/channels/plugins/outbound.types.ts | 1 + ...ndled-channel-config-metadata.generated.ts | 10 ++ src/config/types.whatsapp.ts | 2 + .../zod-schema.providers-whatsapp.test.ts | 15 +++ src/config/zod-schema.providers-whatsapp.ts | 1 + src/infra/outbound/deliver.test.ts | 107 ++++++++++++++++++ src/infra/outbound/deliver.ts | 29 ++++- .../text/assistant-visible-text.test.ts | 12 ++ src/shared/text/assistant-visible-text.ts | 54 +++++++-- 27 files changed, 660 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e2ac576582..73d1bebc7e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,7 @@ Docs: https://docs.openclaw.ai - Channels/WhatsApp: detect explicit group `@mentions` again when the bot's own E.164 is in `allowFrom`, so shared-number setups no longer skip group pings that directly mention the bot. Fixes #49317. (#73453) Thanks @juan-flores077. - WhatsApp/reliability: publish real transport-liveness into WhatsApp channel status and force earlier reconnects on silent transport stalls, so quiet healthy sessions stay connected while wedged sockets recover before the later remote 408 path. (#72656) Thanks @Sathvik-1007. - Core/channels: tighten selected runtime, media, and plugin edge-case handling while preserving existing behavior. Thanks @jesse-merhi. +- Channels/WhatsApp: strip leaked plural tool-call XML wrappers on every WhatsApp-visible outbound path and allow `channels.whatsapp.exposeErrorText` to suppress visible error text per channel or account. (#71830) Thanks @rubencu. ## 2026.4.27 diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 6e36959297e..44660d2373f 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -7f9815a297504c75022c4db2df250ce4cc9ff5c3f69250c67ca253b89148b9f3 config-baseline.json -8bc9fda7c1096472beaa416a61043ce51d691d4dcad9ed3e0be46e68bb70b0ce config-baseline.core.json -45162ff84813be8a1fe561ed8d6245a248d5c6288ef9e9af51bdf4ec05ef65ad config-baseline.channel.json -0dd6583fafae6c9134e46c4cf9bddee9822d6436436dcb1a6dcba6d012962e51 config-baseline.plugin.json +8903fd57a58acb9a7c949efc6b4197b249220dcd965420ceb7d884cb45fbc48d config-baseline.json +86ad0927d992bc873affb3e20a31c6e3c95b2185a91f46cc8e6262a723a78f7d config-baseline.core.json +bb7234c52b0bbf12de2a87fa553ec4e89e13aaba9d0d81cf1370621292da13e9 config-baseline.channel.json +1f5592bfd141ba1e982ce31763a253c10afb080ab4ea2b6538299b114e29cee1 config-baseline.plugin.json diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 6802c7ccf84..dc4ecfcac87 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -e7d03a0d5aed4f1afb5c7d5e235a166e1e248090632248eaa92b0016531e7f3b plugin-sdk-api-baseline.json -b9bbf8e444b358485cb33c634d3f6f6588004a5c32482c1a473167957269ae58 plugin-sdk-api-baseline.jsonl +c65b1aa1fb4cf402b90bedd3614eb5d7c3903ab860856392d1ee2481818a7a22 plugin-sdk-api-baseline.json +da172742470204044c1542a3bba7f183161e90e742f6865c1c7f822dbdc7a7d6 plugin-sdk-api-baseline.jsonl diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index da9835ed12c..59022be259f 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -393,6 +393,22 @@ When the linked self number is also present in `allowFrom`, WhatsApp self-chat s +## Error visibility + +`channels.whatsapp.exposeErrorText` controls whether agent/provider error text is delivered back into WhatsApp. The default is `true`. Set it to `false` to keep failures quiet on WhatsApp while preserving other channel behavior. + +```json5 +{ + channels: { + whatsapp: { + exposeErrorText: false, + }, + }, +} +``` + +Per-account overrides use `channels.whatsapp.accounts..exposeErrorText`. + ## Reply quoting WhatsApp supports native reply quoting, where outbound replies visibly quote the inbound message. Control it with `channels.whatsapp.replyToMode`. @@ -660,7 +676,7 @@ Primary reference: High-signal WhatsApp fields: - access: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups` -- delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `sendReadReceipts`, `ackReaction`, `reactionLevel` +- delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `sendReadReceipts`, `ackReaction`, `reactionLevel`, `exposeErrorText` - multi-account: `accounts..enabled`, `accounts..authDir`, account-level overrides - operations: `configWrites`, `debounceMs`, `web.enabled`, `web.heartbeatSeconds`, `web.reconnect.*`, `web.whatsapp.*` - session behavior: `session.dmScope`, `historyLimit`, `dmHistoryLimit`, `dms..historyLimit` diff --git a/extensions/whatsapp/src/accounts.ts b/extensions/whatsapp/src/accounts.ts index e6c8101f725..1e10209ab17 100644 --- a/extensions/whatsapp/src/accounts.ts +++ b/extensions/whatsapp/src/accounts.ts @@ -45,6 +45,7 @@ export type ResolvedWhatsAppAccount = { direct?: WhatsAppAccountConfig["direct"]; debounceMs?: number; replyToMode?: ReplyToMode; + exposeErrorText?: boolean; }; export const DEFAULT_WHATSAPP_MEDIA_MAX_MB = 50; @@ -156,6 +157,7 @@ export function resolveWhatsAppAccount(params: { direct: merged.direct, debounceMs: merged.debounceMs, replyToMode: merged.replyToMode, + exposeErrorText: merged.exposeErrorText, }; } diff --git a/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts index 5bb4c62f2bf..4fb754e1f71 100644 --- a/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts +++ b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts @@ -177,6 +177,54 @@ describe("deliverWebReply", () => { expect(replyLogger.info).toHaveBeenCalledWith(expect.any(Object), "auto-reply sent (text)"); }); + it("strips raw XML tool-call blocks before WhatsApp text delivery", async () => { + const msg = makeMsg(); + + await deliverWebReply({ + replyResult: { + text: 'Before\nx\nAfter', + }, + msg, + maxMediaBytes: 1024 * 1024, + textLimit: 4000, + replyLogger, + skipLog: true, + }); + + expect(msg.reply).toHaveBeenCalledTimes(1); + const sentText = vi.mocked(msg.reply).mock.calls[0]?.[0]; + expect(sentText).not.toContain("function_calls"); + expect(sentText).not.toContain("invoke"); + expect(sentText).toContain("Before"); + expect(sentText).toContain("After"); + }); + + it("uses the same final sanitizer stack for auto-reply text delivery", async () => { + const msg = makeMsg(); + + await deliverWebReply({ + replyResult: { + text: [ + "Before", + "", + ' ', + ' hidden', + " ", + "", + "
After
", + ].join("\n"), + }, + msg, + maxMediaBytes: 1024 * 1024, + textLimit: 4000, + replyLogger, + skipLog: true, + }); + + expect(msg.reply).toHaveBeenCalledTimes(1); + expect(vi.mocked(msg.reply).mock.calls[0]?.[0]).toBe("Before\n\nAfter\n"); + }); + it("keeps quote threading on every text chunk for a threaded reply", async () => { const msg = makeMsg(); cacheInboundMessageMeta("work", "15551234567@s.whatsapp.net", "reply-1", { @@ -478,6 +526,30 @@ describe("deliverWebReply", () => { ).not.toContain("boom"); }); + it("sanitizes XML tool-call blocks for outbound sendPayload delivery", async () => { + const sendWhatsApp = vi.fn(async (_to: string, _text: string) => ({ + messageId: "wa-1", + toJid: "jid", + })); + + await whatsappOutbound.sendPayload!({ + cfg: {}, + to: "5511999999999@c.us", + text: "", + payload: { + text: 'Before\nx\nAfter', + }, + deps: { sendWhatsApp }, + }); + + expect(sendWhatsApp).toHaveBeenCalledTimes(1); + const sentText = sendWhatsApp.mock.calls[0]?.[1]; + expect(sentText).not.toContain("function_calls"); + expect(sentText).not.toContain("invoke"); + expect(sentText).toContain("Before"); + expect(sentText).toContain("After"); + }); + it("keeps payload and auto-reply media normalization in parity", async () => { const payload = { text: "\n\ncaption", diff --git a/extensions/whatsapp/src/auto-reply/deliver-reply.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.ts index 3c4adcd415f..2a88096e96b 100644 --- a/extensions/whatsapp/src/auto-reply/deliver-reply.ts +++ b/extensions/whatsapp/src/auto-reply/deliver-reply.ts @@ -8,6 +8,7 @@ import { import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { loadWebMedia } from "../media.js"; import { + type DeliverableWhatsAppOutboundPayload, normalizeWhatsAppOutboundPayload, normalizeWhatsAppPayloadTextPreservingIndentation, prepareWhatsAppOutboundMedia, @@ -24,6 +25,7 @@ import { elide } from "./util.js"; export async function deliverWebReply(params: { replyResult: ReplyPayload; + normalizedReplyResult?: DeliverableWhatsAppOutboundPayload; msg: WebInboundMsg; mediaLocalRoots?: readonly string[]; maxMediaBytes: number; @@ -45,10 +47,14 @@ export async function deliverWebReply(params: { } const tableMode = params.tableMode ?? "code"; const chunkMode = params.chunkMode ?? "length"; - const normalizedReply = normalizeWhatsAppOutboundPayload(replyResult, { - normalizeText: normalizeWhatsAppPayloadTextPreservingIndentation, - }); - const convertedText = markdownToWhatsApp(convertMarkdownTables(normalizedReply.text, tableMode)); + const normalizedReply = + params.normalizedReplyResult ?? + normalizeWhatsAppOutboundPayload(replyResult, { + normalizeText: normalizeWhatsAppPayloadTextPreservingIndentation, + }); + const convertedText = markdownToWhatsApp( + convertMarkdownTables(normalizedReply.text ?? "", tableMode), + ); const textChunks = chunkMarkdownTextWithMode(convertedText, textLimit, chunkMode); const mediaList = normalizedReply.mediaUrls ?? []; diff --git a/extensions/whatsapp/src/auto-reply/monitor.ts b/extensions/whatsapp/src/auto-reply/monitor.ts index f4a2f3a4674..662b793280f 100644 --- a/extensions/whatsapp/src/auto-reply/monitor.ts +++ b/extensions/whatsapp/src/auto-reply/monitor.ts @@ -176,6 +176,7 @@ export async function monitorWebChannel( mediaMaxMb: account.mediaMaxMb, blockStreaming: account.blockStreaming, groups: account.groups, + exposeErrorText: account.exposeErrorText, }, }, } satisfies ReturnType; diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts index 50ee424ec39..5095ca0ce24 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts @@ -6,6 +6,7 @@ type CapturedReplyPayload = { text?: string; isReasoning?: boolean; isCompactionNotice?: boolean; + isError?: boolean; mediaUrl?: string; mediaUrls?: string[]; }; @@ -434,6 +435,39 @@ describe("whatsapp inbound dispatch", () => { expect(rememberSentText).toHaveBeenCalledTimes(4); }); + it("normalizes WhatsApp payload text before delivery and echo bookkeeping", async () => { + const deliverReply = vi.fn(async () => undefined); + const rememberSentText = vi.fn(); + + await dispatchBufferedReply({ + deliverReply, + rememberSentText, + }); + + const deliver = getCapturedDeliver(); + expect(deliver).toBeTypeOf("function"); + + await deliver?.( + { + text: 'Before\nx\nAfter', + }, + { kind: "final" }, + ); + + expect(deliverReply).toHaveBeenCalledWith( + expect.objectContaining({ + replyResult: expect.objectContaining({ text: "Before\n\nAfter" }), + }), + ); + expect(rememberSentText).toHaveBeenCalledWith( + "Before\n\nAfter", + expect.objectContaining({ + combinedBody: "hi", + combinedBodySessionKey: "agent:main:whatsapp:direct:+1000", + }), + ); + }); + it("suppresses reasoning and compaction payloads before WhatsApp delivery", async () => { const deliverReply = vi.fn(async () => undefined); const rememberSentText = vi.fn(); @@ -455,6 +489,76 @@ describe("whatsapp inbound dispatch", () => { expect(rememberSentText).not.toHaveBeenCalled(); }); + it("suppresses payloads that normalize to no visible WhatsApp content", async () => { + const deliverReply = vi.fn(async () => undefined); + const rememberSentText = vi.fn(); + + await dispatchBufferedReply({ + deliverReply, + rememberSentText, + }); + + const deliver = getCapturedDeliver(); + expect(deliver).toBeTypeOf("function"); + + await deliver?.( + { + text: 'x', + }, + { kind: "final" }, + ); + + expect(deliverReply).not.toHaveBeenCalled(); + expect(rememberSentText).not.toHaveBeenCalled(); + }); + + it("suppresses error payload text when exposeErrorText is false", async () => { + const deliverReply = vi.fn(async () => undefined); + const rememberSentText = vi.fn(); + + await dispatchBufferedReply({ + cfg: { channels: { whatsapp: { exposeErrorText: false } } } as never, + deliverReply, + rememberSentText, + }); + + const deliver = getCapturedDeliver(); + expect(deliver).toBeTypeOf("function"); + + await deliver?.({ text: "provider exploded", isError: true }, { kind: "final" }); + + expect(deliverReply).not.toHaveBeenCalled(); + expect(rememberSentText).not.toHaveBeenCalled(); + }); + + it("honors account-level exposeErrorText overrides for error payloads", async () => { + const deliverReply = vi.fn(async () => undefined); + const rememberSentText = vi.fn(); + + await dispatchBufferedReply({ + cfg: { + channels: { + whatsapp: { + accounts: { + work: { exposeErrorText: false }, + }, + }, + }, + } as never, + deliverReply, + rememberSentText, + route: makeRoute({ accountId: "work" }), + }); + + const deliver = getCapturedDeliver(); + expect(deliver).toBeTypeOf("function"); + + await deliver?.({ text: "provider exploded", isError: true }, { kind: "final" }); + + expect(deliverReply).not.toHaveBeenCalled(); + expect(rememberSentText).not.toHaveBeenCalled(); + }); + it("maps WhatsApp blockStreaming=true to disableBlockStreaming=false", async () => { await dispatchBufferedReply(); diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts index 971d8cedd75..794b8244f52 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts @@ -1,3 +1,9 @@ +import { resolveMergedWhatsAppAccountConfig } from "../../account-config.js"; +import { + type DeliverableWhatsAppOutboundPayload, + normalizeWhatsAppOutboundPayload, + normalizeWhatsAppPayloadTextPreservingIndentation, +} from "../../outbound-media-contract.js"; import type { WebInboundMsg } from "../types.js"; import { formatGroupMembers } from "./group-members.js"; import type { GroupHistoryEntry } from "./inbound-context.js"; @@ -60,10 +66,14 @@ function resolveWhatsAppDisableBlockStreaming(cfg: ReturnType): bo function resolveWhatsAppDeliverablePayload( payload: ReplyPayload, info: { kind: ReplyLifecycleKind }, + options?: { exposeErrorText?: boolean }, ): ReplyPayload | null { if (payload.isReasoning === true || payload.isCompactionNotice === true) { return null; } + if (payload.isError === true && options?.exposeErrorText === false) { + return null; + } if (info.kind === "tool") { if (!resolveSendableOutboundReplyParts(payload).hasMedia) { return null; @@ -241,6 +251,7 @@ export async function dispatchWhatsAppBufferedReply(params: { conversationId: string; deliverReply: (params: { replyResult: ReplyPayload; + normalizedReplyResult?: DeliverableWhatsAppOutboundPayload; msg: WebInboundMsg; mediaLocalRoots: readonly string[]; maxMediaBytes: number; @@ -280,6 +291,9 @@ export async function dispatchWhatsAppBufferedReply(params: { }); const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.route.agentId); const disableBlockStreaming = resolveWhatsAppDisableBlockStreaming(params.cfg); + const exposeErrorText = + resolveMergedWhatsAppAccountConfig({ cfg: params.cfg, accountId: params.route.accountId }) + .exposeErrorText !== false; let didSendReply = false; let didLogHeartbeatStrip = false; @@ -296,12 +310,26 @@ export async function dispatchWhatsAppBufferedReply(params: { } }, deliver: async (payload: ReplyPayload, info: { kind: ReplyLifecycleKind }) => { - const deliveryPayload = resolveWhatsAppDeliverablePayload(payload, info); + const deliveryPayload = resolveWhatsAppDeliverablePayload(payload, info, { + exposeErrorText, + }); if (!deliveryPayload) { return; } + const normalizedOutboundPayload = normalizeWhatsAppOutboundPayload(deliveryPayload, { + normalizeText: normalizeWhatsAppPayloadTextPreservingIndentation, + }); + const normalizedDeliveryPayload = + deliveryPayload.text === undefined + ? { ...normalizedOutboundPayload, text: undefined } + : normalizedOutboundPayload; + const reply = resolveSendableOutboundReplyParts(normalizedDeliveryPayload); + if (!reply.hasMedia && !reply.text.trim()) { + return; + } await params.deliverReply({ - replyResult: deliveryPayload, + replyResult: normalizedDeliveryPayload, + normalizedReplyResult: normalizedDeliveryPayload, msg: params.msg, mediaLocalRoots, maxMediaBytes: params.maxMediaBytes, @@ -313,17 +341,16 @@ export async function dispatchWhatsAppBufferedReply(params: { tableMode, }); didSendReply = true; - const shouldLog = deliveryPayload.text ? true : undefined; - params.rememberSentText(deliveryPayload.text, { + const shouldLog = normalizedDeliveryPayload.text ? true : undefined; + params.rememberSentText(normalizedDeliveryPayload.text, { combinedBody: params.context.Body as string | undefined, combinedBodySessionKey: params.route.sessionKey, logVerboseMessage: shouldLog, }); const fromDisplay = params.msg.chatType === "group" ? params.conversationId : (params.msg.from ?? "unknown"); - const reply = resolveSendableOutboundReplyParts(deliveryPayload); if (shouldLogVerbose()) { - const preview = deliveryPayload.text != null ? reply.text : ""; + const preview = normalizedDeliveryPayload.text != null ? reply.text : ""; logVerbose(`Reply body: ${preview}${reply.hasMedia ? " (media)" : ""} -> ${fromDisplay}`); } }, diff --git a/extensions/whatsapp/src/channel-outbound.test.ts b/extensions/whatsapp/src/channel-outbound.test.ts index a6392a01b41..1d74ca6c45b 100644 --- a/extensions/whatsapp/src/channel-outbound.test.ts +++ b/extensions/whatsapp/src/channel-outbound.test.ts @@ -39,6 +39,52 @@ describe("whatsappChannelOutbound", () => { }); }); + it("keeps XML sanitizer normalization idempotent", () => { + const raw = [ + "", + ' ', + ' hidden', + " ", + "", + "After", + ].join("\n"); + const once = whatsappChannelOutbound.normalizePayload?.({ payload: { text: raw } }); + const twice = whatsappChannelOutbound.normalizePayload?.({ payload: { text: once?.text } }); + + expect(once?.text).toBe("After"); + expect(twice?.text).toBe("After"); + }); + + it("drops whitespace-only text after XML sanitizer removal", () => { + const raw = [ + " ", + ' ', + ' hidden', + " ", + " ", + ].join("\n"); + + expect(whatsappChannelOutbound.normalizePayload?.({ payload: { text: raw } })).toEqual({ + text: "", + }); + }); + + it("sanitizes XML tool payloads before plain HTML stripping", () => { + const raw = [ + "Before", + "", + ' ', + ' hidden', + " ", + "", + "After", + ].join("\n"); + + expect(whatsappChannelOutbound.sanitizeText?.({ text: raw, payload: { text: raw } })).toBe( + "Before\n\nAfter", + ); + }); + it("preserves indentation for live text sends", async () => { await whatsappChannelOutbound.sendText!({ cfg: {}, diff --git a/extensions/whatsapp/src/channel-outbound.ts b/extensions/whatsapp/src/channel-outbound.ts index ca80f2a29ac..55bbaa782f6 100644 --- a/extensions/whatsapp/src/channel-outbound.ts +++ b/extensions/whatsapp/src/channel-outbound.ts @@ -1,11 +1,12 @@ import { chunkText } from "openclaw/plugin-sdk/reply-chunking"; import { createWhatsAppOutboundBase } from "./outbound-base.js"; +import { normalizeWhatsAppPayloadTextPreservingIndentation } from "./outbound-media-contract.js"; import { resolveWhatsAppOutboundTarget } from "./resolve-outbound-target.js"; import { getWhatsAppRuntime } from "./runtime.js"; import { sendMessageWhatsApp, sendPollWhatsApp } from "./send.js"; export function normalizeWhatsAppChannelPayloadText(text: string | undefined): string { - return (text ?? "").replace(/^(?:[ \t]*\r?\n)+/, ""); + return normalizeWhatsAppPayloadTextPreservingIndentation(text); } function normalizeWhatsAppChannelSendText(text: string | undefined): string { @@ -27,6 +28,7 @@ export const whatsappChannelOutbound = { resolveWhatsAppOutboundTarget({ to, allowFrom, mode }), normalizeText: normalizeWhatsAppChannelSendText, }), + sendTextOnlyErrorPayloads: true, normalizePayload: ({ payload }: { payload: { text?: string } }) => ({ ...payload, text: normalizeWhatsAppChannelPayloadText(payload.text), diff --git a/extensions/whatsapp/src/config-schema.test.ts b/extensions/whatsapp/src/config-schema.test.ts index ce1e089866e..9bedf6cd7ee 100644 --- a/extensions/whatsapp/src/config-schema.test.ts +++ b/extensions/whatsapp/src/config-schema.test.ts @@ -63,6 +63,20 @@ describe("whatsapp config schema", () => { } }); + it("accepts exposeErrorText at channel and account scope", () => { + const res = expectWhatsAppConfigValid({ + exposeErrorText: false, + accounts: { + work: { exposeErrorText: true }, + }, + }); + + if (res.success) { + expect(res.data.exposeErrorText).toBe(false); + expect(res.data.accounts?.work?.exposeErrorText).toBe(true); + } + }); + it("accepts enabled", () => { expectWhatsAppConfigValid({ enabled: true, diff --git a/extensions/whatsapp/src/config-ui-hints.ts b/extensions/whatsapp/src/config-ui-hints.ts index c32d14a1d79..a891cec3ee2 100644 --- a/extensions/whatsapp/src/config-ui-hints.ts +++ b/extensions/whatsapp/src/config-ui-hints.ts @@ -21,4 +21,8 @@ export const whatsAppChannelConfigUiHints = { label: "WhatsApp Config Writes", help: "Allow WhatsApp to write config in response to channel events/commands (default: true).", }, + exposeErrorText: { + label: "WhatsApp Error Text", + help: "Deliver user-visible agent/provider error text into WhatsApp (default: true). Disable to keep failures quiet on WhatsApp.", + }, } satisfies Record; diff --git a/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts b/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts index 1f568237f74..5e09de52122 100644 --- a/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts +++ b/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts @@ -20,6 +20,36 @@ describe("whatsappOutbound sendPayload", () => { }); }); + it("uses the same final sanitizer stack for direct text sends", async () => { + const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" })); + + await whatsappOutbound.sendText!({ + cfg: {}, + to: "5511999999999@c.us", + text: [ + "Before", + "", + ' ', + ' hidden', + " ", + "", + "
After
", + ].join("\n"), + deps: { sendWhatsApp }, + }); + + expect(sendWhatsApp).toHaveBeenCalledWith( + "5511999999999@c.us", + "Before\n\nAfter\n", + expect.objectContaining({ + verbose: false, + cfg: {}, + accountId: undefined, + gifPlayback: undefined, + }), + ); + }); + it("trims leading whitespace for direct media captions", async () => { const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" })); @@ -137,6 +167,50 @@ describe("whatsappOutbound sendPayload", () => { expect(sendWhatsApp).not.toHaveBeenCalled(); }); + it("suppresses routed error payloads when error text is hidden", async () => { + const sendWhatsApp = vi.fn(); + + const result = await whatsappOutbound.sendPayload!({ + cfg: { channels: { whatsapp: { exposeErrorText: false } } }, + to: "5511999999999@c.us", + text: "", + payload: { text: "provider exploded", isError: true }, + deps: { sendWhatsApp }, + }); + + expect(result).toEqual({ channel: "whatsapp", messageId: "" }); + expect(sendWhatsApp).not.toHaveBeenCalled(); + }); + + it("uses account-level error text visibility for routed payloads", async () => { + const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" })); + + await whatsappOutbound.sendPayload!({ + cfg: { + channels: { + whatsapp: { + exposeErrorText: false, + accounts: { + work: { exposeErrorText: true }, + }, + }, + }, + }, + accountId: "work", + to: "5511999999999@c.us", + text: "", + payload: { text: "provider exploded", isError: true }, + deps: { sendWhatsApp }, + }); + + expect(sendWhatsApp).toHaveBeenCalledTimes(1); + expect(sendWhatsApp).toHaveBeenCalledWith( + "5511999999999@c.us", + "provider exploded", + expect.any(Object), + ); + }); + it("sanitizes HTML-only text to whitespace-only payload", () => { expect( whatsappOutbound diff --git a/extensions/whatsapp/src/outbound-adapter.ts b/extensions/whatsapp/src/outbound-adapter.ts index df56baf527b..e4811db84c7 100644 --- a/extensions/whatsapp/src/outbound-adapter.ts +++ b/extensions/whatsapp/src/outbound-adapter.ts @@ -2,6 +2,7 @@ import { type ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-re import { chunkText } from "openclaw/plugin-sdk/reply-chunking"; import { shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { createWhatsAppOutboundBase } from "./outbound-base.js"; +import { normalizeWhatsAppPayloadText } from "./outbound-media-contract.js"; import { resolveWhatsAppOutboundTarget } from "./resolve-outbound-target.js"; type WhatsAppSendModule = typeof import("./send.js"); @@ -13,8 +14,8 @@ function loadWhatsAppSendModule(): Promise { return whatsAppSendModulePromise; } -function trimLeadingWhitespace(text: string | undefined): string { - return text?.trimStart() ?? ""; +function normalizeOutboundText(text: string | undefined): string { + return normalizeWhatsAppPayloadText(text); } export const whatsappOutbound: ChannelOutboundAdapter = createWhatsAppOutboundBase({ @@ -22,7 +23,7 @@ export const whatsappOutbound: ChannelOutboundAdapter = createWhatsAppOutboundBa sendMessageWhatsApp: async (to, text, options) => await ( await loadWhatsAppSendModule() - ).sendMessageWhatsApp(to, trimLeadingWhitespace(text), { + ).sendMessageWhatsApp(to, normalizeOutboundText(text), { ...options, }), sendPollWhatsApp: async (to, poll, options) => @@ -30,6 +31,6 @@ export const whatsappOutbound: ChannelOutboundAdapter = createWhatsAppOutboundBa shouldLogVerbose: () => shouldLogVerbose(), resolveTarget: ({ to, allowFrom, mode }) => resolveWhatsAppOutboundTarget({ to, allowFrom, mode }), - normalizeText: trimLeadingWhitespace, + normalizeText: normalizeOutboundText, skipEmptyText: true, }); diff --git a/extensions/whatsapp/src/outbound-base.ts b/extensions/whatsapp/src/outbound-base.ts index 3941b9063a9..edeb8e43e4c 100644 --- a/extensions/whatsapp/src/outbound-base.ts +++ b/extensions/whatsapp/src/outbound-base.ts @@ -9,9 +9,9 @@ import { type ChannelOutboundAdapter, } from "openclaw/plugin-sdk/channel-send-result"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; -import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-send-deps"; import { sendTextMediaPayload } from "openclaw/plugin-sdk/reply-payload"; +import { resolveMergedWhatsAppAccountConfig } from "./account-config.js"; import { normalizeWhatsAppOutboundPayload, normalizeWhatsAppPayloadText, @@ -144,7 +144,7 @@ export function createWhatsAppOutboundBase({ chunker, chunkerMode: "text", textChunkLimit: 4000, - sanitizeText: ({ text }) => sanitizeForPlainText(text), + sanitizeText: ({ text }) => normalizeText(text), pollMaxOptions: 12, resolveTarget, ...createAttachedChannelResultAdapter({ @@ -220,6 +220,13 @@ export function createWhatsAppOutboundBase({ return { ...outbound, sendPayload: async (ctx) => { + if ( + ctx.payload.isError === true && + resolveMergedWhatsAppAccountConfig({ cfg: ctx.cfg, accountId: ctx.accountId }) + .exposeErrorText === false + ) { + return { channel: "whatsapp", messageId: "" }; + } const payload = normalizeWhatsAppOutboundPayload(ctx.payload, { normalizeText }); if (!payload.text && !(payload.mediaUrl || payload.mediaUrls?.length)) { if (ctx.payload.interactive || ctx.payload.presentation || ctx.payload.channelData) { diff --git a/extensions/whatsapp/src/outbound-media-contract.ts b/extensions/whatsapp/src/outbound-media-contract.ts index e341e9d924a..8f7cb9de064 100644 --- a/extensions/whatsapp/src/outbound-media-contract.ts +++ b/extensions/whatsapp/src/outbound-media-contract.ts @@ -1,9 +1,15 @@ import fs from "node:fs/promises"; import path from "node:path"; import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime"; +import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { formatError } from "./session-errors.js"; -import { sleep } from "./text-runtime.js"; +import { + sanitizeAssistantVisibleText, + sanitizeAssistantVisibleTextWithProfile, + stripToolCallXmlTags, + sleep, +} from "./text-runtime.js"; type WhatsAppOutboundPayloadLike = { text?: string; @@ -18,6 +24,22 @@ type WhatsAppLoadedMediaLike = { fileName?: string; }; +export type NormalizedWhatsAppOutboundPayload = Omit< + T, + "text" | "mediaUrl" | "mediaUrls" +> & { + text: string; + mediaUrl?: string; + mediaUrls?: string[]; +}; + +export type DeliverableWhatsAppOutboundPayload = Omit< + NormalizedWhatsAppOutboundPayload, + "text" +> & { + text?: string; +}; + export type CanonicalWhatsAppLoadedMedia = { buffer: Buffer; kind: "image" | "audio" | "video" | "document"; @@ -30,14 +52,31 @@ const WHATSAPP_VOICE_SAMPLE_RATE_HZ = 48_000; const WHATSAPP_VOICE_BITRATE = "64k"; const WHATSAPP_VOICE_MIMETYPE = "audio/ogg; codecs=opus"; +function stripWhatsAppPluralToolXml(text: string): string { + return stripToolCallXmlTags(text, { stripFunctionCallsXmlPayloads: true }); +} + +function finalizeWhatsAppVisibleText(text: string): string { + return sanitizeForPlainText(stripWhatsAppPluralToolXml(text)); +} + export function normalizeWhatsAppPayloadText(text: string | undefined): string { - return text?.trimStart() ?? ""; + return finalizeWhatsAppVisibleText(sanitizeAssistantVisibleText(text ?? "")).trimStart(); +} + +function stripLeadingBlankLines(text: string): string { + return text.replace(/^(?:[ \t]*\r?\n)+/, ""); } export function normalizeWhatsAppPayloadTextPreservingIndentation( text: string | undefined, ): string { - return (text ?? "").replace(/^(?:[ \t]*\r?\n)+/, ""); + const sanitized = sanitizeAssistantVisibleTextWithProfile( + stripLeadingBlankLines(text ?? ""), + "history", + ); + const normalized = stripLeadingBlankLines(finalizeWhatsAppVisibleText(sanitized)); + return normalized.trim() ? normalized : ""; } export function resolveWhatsAppOutboundMediaUrls( @@ -59,11 +98,7 @@ export function normalizeWhatsAppOutboundPayload string; }, -): Omit & { - text: string; - mediaUrl?: string; - mediaUrls?: string[]; -} { +): NormalizedWhatsAppOutboundPayload { const mediaUrls = resolveWhatsAppOutboundMediaUrls(payload); const normalizeText = options?.normalizeText ?? normalizeWhatsAppPayloadText; return { diff --git a/src/channels/plugins/outbound.types.ts b/src/channels/plugins/outbound.types.ts index 75c381848b1..9237f9a1cc4 100644 --- a/src/channels/plugins/outbound.types.ts +++ b/src/channels/plugins/outbound.types.ts @@ -84,6 +84,7 @@ export type ChannelOutboundAdapter = { supportsPollDurationSeconds?: boolean; supportsAnonymousPolls?: boolean; normalizePayload?: (params: { payload: ReplyPayload }) => ReplyPayload | null; + sendTextOnlyErrorPayloads?: boolean; shouldSkipPlainTextSanitization?: (params: { payload: ReplyPayload }) => boolean; resolveEffectiveTextChunkLimit?: (params: { cfg: OpenClawConfig; diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index af0dc873ba1..14af30b4518 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -15966,6 +15966,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ }, ], }, + exposeErrorText: { + type: "boolean", + }, heartbeat: { type: "object", properties: { @@ -16254,6 +16257,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ }, ], }, + exposeErrorText: { + type: "boolean", + }, heartbeat: { type: "object", properties: { @@ -16342,6 +16348,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ label: "WhatsApp Config Writes", help: "Allow WhatsApp to write config in response to channel events/commands (default: true).", }, + exposeErrorText: { + label: "WhatsApp Error Text", + help: "Deliver user-visible agent/provider error text into WhatsApp (default: true). Disable to keep failures quiet on WhatsApp.", + }, }, unsupportedSecretRefSurfacePatterns: [ "channels.whatsapp.accounts.*.creds.json", diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index 0d52fee9dea..8e7d7fec5aa 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -105,6 +105,8 @@ type WhatsAppSharedConfig = { debounceMs?: number; /** Reply threading mode for auto-replies (off|first|all|batched). */ replyToMode?: ReplyToMode; + /** Whether WhatsApp should deliver user-visible error text. Default: true. */ + exposeErrorText?: boolean; /** Heartbeat visibility settings. */ heartbeat?: ChannelHeartbeatVisibilityConfig; /** Channel health monitor overrides for this channel/account. */ diff --git a/src/config/zod-schema.providers-whatsapp.test.ts b/src/config/zod-schema.providers-whatsapp.test.ts index d45b9a93378..0b3d693d1b8 100644 --- a/src/config/zod-schema.providers-whatsapp.test.ts +++ b/src/config/zod-schema.providers-whatsapp.test.ts @@ -72,6 +72,21 @@ describe("WhatsApp prompt config Zod validation", () => { } }); + it("validates exposeErrorText at root and account scope", () => { + const result = WhatsAppConfigSchema.safeParse({ + exposeErrorText: false, + accounts: { + work: { exposeErrorText: true }, + }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.exposeErrorText).toBe(false); + expect(result.data.accounts?.work?.exposeErrorText).toBe(true); + } + }); + it("validates WhatsAppAccountSchema directly", () => { const accountConfig = { name: "Personal Account", diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index f1b1fb0516b..a859ee0c9d8 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -83,6 +83,7 @@ function buildWhatsAppCommonShape(params: { useDefaults: boolean }) { ? z.number().int().nonnegative().optional().default(0) : z.number().int().nonnegative().optional(), replyToMode: ReplyToModeSchema.optional(), + exposeErrorText: z.boolean().optional(), heartbeat: ChannelHeartbeatVisibilitySchema, healthMonitor: ChannelHealthMonitorSchema, }; diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 0f7c3af55d8..60abf0a418e 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -1797,6 +1797,113 @@ describe("deliverOutboundPayloads", () => { expect(hookMocks.runner.runMessageSent).not.toHaveBeenCalled(); }); + it("keeps text-only error payloads on the normal text path by default", async () => { + const sendPayload = vi.fn(); + const sendText = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-1" }); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "matrix", + source: "test", + plugin: createOutboundTestPlugin({ + id: "matrix", + outbound: { deliveryMode: "direct", sendPayload, sendText }, + }), + }, + ]), + ); + + const results = await deliverOutboundPayloads({ + cfg: {}, + channel: "matrix", + to: "!room:1", + payloads: [{ text: "provider exploded", isError: true }], + }); + + expect(results).toEqual([{ channel: "matrix", messageId: "mx-1" }]); + expect(sendText).toHaveBeenCalledWith(expect.objectContaining({ text: "provider exploded" })); + expect(sendPayload).not.toHaveBeenCalled(); + }); + + it("routes text-only error payloads through sendPayload when the adapter opts in", async () => { + const sendPayload = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-1" }); + const sendText = vi.fn(); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "matrix", + source: "test", + plugin: createOutboundTestPlugin({ + id: "matrix", + outbound: { + deliveryMode: "direct", + sendPayload, + sendText, + sendTextOnlyErrorPayloads: true, + }, + }), + }, + ]), + ); + + const results = await deliverOutboundPayloads({ + cfg: {}, + channel: "matrix", + to: "!room:1", + payloads: [{ text: "provider exploded", isError: true }], + }); + + expect(results).toEqual([{ channel: "matrix", messageId: "mx-1" }]); + expect(sendPayload).toHaveBeenCalledWith( + expect.objectContaining({ + text: "provider exploded", + payload: expect.objectContaining({ text: "provider exploded", isError: true }), + }), + ); + expect(sendText).not.toHaveBeenCalled(); + }); + + it("does not count no-op sendPayload results as delivered", async () => { + hookMocks.runner.hasHooks.mockReturnValue(true); + const sendPayload = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "" }); + const sendText = vi.fn(); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "matrix", + source: "test", + plugin: createOutboundTestPlugin({ + id: "matrix", + outbound: { + deliveryMode: "direct", + sendPayload, + sendText, + sendTextOnlyErrorPayloads: true, + }, + }), + }, + ]), + ); + + const results = await deliverOutboundPayloads({ + cfg: {}, + channel: "matrix", + to: "!room:1", + payloads: [{ text: "provider exploded", isError: true }], + mirror: { + sessionKey: "agent:main:main", + agentId: "main", + text: "provider exploded", + }, + }); + + expect(results).toEqual([]); + expect(sendPayload).toHaveBeenCalledTimes(1); + expect(sendText).not.toHaveBeenCalled(); + expect(hookMocks.runner.runMessageSent).not.toHaveBeenCalled(); + expect(mocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled(); + }); + it("emits message_sent success for sendPayload deliveries", async () => { hookMocks.runner.hasHooks.mockReturnValue(true); const sendPayload = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-1" }); diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 8cab39103e9..ab2f659a845 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -91,6 +91,7 @@ type ChannelHandler = { supportsMedia: boolean; sanitizeText?: (payload: ReplyPayload) => string; normalizePayload?: (payload: ReplyPayload) => ReplyPayload | null; + sendTextOnlyErrorPayloads?: boolean; renderPresentation?: (payload: ReplyPayload) => Promise; pinDeliveredMessage?: (params: { target: ChannelOutboundTargetRef; @@ -229,6 +230,7 @@ function createPluginHandler( normalizePayload: outbound.normalizePayload ? (payload) => outbound.normalizePayload!({ payload }) : undefined, + sendTextOnlyErrorPayloads: outbound.sendTextOnlyErrorPayloads === true, renderPresentation: outbound.renderPresentation ? async (payload) => { const presentation = normalizeMessagePresentation(payload.presentation); @@ -544,6 +546,18 @@ function buildPayloadSummary(payload: ReplyPayload): NormalizedOutboundPayload { return summarizeOutboundPayloadForTransport(payload); } +function hasDeliveryResultIdentity(result: OutboundDeliveryResult): boolean { + return Boolean( + result.messageId || + result.chatId || + result.channelId || + result.roomId || + result.conversationId || + result.toJid || + result.pollId, + ); +} + function normalizeDeliveryPin(payload: ReplyPayload): ReplyPayloadDeliveryPin | undefined { const pin = payload.delivery?.pin; if (pin === true) { @@ -1107,17 +1121,22 @@ async function deliverOutboundPayloadsCore( const deliveryTarget = handler.buildTargetRef({ threadId: sendOverrides.threadId }); if ( handler.sendPayload && - (hasReplyPayloadContent({ - presentation: effectivePayload.presentation, - interactive: effectivePayload.interactive, - channelData: effectivePayload.channelData, - }) || + ((effectivePayload.isError === true && handler.sendTextOnlyErrorPayloads === true) || + hasReplyPayloadContent({ + presentation: effectivePayload.presentation, + interactive: effectivePayload.interactive, + channelData: effectivePayload.channelData, + }) || effectivePayload.audioAsVoice === true) ) { const delivery = await handler.sendPayload( effectivePayload, applySendReplyToConsumption(sendOverrides), ); + if (!hasDeliveryResultIdentity(delivery)) { + completeDeliveryDiagnostics(0); + continue; + } results.push(delivery); await maybePinDeliveredMessage({ handler, diff --git a/src/shared/text/assistant-visible-text.test.ts b/src/shared/text/assistant-visible-text.test.ts index c30deeb9688..7dfb1392cd2 100644 --- a/src/shared/text/assistant-visible-text.test.ts +++ b/src/shared/text/assistant-visible-text.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { sanitizeAssistantVisibleText, sanitizeAssistantVisibleTextWithProfile, + stripToolCallXmlTags, stripAssistantInternalScaffolding, } from "./assistant-visible-text.js"; import { stripModelSpecialTokens } from "./model-special-tokens.js"; @@ -504,6 +505,17 @@ describe("stripAssistantInternalScaffolding", () => { }); }); +describe("stripToolCallXmlTags", () => { + it("strips plural function/tool wrapper XML only when the opt-in flag is enabled", () => { + const input = + 'prefix secret suffix'; + expect(stripToolCallXmlTags(input)).toBe(input); + expect(stripToolCallXmlTags(input, { stripFunctionCallsXmlPayloads: true })).toBe( + "prefix suffix", + ); + }); +}); + describe("sanitizeAssistantVisibleText", () => { it("strips minimax, tool XML, downgraded tool markers, and think tags in one pass", () => { const input = [ diff --git a/src/shared/text/assistant-visible-text.ts b/src/shared/text/assistant-visible-text.ts index 26eb77f9d68..7d6822cb585 100644 --- a/src/shared/text/assistant-visible-text.ts +++ b/src/shared/text/assistant-visible-text.ts @@ -29,7 +29,8 @@ const TOOL_CALL_TAG_NAMES = new Set([ const TOOL_CALL_JSON_PAYLOAD_START_RE = /^(?:\s+[A-Za-z_:][-A-Za-z0-9_:.]*\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))*\s*(?:\r?\n\s*)?[[{]/; const TOOL_CALL_XML_PAYLOAD_START_RE = - /^\s*(?:\r?\n\s*)?<(?:function|invoke|parameters?|arguments?)\b/i; + /^\s*(?:\r?\n\s*)?<(?:function_call|tool_call|function|invoke|parameters?|arguments?)\b/i; +const NESTED_JSON_TOOL_CALL_PAYLOAD_START_RE = /^\s*(?:\r?\n\s*)?<(?:function_call|tool_call)\b/i; type ToolCallPayloadKind = "json" | "xml" | null; @@ -124,6 +125,27 @@ function detectToolCallPayloadKind(text: string, start: number): ToolCallPayload return null; } +function startsWithNestedJsonToolCallPayload(text: string, start: number): boolean { + if (!NESTED_JSON_TOOL_CALL_PAYLOAD_START_RE.test(text.slice(start))) { + return false; + } + let cursor = start; + while (cursor < text.length && /\s/.test(text[cursor])) { + cursor += 1; + } + const nestedTag = parseToolCallTagAt(text, cursor); + if ( + !nestedTag || + nestedTag.isClose || + nestedTag.isSelfClosing || + nestedTag.isTruncated || + (nestedTag.tagName !== "function_call" && nestedTag.tagName !== "tool_call") + ) { + return false; + } + return TOOL_CALL_JSON_PAYLOAD_START_RE.test(text.slice(nestedTag.end)); +} + function isLikelyStandaloneFunctionToolCall( text: string, tagStart: number, @@ -197,7 +219,10 @@ function parseToolCallTagAt(text: string, start: number): ParsedToolCallTag | nu }; } -export function stripToolCallXmlTags(text: string): string { +export function stripToolCallXmlTags( + text: string, + options: { stripFunctionCallsXmlPayloads?: boolean } = {}, +): string { if (!text || !TOOL_CALL_QUICK_RE.test(text)) { return text; } @@ -250,18 +275,24 @@ export function stripToolCallXmlTags(text: string): string { continue; } const payloadStart = tag.isTruncated ? tag.contentStart : tag.end; - const payloadKind = - tag.tagName === "tool_call" || tag.tagName === "function" - ? detectToolCallPayloadKind(text, payloadStart) - : TOOL_CALL_JSON_PAYLOAD_START_RE.test(text.slice(payloadStart)) - ? "json" - : null; + const shouldDetectXmlPayload = + tag.tagName === "tool_call" || + tag.tagName === "function" || + (options.stripFunctionCallsXmlPayloads === true && + (tag.tagName === "function_calls" || tag.tagName === "tool_calls")); + const payloadKind = shouldDetectXmlPayload + ? detectToolCallPayloadKind(text, payloadStart) + : TOOL_CALL_JSON_PAYLOAD_START_RE.test(text.slice(payloadStart)) + ? "json" + : null; const shouldStripStandaloneFunction = tag.tagName !== "function" || isLikelyStandaloneFunctionToolCall(text, idx, tag); if (!tag.isClose && payloadKind && shouldStripStandaloneFunction) { inToolCallBlock = true; toolCallBlockContentStart = tag.end; - toolCallBlockNeedsQuoteBalance = payloadKind === "json"; + toolCallBlockNeedsQuoteBalance = + payloadKind === "json" || + (payloadKind === "xml" && startsWithNestedJsonToolCallPayload(text, payloadStart)); toolCallBlockStart = idx; toolCallBlockTagName = tag.tagName; if (tag.isTruncated) { @@ -526,6 +557,7 @@ type AssistantVisibleTextPipelineOptions = { finalTrim: ReasoningTagTrim; preserveDowngradedToolText?: boolean; preserveMinimaxToolXml?: boolean; + stripFunctionCallsXmlPayloads?: boolean; reasoningMode: ReasoningTagMode; reasoningTrim: ReasoningTagTrim; stageOrder: "reasoning-first" | "reasoning-last"; @@ -586,7 +618,9 @@ function applyAssistantVisibleTextStagePipeline( } cleaned = stripModelSpecialTokens(cleaned); cleaned = stripRelevantMemoriesTags(cleaned); - cleaned = stripToolCallXmlTags(cleaned); + cleaned = stripToolCallXmlTags(cleaned, { + stripFunctionCallsXmlPayloads: options.stripFunctionCallsXmlPayloads, + }); cleaned = stripPlainTextToolCallBlocks(cleaned); if (!options.preserveDowngradedToolText) { cleaned = stripDowngradedToolCallText(cleaned);