fix(discord): suppress internal agent failure traces

This commit is contained in:
FullerStackDev
2026-06-03 08:03:57 +05:30
committed by Ayaan Zaidi
parent a9f014e9df
commit a8bf14da84
8 changed files with 170 additions and 88 deletions
@@ -2504,7 +2504,7 @@ describe("processDiscordMessage draft streaming", () => {
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
it("delivers tool warning finals when no recovered reply is available", async () => {
it("suppresses pure tool warning finals when no recovered reply is available", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.dispatcher.sendFinalReply(createNonTerminalToolWarningPayload());
@@ -2519,18 +2519,10 @@ describe("processDiscordMessage draft streaming", () => {
expect(editMessageDiscord).not.toHaveBeenCalled();
expect(draftStream.clear).toHaveBeenCalledTimes(1);
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
replies: [
{
text: "⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
isError: true,
},
],
});
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
it("delivers tool warning finals when the recovered reply fails to send", async () => {
it("suppresses tool warning finals when the recovered reply fails to send", async () => {
deliverDiscordReply.mockRejectedValueOnce(new Error("send failed"));
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.dispatcher.sendFinalReply({ text: "delivery failed" });
@@ -2549,21 +2541,13 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(deliverDiscordReply).toHaveBeenCalledTimes(2);
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
replies: [{ text: "delivery failed" }],
});
expect(deliverDiscordReply.mock.calls[1]?.[0]).toMatchObject({
replies: [
{
text: "⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
isError: true,
},
],
});
});
it("keeps mutating tool warning finals after successful-looking replies", async () => {
it("suppresses mutating tool warning finals after successful-looking replies", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.dispatcher.sendFinalReply({ text: "Done." });
@@ -2582,15 +2566,7 @@ describe("processDiscordMessage draft streaming", () => {
expectPreviewEditContent("Done.");
expect(draftStream.clear).not.toHaveBeenCalled();
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
replies: [
{
text: "⚠️ 🛠️ `write file (agent)` failed",
isError: true,
},
],
});
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
it("suppresses reasoning payload delivery to Discord", async () => {
@@ -66,6 +66,7 @@ import { createDiscordDraftPreviewController } from "./message-handler.draft-pre
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
import { resolveForwardedMediaList, resolveMediaList } from "./message-utils.js";
import { deliverDiscordReply } from "./reply-delivery.js";
import { sanitizeDiscordFrontChannelReplyPayloads } from "./reply-safety.js";
import { createDiscordReplyTypingFeedback } from "./reply-typing-feedback.js";
import {
DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS,
@@ -111,7 +112,10 @@ function isFallbackOnlyToolWarningFinal(payload: ReplyPayload): boolean {
return !resolveSendableOutboundReplyParts(payload).hasMedia;
}
type DiscordReplySkipReason = "aborted before delivery" | "reasoning payload";
type DiscordReplySkipReason =
| "aborted before delivery"
| "reasoning payload"
| "internal-only payload";
export function formatDiscordReplySkip(params: {
kind: "tool" | "block" | "final";
@@ -669,10 +673,24 @@ async function processDiscordMessageInner(
})
: payload.text;
const effectivePayload = finalText !== payload.text ? { ...payload, text: finalText } : payload;
const [deliverablePayload] = sanitizeDiscordFrontChannelReplyPayloads([effectivePayload], {
kind: info.kind,
});
if (!deliverablePayload) {
logVerbose(
formatDiscordReplySkip({
kind: info.kind,
reason: "internal-only payload",
target: deliverTarget,
sessionKey: ctxPayload.SessionKey,
}),
);
return { visibleReplySent: false };
}
const draftStream = draftPreview.draftStream;
if (draftStream && draftPreview.isProgressMode && info.kind === "block") {
const reply = resolveSendableOutboundReplyParts(effectivePayload);
if (!reply.hasMedia && !payload.isError) {
const reply = resolveSendableOutboundReplyParts(deliverablePayload);
if (!reply.hasMedia && !deliverablePayload.isError) {
return { visibleReplySent: false };
}
}
@@ -680,22 +698,22 @@ async function processDiscordMessageInner(
draftStream &&
isFinal &&
(!draftPreview.isProgressMode || draftPreview.hasProgressDraftStarted) &&
!payload.isError;
!deliverablePayload.isError;
if (shouldFinalizeDraftPreview) {
const reply = resolveSendableOutboundReplyParts(effectivePayload);
const reply = resolveSendableOutboundReplyParts(deliverablePayload);
const hasMedia = reply.hasMedia;
const ttsSupplement = getReplyPayloadTtsSupplement(effectivePayload);
const previewSourceText = finalText ?? ttsSupplement?.spokenText;
const ttsSupplement = getReplyPayloadTtsSupplement(deliverablePayload);
const previewSourceText = deliverablePayload.text ?? ttsSupplement?.spokenText;
const previewFinalText = draftPreview.resolvePreviewFinalText(previewSourceText);
const previewReplyToId = replyReference.peek();
const hasExplicitReplyDirective =
Boolean(effectivePayload.replyToTag || effectivePayload.replyToCurrent) ||
Boolean(deliverablePayload.replyToTag || deliverablePayload.replyToCurrent) ||
(typeof previewSourceText === "string" &&
/\[\[\s*reply_to(?:_current|\s*:)/i.test(previewSourceText));
const result = await deliverWithFinalizableLivePreviewAdapter({
kind: info.kind,
payload: effectivePayload,
payload: deliverablePayload,
adapter: defineFinalizableLivePreviewAdapter({
draft: {
flush: () => draftPreview.flush(),
@@ -710,7 +728,7 @@ async function processDiscordMessageInner(
(hasMedia && !ttsSupplement) ||
typeof previewFinalText !== "string" ||
hasExplicitReplyDirective ||
payload.isError
deliverablePayload.isError
) {
return undefined;
}
@@ -747,7 +765,7 @@ async function processDiscordMessageInner(
replyReference.markSent();
},
buildSupplementalPayload: () =>
ttsSupplement ? buildTtsSupplementMediaPayload(effectivePayload) : undefined,
ttsSupplement ? buildTtsSupplementMediaPayload(deliverablePayload) : undefined,
deliverSupplemental: async (supplementalPayload) => {
if (isProcessAborted(abortSignal)) {
return false;
@@ -794,9 +812,9 @@ async function processDiscordMessageInner(
const fallbackPayload =
ttsSupplement &&
ttsSupplement.visibleTextAlreadyDelivered !== true &&
!effectivePayload.text?.trim()
? { ...effectivePayload, text: ttsSupplement.spokenText }
: effectivePayload;
!deliverablePayload.text?.trim()
? { ...deliverablePayload, text: ttsSupplement.spokenText }
: deliverablePayload;
const replyToId = replyReference.use();
notifyFinalReplyStart();
await deliverDiscordReply({
@@ -849,7 +867,7 @@ async function processDiscordMessageInner(
}
await deliverDiscordReply({
cfg,
replies: [effectivePayload],
replies: [deliverablePayload],
target: deliverTarget,
token,
accountId,
@@ -867,7 +885,7 @@ async function processDiscordMessageInner(
kind: info.kind,
});
replyReference.markSent();
if (isFinal && payload.isError !== true) {
if (isFinal && deliverablePayload.isError !== true) {
markUserFacingFinalDelivered();
}
return { visibleReplySent: true };
@@ -183,6 +183,7 @@ describe("deliverDiscordReply", () => {
text: [
"📊 Session Status: current",
"🛠️ run git status",
"⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
"🛠️ `gh pr view`",
"🛠️ `docker compose up`",
"🛠️ elevated · `cd /tmp && pnpm test`",
@@ -204,6 +205,26 @@ describe("deliverDiscordReply", () => {
expect(firstDeliverParams().payloads).toEqual([{ text: "Visible reply." }]);
});
it("drops pure internal tool failure warnings at the final Discord send boundary", async () => {
await deliverDiscordReply({
replies: [
{
text: "⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
isError: true,
},
],
target: "channel:101",
token: "token",
accountId: "default",
runtime,
cfg,
textLimit: 2000,
kind: "final",
});
expect(sendDurableMessageBatchMock).not.toHaveBeenCalled();
});
it("strips serialized tool call blocks at the final Discord send boundary", async () => {
await deliverDiscordReply({
replies: [
@@ -243,7 +264,7 @@ describe("deliverDiscordReply", () => {
await deliverDiscordReply({
replies: [
{
text: "commentary: calling tool\nanalysis: inspect private state",
text: "tool_call: calling tool\ntool_result: inspect private state",
mediaUrl: "https://example.com/result.png",
},
],
@@ -283,7 +304,7 @@ describe("deliverDiscordReply", () => {
await deliverDiscordReply({
replies: [
{
text: "analysis: internal only",
text: "tool_result: internal only",
channelData,
},
],
@@ -313,7 +334,7 @@ describe("deliverDiscordReply", () => {
await deliverDiscordReply({
replies: [
{
text: "commentary: hidden",
text: "function_call: hidden",
presentation,
},
],
+2 -34
View File
@@ -3,13 +3,6 @@ import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-pay
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
import { stripPlainTextToolCallBlocks } from "openclaw/plugin-sdk/tool-payload";
const DISCORD_INTERNAL_TRACE_LINE_RE =
/^(?:>\s*)?(?:📊|🛠️|📖|📝|🔍|🔎|⚙️)\s*(?:Session Status|Exec|Read|Edit|Write|Patch|Search|Open|Click|Find|Screenshot|Update Plan|Tool Call|Tool Result|Function Call|Shell|Command)\s*:/i;
const DISCORD_INTERNAL_COMPACT_COMMAND_TRACE_LINE_RE =
/^(?:>\s*)?🛠️\s*(?:(?:(?:elevated|pty)\b\s*(?:·|,)\s*)+)?(?:`{1,2}\s*\S|(?:run|check|fetch|pull|push|view|show|list|switch|create|merge|rebase|stage|restore|reset|stash|search|find|print|copy|move|remove|install|start|cd|git|pnpm|npm|yarn|bun|node|python|python3|bash|sh)\b)/i;
const DISCORD_INTERNAL_CHANNEL_LINE_RE =
/^(?:>\s*)?(?:analysis|commentary|tool[-_ ]?call|tool[-_ ]?result|function[-_ ]?call|thinking|reasoning)\s*[:=]/i;
function hasNonEmptyRecord(value: unknown): value is Record<string, unknown> {
return Boolean(
value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0,
@@ -36,30 +29,6 @@ function hasNonTextReplyPayloadContent(payload: ReplyPayload): boolean {
);
}
function stripDiscordInternalTraceLines(text: string): string {
let inFence = false;
const kept: string[] = [];
for (const line of text.split(/\r?\n/)) {
if (/^\s*```/.test(line)) {
inFence = !inFence;
kept.push(line);
continue;
}
if (!inFence) {
const trimmed = line.trim();
if (
DISCORD_INTERNAL_TRACE_LINE_RE.test(trimmed) ||
DISCORD_INTERNAL_COMPACT_COMMAND_TRACE_LINE_RE.test(trimmed) ||
DISCORD_INTERNAL_CHANNEL_LINE_RE.test(trimmed)
) {
continue;
}
}
kept.push(line);
}
return kept.join("\n");
}
function collapseExcessBlankLines(text: string): string {
return text.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n");
}
@@ -68,8 +37,7 @@ export function sanitizeDiscordFrontChannelText(text: string): string {
const withoutToolCallBlocks = stripPlainTextToolCallBlocks(text);
const withoutAssistantScaffolding = sanitizeAssistantVisibleText(withoutToolCallBlocks);
const withoutResidualToolCallBlocks = stripPlainTextToolCallBlocks(withoutAssistantScaffolding);
const withoutTraceLines = stripDiscordInternalTraceLines(withoutResidualToolCallBlocks);
return collapseExcessBlankLines(withoutTraceLines).trim();
return collapseExcessBlankLines(withoutResidualToolCallBlocks).trim();
}
export function sanitizeDiscordFrontChannelReplyPayloads(
@@ -82,7 +50,7 @@ export function sanitizeDiscordFrontChannelReplyPayloads(
const safeText =
typeof payload.text === "string"
? preserveVerboseToolProgress
? collapseExcessBlankLines(sanitizeAssistantVisibleText(payload.text)).trim()
? collapseExcessBlankLines(payload.text).trim()
: sanitizeDiscordFrontChannelText(payload.text)
: payload.text;
const nextPayload =
@@ -304,6 +304,37 @@ describe("sanitizeUserFacingText", () => {
expect(sanitizeUserFacingText(input)).toBe("Before\n\nAfter");
});
it("strips internal tool trace warning lines from error-context delivery text", () => {
const input = [
"Visible intro.",
"⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
"🛠️ run git status",
"📖 Read: lines 1-40 from secret.md",
"Visible outro.",
].join("\n");
expect(sanitizeUserFacingText(input, { errorContext: true })).toBe(
"Visible intro.\nVisible outro.",
);
});
it("preserves explicit tool progress outside error-context delivery text", () => {
const input = "🛠️ Exec: echo queued-progress";
expect(sanitizeUserFacingText(input)).toBe(input);
});
it("preserves internal trace examples inside fenced code", () => {
const input = [
"Example:",
"```",
"⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
"```",
].join("\n");
expect(sanitizeUserFacingText(input)).toBe(input);
});
it("strips plural XML function-call wrappers before user-facing delivery", () => {
const input = [
"Before",
@@ -1,3 +1,7 @@
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "../../../packages/normalization-core/src/string-coerce.js";
import { stripPlainTextToolCallBlocks } from "../../../packages/tool-call-repair/src/index.js";
import { stripInboundMetadata } from "../../auto-reply/reply/strip-inbound-meta.js";
import {
@@ -11,10 +15,7 @@ import {
} from "../../shared/assistant-error-format.js";
import { coerceChatContentText } from "../../shared/chat-content.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "../../../packages/normalization-core/src/string-coerce.js";
import {
stripAssistantInternalTraceLines,
stripLegacyBracketToolCallBlocks,
stripMinimaxToolCallXml,
stripToolCallXmlTags,
@@ -414,8 +415,11 @@ export function sanitizeUserFacingText(text: unknown, opts?: { errorContext?: bo
// It is internal scaffolding, so drop standalone placeholder lines before delivery
// while preserving ordinary inline mentions a user may be discussing.
const withoutPlaceholder = stripToolCallsOmittedPlaceholderLines(withoutToolCallXml);
const withoutInternalTraceLines = errorContext
? stripAssistantInternalTraceLines(withoutPlaceholder)
: withoutPlaceholder;
const withoutToolCallBlocks = stripPlainTextToolCallBlocks(
stripLegacyBracketToolCallBlocks(withoutPlaceholder),
stripLegacyBracketToolCallBlocks(withoutInternalTraceLines),
);
const trimmed = withoutToolCallBlocks.trim();
if (!trimmed) {
@@ -830,6 +830,34 @@ describe("sanitizeAssistantVisibleText", () => {
expect(sanitizeAssistantVisibleText(input)).toBe("Visible answer");
});
it("strips internal tool trace warning lines on the delivery path", () => {
const input = [
"Visible intro.",
"⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
"🛠️ run git status",
"Visible outro.",
].join("\n");
expect(sanitizeAssistantVisibleText(input)).toBe("Visible intro.\nVisible outro.");
});
it("preserves internal tool trace examples inside fenced code", () => {
const input = [
"Example:",
"```",
"⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
"```",
].join("\n");
expect(sanitizeAssistantVisibleText(input)).toBe(input);
});
it("preserves ordinary analysis headings", () => {
const input = ["Analysis:", "This is user-visible reasoning about the result."].join("\n");
expect(sanitizeAssistantVisibleText(input)).toBe(input);
});
it("drops malformed reasoning before orphan close tags when final text follows", () => {
expect(sanitizeAssistantVisibleText("private chain of thought </think> Visible answer")).toBe(
"Visible answer",
+36
View File
@@ -11,6 +11,14 @@ 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_BLOCK_QUICK_RE = /\[\s*\/?\s*TOOL_(?:CALL|RESULT)\s*\]/i;
const INTERNAL_TRACE_LINE_QUICK_RE =
/(?:📊|🛠️|📖|📝|🔍|🔎|⚙️|tool[-_ ]?call|tool[-_ ]?result|function[-_ ]?call)/i;
const INTERNAL_TRACE_LINE_RE =
/^(?:>\s*)?(?:⚠️\s*)?(?:📊|🛠️|📖|📝|🔍|🔎|⚙️)\s*(?:Session Status|Exec|Read|Edit|Write|Patch|Search|Open|Click|Find|Screenshot|Update Plan|Tool Call|Tool Result|Function Call|Shell|Command)\s*:/i;
const INTERNAL_COMPACT_COMMAND_TRACE_LINE_RE =
/^(?:>\s*)?(?:⚠️\s*)?🛠️\s*(?:(?:(?:elevated|pty)\b\s*(?:·|,)\s*)+)?(?:`{1,2}\s*\S|(?:run|check|fetch|pull|push|view|show|list|switch|create|merge|rebase|stage|restore|reset|stash|search|find|print|copy|move|remove|install|start|cd|git|pnpm|npm|yarn|bun|node|python|python3|bash|sh)\b)/i;
const INTERNAL_CHANNEL_TRACE_LINE_RE =
/^(?:>\s*)?(?:tool[-_ ]?call|tool[-_ ]?result|function[-_ ]?call)\s*[:=]/i;
/**
* Strip XML-style tool call tags that models sometimes emit as plain text.
@@ -760,6 +768,33 @@ function stripRelevantMemoriesTags(text: string): string {
return result;
}
export function stripAssistantInternalTraceLines(text: string): string {
if (!text || !INTERNAL_TRACE_LINE_QUICK_RE.test(text)) {
return text;
}
const codeRegions = findCodeRegions(text);
let result = "";
let lineStart = 0;
while (lineStart < text.length) {
const newlineIndex = text.indexOf("\n", lineStart);
const lineEnd = newlineIndex === -1 ? text.length : newlineIndex + 1;
const rawLine = text.slice(lineStart, lineEnd);
const line = rawLine.endsWith("\n") ? rawLine.slice(0, -1).replace(/\r$/, "") : rawLine;
const trimmed = line.trim();
const shouldStrip =
!isInsideCode(lineStart, codeRegions) &&
(INTERNAL_TRACE_LINE_RE.test(trimmed) ||
INTERNAL_COMPACT_COMMAND_TRACE_LINE_RE.test(trimmed) ||
INTERNAL_CHANNEL_TRACE_LINE_RE.test(trimmed));
if (!shouldStrip) {
result += rawLine;
}
lineStart = lineEnd;
}
return result;
}
export type AssistantVisibleTextSanitizerProfile = "delivery" | "history" | "internal-scaffolding";
type AssistantVisibleTextPipelineOptions = {
@@ -833,6 +868,7 @@ function applyAssistantVisibleTextStagePipeline(
stripFunctionCallsXmlPayloads: options.stripFunctionCallsXmlPayloads,
stripFunctionResponseAfterPluralToolCalls: options.stripFunctionResponseAfterPluralToolCalls,
});
cleaned = stripAssistantInternalTraceLines(cleaned);
cleaned = stripLegacyBracketToolCallBlocks(cleaned);
cleaned = stripPlainTextToolCallBlocks(cleaned);
if (!options.preserveDowngradedToolText) {