From f32c36cdc878aca611fdfe12513deb9352cb49bf Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 9 Jul 2026 15:36:15 +0530 Subject: [PATCH] fix(auto-reply): surface periodic usage-limit failures in group chats --- ...ent-helpers.sanitizeuserfacingtext.test.ts | 8 ++ .../sanitize-user-facing-text.ts | 2 +- .../reply/agent-runner-execution.test.ts | 80 +++++++++++++++++++ .../reply/agent-runner-execution.ts | 34 ++++++-- 4 files changed, 116 insertions(+), 8 deletions(-) diff --git a/src/agents/embedded-agent-helpers.sanitizeuserfacingtext.test.ts b/src/agents/embedded-agent-helpers.sanitizeuserfacingtext.test.ts index 5d003fdd8533..4ccee52d640b 100644 --- a/src/agents/embedded-agent-helpers.sanitizeuserfacingtext.test.ts +++ b/src/agents/embedded-agent-helpers.sanitizeuserfacingtext.test.ts @@ -168,6 +168,14 @@ describe("sanitizeUserFacingText", () => { ); }); + it("preserves rate limit reset details that use resets wording", () => { + expect( + sanitizeUserFacingText("Error: Rate limit reached, resets 6pm (UTC)", { + errorContext: true, + }), + ).toBe("⚠️ Rate limit reached, resets 6pm (UTC)"); + }); + it("returns a model-switch hint for OpenAI model capacity errors", () => { expect( sanitizeUserFacingText( diff --git a/src/agents/embedded-agent-helpers/sanitize-user-facing-text.ts b/src/agents/embedded-agent-helpers/sanitize-user-facing-text.ts index d2eaae344fb5..cb05843bf27e 100644 --- a/src/agents/embedded-agent-helpers/sanitize-user-facing-text.ts +++ b/src/agents/embedded-agent-helpers/sanitize-user-facing-text.ts @@ -95,7 +95,7 @@ const HTTP_ERROR_HINTS = [ "permission", ]; const RATE_LIMIT_SPECIFIC_HINT_RE = - /\bmin(ute)?s?\b|\bhours?\b|\bseconds?\b|\btry again in\b|\breset\b|\bplan\b|\bquota\b/i; + /\bmin(ute)?s?\b|\bhours?\b|\bseconds?\b|\btry again in\b|\bresets?\b|\bplan\b|\bquota\b/i; const MODEL_CAPACITY_ERROR_RE = /\b(?:selected\s+)?model\s+(?:is\s+)?at capacity\b/i; const NON_ERROR_PROVIDER_PAYLOAD_MAX_LENGTH = 16_384; const NON_ERROR_PROVIDER_PAYLOAD_PREFIX_RE = /^codex\s*error(?:\s+\d{3})?[:\s-]+/i; diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index 4c95778780ce..15ed0859f03e 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -26,6 +26,7 @@ import { SILENT_REPLY_TOKEN } from "../tokens.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; import { buildEmptyInteractiveReplyPayload, + buildKnownAgentRunFailureReplyPayload, buildContextOverflowRecoveryText, computeContextAwareReserveTokensFloor, MAX_LIVE_SWITCH_RETRIES, @@ -7119,6 +7120,58 @@ describe("runAgentTurnWithFallback", () => { }, ); + it.each(NON_DIRECT_FAILURE_SURFACE_CASES)( + "surfaces typed periodic rate-limit details in $label chats", + async (testCase) => { + const periodicLimitMessage = "You've hit your weekly limit · resets 6pm (UTC)"; + state.runEmbeddedAgentMock.mockRejectedValueOnce( + new FailoverError(periodicLimitMessage, { + reason: "rate_limit", + provider: "anthropic", + model: "claude-opus-4-1", + rawError: periodicLimitMessage, + }), + ); + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const result = await runAgentTurnWithFallback( + createMinimalRunAgentTurnParams({ + sessionCtx: createNonDirectFailureSessionCtx(testCase), + }), + ); + + expect(result.kind).toBe("final"); + if (result.kind === "final") { + expect(result.payload.isError).toBe(true); + expect(result.payload.text).not.toBe(SILENT_REPLY_TOKEN); + expect(result.payload.text).toContain("weekly limit"); + expect(result.payload.text).toContain("resets 6pm"); + expect(result.payload.text).not.toContain("few minutes"); + } + }, + ); + + it("surfaces typed periodic rate-limit details through known failure payloads in group chats", () => { + const periodicLimitMessage = "You've hit your weekly limit · resets 6pm (UTC)"; + const payload = buildKnownAgentRunFailureReplyPayload({ + err: new FailoverError(periodicLimitMessage, { + reason: "rate_limit", + provider: "anthropic", + model: "claude-opus-4-1", + rawError: periodicLimitMessage, + }), + sessionCtx: createNonDirectFailureSessionCtx(NON_DIRECT_FAILURE_SURFACE_CASES[0]), + resolvedVerboseLevel: "off", + }); + + expect(payload).toBeDefined(); + expect(payload?.isError).toBe(true); + expect(payload?.text).not.toBe(SILENT_REPLY_TOKEN); + expect(payload?.text).toContain("weekly limit"); + expect(payload?.text).toContain("resets 6pm"); + expect(payload?.text).not.toContain("few minutes"); + }); + it.each(NON_DIRECT_FAILURE_SURFACE_CASES)( "surfaces overloaded fallback copy in $label chats", async (testCase) => { @@ -7140,6 +7193,33 @@ describe("runAgentTurnWithFallback", () => { }, ); + it("surfaces typed overloaded failures without rate-limit cooldown copy", async () => { + state.runEmbeddedAgentMock.mockRejectedValueOnce( + new FailoverError("529 Please try again", { + reason: "overloaded", + provider: "anthropic", + model: "claude-opus-4-1", + status: 529, + }), + ); + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const result = await runAgentTurnWithFallback( + createMinimalRunAgentTurnParams({ + sessionCtx: createNonDirectFailureSessionCtx(NON_DIRECT_FAILURE_SURFACE_CASES[0]), + }), + ); + + expect(result.kind).toBe("final"); + if (result.kind === "final") { + expect(result.payload.isError).toBe(true); + expect(result.payload.text).not.toBe(SILENT_REPLY_TOKEN); + expect(result.payload.text).toContain("overloaded"); + expect(result.payload.text).not.toContain("rate-limited"); + expect(result.payload.text).not.toContain("few minutes"); + } + }); + it("surfaces rate-limit fallback copy in Discord group chats when silentReply.group is disallow", async () => { state.runEmbeddedAgentMock.mockRejectedValueOnce(new Error("429 rate limit exceeded")); diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index d1d4fb317970..edddd3fed008 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -42,6 +42,7 @@ import { isRateLimitErrorMessage, isTransientHttpError, } from "../../agents/embedded-agent-helpers.js"; +import { isPeriodicUsageLimitErrorMessage } from "../../agents/embedded-agent-helpers/failover-matches.js"; import { sanitizeUserFacingText } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js"; import { isMessagingToolSendAction } from "../../agents/embedded-agent-messaging.js"; import { mergeEmbeddedAgentRunResultForModelFallbackExhaustion } from "../../agents/embedded-agent-runner/result-fallback-classifier.js"; @@ -685,6 +686,10 @@ function buildRateLimitCooldownMessage(err: unknown): string { return BILLING_ERROR_USER_MESSAGE; } if (!isFallbackSummaryError(err)) { + if (isPeriodicUsageLimitErrorMessage(message)) { + const providerMessage = sanitizeUserFacingText(message, { errorContext: true }); + return providerMessage.startsWith("⚠️") ? providerMessage : `⚠️ ${providerMessage}`; + } return "⚠️ All models are temporarily rate-limited. Please try again in a few minutes."; } const expiry = err.soonestCooldownExpiry; @@ -1150,13 +1155,22 @@ export function buildKnownAgentRunFailureReplyPayload(params: { const isPureTransientSummary = isFallbackSummary ? isPureTransientRateLimitSummary(params.err) : false; - const isRateLimit = isFallbackSummary ? isPureTransientSummary : isRateLimitErrorMessage(message); + const failoverReason = + !isFallbackSummary && isFailoverError(params.err) ? params.err.reason : undefined; + const isOverloaded = failoverReason === "overloaded" || isOverloadedErrorMessage(message); + const isRateLimit = isFallbackSummary + ? isPureTransientSummary + : failoverReason + ? failoverReason === "rate_limit" || failoverReason === "overloaded" + : isRateLimitErrorMessage(message); const rateLimitOrOverloadedCopy = !isFallbackSummary || isPureTransientSummary - ? formatRateLimitOrOverloadedErrorCopy(message) + ? formatRateLimitOrOverloadedErrorCopy( + failoverReason === "overloaded" ? "overloaded" : message, + ) : undefined; - if (isRateLimit && !isOverloadedErrorMessage(message)) { + if (isRateLimit && !isOverloaded) { return markAgentRunFailureReplyPayload({ text: resolveExternalRunFailureTextForConversation({ text: buildRateLimitCooldownMessage(params.err), @@ -3444,12 +3458,18 @@ async function runAgentTurnWithFallbackInternal( const isPureTransientSummary = isFallbackSummary ? isPureTransientRateLimitSummary(err) : false; + const failoverReason = !isFallbackSummary && isFailoverError(err) ? err.reason : undefined; + const isOverloaded = failoverReason === "overloaded" || isOverloadedErrorMessage(message); const isRateLimit = isFallbackSummary ? isPureTransientSummary - : isRateLimitErrorMessage(message); + : failoverReason + ? failoverReason === "rate_limit" || failoverReason === "overloaded" + : isRateLimitErrorMessage(message); const rateLimitOrOverloadedCopy = !isFallbackSummary || isPureTransientSummary - ? formatRateLimitOrOverloadedErrorCopy(message) + ? formatRateLimitOrOverloadedErrorCopy( + failoverReason === "overloaded" ? "overloaded" : message, + ) : undefined; const safeMessage = isTransientHttp ? sanitizeUserFacingText(message, { errorContext: true }) @@ -3457,7 +3477,7 @@ async function runAgentTurnWithFallbackInternal( const trimmedMessage = safeMessage.replace(/\.\s*$/, ""); const externalRunFailureReply = !isBilling && - !(isRateLimit && !isOverloadedErrorMessage(message)) && + !(isRateLimit && !isOverloaded) && !rateLimitOrOverloadedCopy && !isContextOverflow && !shouldSurfaceToControlUi @@ -3475,7 +3495,7 @@ async function runAgentTurnWithFallbackInternal( : GENERIC_EXTERNAL_RUN_FAILURE_TEXT; const fallbackText = isBilling ? resolveBillingFailureReplyText(err) - : isRateLimit && !isOverloadedErrorMessage(message) + : isRateLimit && !isOverloaded ? buildRateLimitCooldownMessage(err) : rateLimitOrOverloadedCopy ? rateLimitOrOverloadedCopy