fix(auto-reply): surface periodic usage-limit failures in group chats

This commit is contained in:
Ayaan Zaidi 2026-07-09 15:36:15 +05:30
parent 34d257713e
commit f32c36cdc8
4 changed files with 116 additions and 8 deletions

View file

@ -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(

View file

@ -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;

View file

@ -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"));

View file

@ -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