From df6ec2822fd85f1336c7756725dc006bd37b1daf Mon Sep 17 00:00:00 2001 From: Sarah Fortune Date: Tue, 26 May 2026 19:30:43 -0700 Subject: [PATCH] Suppress transient runner failures in channels (#87069) --- .../reply/agent-runner-execution.test.ts | 89 +++++++++++++++++++ .../reply/agent-runner-execution.ts | 18 +++- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index 7e18e9efa7cf..d1d5499c7fee 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -4854,6 +4854,95 @@ describe("runAgentTurnWithFallback", () => { }, ); + it.each(["group", "channel"] as const)( + "keeps classified non-transient failures visible in Discord %s chats", + async (chatType) => { + state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + new Error('No API key found for provider "openai"'), + ); + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const result = await runAgentTurnWithFallback( + createMinimalRunAgentTurnParams({ + sessionCtx: { + Provider: "discord", + Surface: "discord", + ChatType: chatType, + GroupSubject: "agent group", + GroupChannel: "#general", + MessageSid: "msg", + } as unknown as TemplateContext, + }), + ); + + expect(result.kind).toBe("final"); + if (result.kind === "final") { + expect(result.payload.text).not.toBe(SILENT_REPLY_TOKEN); + expect(result.payload.text).toContain('Missing API key for provider "openai"'); + } + }, + ); + + it.each(["group", "channel"] as const)( + "keeps rate-limit fallback copy out of Discord %s chats", + async (chatType) => { + state.runEmbeddedPiAgentMock.mockRejectedValueOnce(new Error("429 rate limit exceeded")); + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const result = await runAgentTurnWithFallback( + createMinimalRunAgentTurnParams({ + sessionCtx: { + Provider: "discord", + Surface: "discord", + ChatType: chatType, + GroupSubject: "agent group", + GroupChannel: "#general", + MessageSid: "msg", + } as unknown as TemplateContext, + }), + ); + + expect(result.kind).toBe("final"); + if (result.kind === "final") { + expect(result.payload.text).toBe(SILENT_REPLY_TOKEN); + } + }, + ); + + it("surfaces rate-limit fallback copy in Discord group chats when silentReply.group is disallow", async () => { + state.runEmbeddedPiAgentMock.mockRejectedValueOnce(new Error("429 rate limit exceeded")); + + const followupRun = createFollowupRun(); + followupRun.run.config = { + agents: { + defaults: { + silentReply: { group: "disallow" }, + }, + }, + }; + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const result = await runAgentTurnWithFallback( + createMinimalRunAgentTurnParams({ + followupRun, + sessionCtx: { + Provider: "discord", + Surface: "discord", + ChatType: "group", + GroupSubject: "agent group", + GroupChannel: "#general", + MessageSid: "msg", + } as unknown as TemplateContext, + }), + ); + + expect(result.kind).toBe("final"); + if (result.kind === "final") { + expect(result.payload.text).not.toBe(SILENT_REPLY_TOKEN); + expect(result.payload.text).toContain("rate-limited"); + } + }); + it("uses compact generic copy for raw runner failures in normal Discord direct chats", async () => { state.runEmbeddedPiAgentMock.mockRejectedValueOnce( new Error("openai-codex/gpt-5.5 ended with an incomplete terminal response"), diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index f9d348d8312b..091f35ad7768 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -643,12 +643,17 @@ function resolveExternalRunFailureTextForConversation(params: { text: string; sessionCtx: TemplateContext; isGenericRunnerFailure: boolean; + suppressInNonDirect?: boolean; cfg?: OpenClawConfig; }): string { if (!isNonDirectConversationContext(params.sessionCtx)) { return params.text; } - if (!params.isGenericRunnerFailure && !params.text.includes(AGENT_FAILED_BEFORE_REPLY_TEXT)) { + if ( + !params.suppressInNonDirect && + !params.isGenericRunnerFailure && + !params.text.includes(AGENT_FAILED_BEFORE_REPLY_TEXT) + ) { return params.text; } // Match normal reply routing: default group/channel failures stay silent, @@ -826,6 +831,7 @@ export function buildKnownAgentRunFailureReplyPayload(params: { text: buildRateLimitCooldownMessage(params.err), sessionCtx: params.sessionCtx, isGenericRunnerFailure: false, + suppressInNonDirect: true, cfg: params.cfg, }), }); @@ -837,6 +843,7 @@ export function buildKnownAgentRunFailureReplyPayload(params: { text: rateLimitOrOverloadedCopy, sessionCtx: params.sessionCtx, isGenericRunnerFailure: false, + suppressInNonDirect: true, cfg: params.cfg, }), }); @@ -2806,6 +2813,7 @@ export async function runAgentTurnWithFallback(params: { text: fallbackText, sessionCtx: params.sessionCtx, isGenericRunnerFailure: externalRunFailureReply?.isGenericRunnerFailure ?? false, + suppressInNonDirect: Boolean(isRateLimit || rateLimitOrOverloadedCopy), cfg: params.followupRun.run.config, }); @@ -2870,7 +2878,13 @@ export async function runAgentTurnWithFallback(params: { if (formattedErrorCandidate) { runResult.payloads = [ markAgentRunFailureReplyPayload({ - text: formattedErrorCandidate, + text: resolveExternalRunFailureTextForConversation({ + text: formattedErrorCandidate, + sessionCtx: params.sessionCtx, + isGenericRunnerFailure: false, + suppressInNonDirect: true, + cfg: params.followupRun.run.config, + }), isError: true, }), ];