Skip to content

Commit f718ba6

Browse files
committed
fix(agents): preserve Codex model capacity guidance
1 parent f030025 commit f718ba6

5 files changed

Lines changed: 130 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Docs: https://docs.openclaw.ai
1313

1414
### Fixes
1515

16-
- Agents/OpenAI: surface selected-model capacity failures with a model-switch hint instead of the generic empty-response error. Thanks @vincentkoc.
16+
- Agents/OpenAI: surface selected-model capacity failures from PI, Codex, and auto-reply harness paths with a model-switch hint instead of the generic empty-response error. Thanks @vincentkoc.
1717
- Providers/OpenAI: stop advertising the removed `gpt-5.3-codex-spark` Codex model through fallback catalogs, and suppress stale rows with a GPT-5.5 recovery hint.
1818
- Plugins/QR: replace legacy `qrcode-terminal` QR rendering with bounded `qrcode-tui` helpers for plugin login/setup flows. (#65969) Thanks @vincentkoc.
1919
- Voice-call/realtime: wait for OpenAI session configuration before greeting or forwarding buffered audio, and reject non-allowlisted Twilio callers before stream setup. (#43501) Thanks @forrestblount.

src/agents/pi-embedded-helpers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export {
1313
BILLING_ERROR_USER_MESSAGE,
1414
classifyProviderRuntimeFailureKind,
1515
formatBillingErrorMessage,
16+
formatRateLimitOrOverloadedErrorCopy,
1617
classifyFailoverReason,
1718
classifyFailoverReasonFromHttpStatus,
1819
formatRawAssistantErrorForUi,

src/agents/pi-embedded-helpers/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import type { FailoverReason } from "./types.js";
5353
export {
5454
BILLING_ERROR_USER_MESSAGE,
5555
formatBillingErrorMessage,
56+
formatRateLimitOrOverloadedErrorCopy,
5657
getApiErrorPayloadFingerprint,
5758
isRawApiErrorPayload,
5859
sanitizeUserFacingText,

src/auto-reply/reply/agent-runner-execution.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,23 @@ vi.mock("../../agents/bootstrap-budget.js", () => ({
4949

5050
vi.mock("../../agents/pi-embedded-helpers.js", () => ({
5151
BILLING_ERROR_USER_MESSAGE: "billing",
52+
formatRateLimitOrOverloadedErrorCopy: (message: string) => {
53+
if (/model\s+(?:is\s+)?at capacity/i.test(message)) {
54+
return "⚠️ Selected model is at capacity. Try a different model, or wait and retry.";
55+
}
56+
if (/rate.limit|too many requests|429/i.test(message)) {
57+
return "⚠️ API rate limit reached. Please try again later.";
58+
}
59+
if (/overloaded/i.test(message)) {
60+
return "The AI service is temporarily overloaded. Please try again in a moment.";
61+
}
62+
return undefined;
63+
},
5264
isCompactionFailureError: () => false,
5365
isContextOverflowError: () => false,
5466
isBillingErrorMessage: () => false,
5567
isLikelyContextOverflowError: () => false,
68+
isOverloadedErrorMessage: (message: string) => /overloaded|capacity/i.test(message),
5669
isRateLimitErrorMessage: () => false,
5770
isTransientHttpError: () => false,
5871
sanitizeUserFacingText: (text?: string) => text ?? "",
@@ -410,6 +423,95 @@ describe("runAgentTurnWithFallback", () => {
410423
expect(onToolResult.mock.calls[0]?.[0]?.text).toBeUndefined();
411424
});
412425

426+
it("surfaces model capacity errors from no-text mid-turn failures", async () => {
427+
state.runEmbeddedPiAgentMock.mockResolvedValueOnce({
428+
payloads: [{ text: "thinking", isReasoning: true }],
429+
meta: {
430+
error: {
431+
kind: "server_overloaded",
432+
message: "Selected model is at capacity. Please try a different model.",
433+
},
434+
},
435+
});
436+
437+
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
438+
const result = await runAgentTurnWithFallback({
439+
commandBody: "hello",
440+
followupRun: createFollowupRun(),
441+
sessionCtx: {
442+
Provider: "whatsapp",
443+
MessageSid: "msg",
444+
} as unknown as TemplateContext,
445+
opts: {},
446+
typingSignals: createMockTypingSignaler(),
447+
blockReplyPipeline: null,
448+
blockStreamingEnabled: false,
449+
resolvedBlockStreamingBreak: "message_end",
450+
applyReplyToMode: (payload) => payload,
451+
shouldEmitToolResult: () => true,
452+
shouldEmitToolOutput: () => false,
453+
pendingToolTasks: new Set(),
454+
resetSessionAfterCompactionFailure: async () => false,
455+
resetSessionAfterRoleOrderingConflict: async () => false,
456+
isHeartbeat: false,
457+
sessionKey: "main",
458+
getActiveSessionEntry: () => undefined,
459+
resolvedVerboseLevel: "off",
460+
});
461+
462+
expect(result.kind).toBe("success");
463+
if (result.kind === "success") {
464+
expect(result.runResult.payloads).toEqual([
465+
{
466+
text: "⚠️ Selected model is at capacity. Try a different model, or wait and retry.",
467+
isError: true,
468+
},
469+
]);
470+
}
471+
});
472+
473+
it("surfaces model capacity errors from pre-reply CLI failures", async () => {
474+
state.runWithModelFallbackMock.mockRejectedValueOnce(
475+
new Error("Selected model is at capacity. Please try a different model."),
476+
);
477+
478+
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
479+
const followupRun = createFollowupRun();
480+
followupRun.run.provider = "openai-codex";
481+
followupRun.run.model = "gpt-5.5";
482+
483+
const result = await runAgentTurnWithFallback({
484+
commandBody: "hello",
485+
followupRun,
486+
sessionCtx: {
487+
Provider: "whatsapp",
488+
MessageSid: "msg",
489+
} as unknown as TemplateContext,
490+
opts: {},
491+
typingSignals: createMockTypingSignaler(),
492+
blockReplyPipeline: null,
493+
blockStreamingEnabled: false,
494+
resolvedBlockStreamingBreak: "message_end",
495+
applyReplyToMode: (payload) => payload,
496+
shouldEmitToolResult: () => true,
497+
shouldEmitToolOutput: () => false,
498+
pendingToolTasks: new Set(),
499+
resetSessionAfterCompactionFailure: async () => false,
500+
resetSessionAfterRoleOrderingConflict: async () => false,
501+
isHeartbeat: false,
502+
sessionKey: "main",
503+
getActiveSessionEntry: () => undefined,
504+
resolvedVerboseLevel: "off",
505+
});
506+
507+
expect(result).toEqual({
508+
kind: "final",
509+
payload: {
510+
text: "⚠️ Selected model is at capacity. Try a different model, or wait and retry.",
511+
},
512+
});
513+
});
514+
413515
it("strips a glued leading NO_REPLY token from streamed tool results", async () => {
414516
const onToolResult = vi.fn();
415517
state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => {

src/auto-reply/reply/agent-runner-execution.ts

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { runWithModelFallback, isFallbackSummaryError } from "../../agents/model
1616
import { isCliProvider } from "../../agents/model-selection.js";
1717
import {
1818
BILLING_ERROR_USER_MESSAGE,
19+
formatRateLimitOrOverloadedErrorCopy,
1920
isCompactionFailureError,
2021
isContextOverflowError,
2122
isBillingErrorMessage,
@@ -1516,24 +1517,34 @@ export async function runAgentTurnWithFallback(params: {
15161517
// underlying error. FallbackSummaryError messages embed per-attempt
15171518
// reason labels like `(rate_limit)`, so string-matching the summary text
15181519
// would misclassify mixed-cause exhaustion as a pure transient cooldown.
1519-
const isRateLimit = isFallbackSummaryError(err)
1520+
const isFallbackSummary = isFallbackSummaryError(err);
1521+
const isPureTransientSummary = isFallbackSummary
15201522
? isPureTransientRateLimitSummary(err)
1523+
: false;
1524+
const isRateLimit = isFallbackSummary
1525+
? isPureTransientSummary
15211526
: isRateLimitErrorMessage(message);
1527+
const rateLimitOrOverloadedCopy =
1528+
!isFallbackSummary || isPureTransientSummary
1529+
? formatRateLimitOrOverloadedErrorCopy(message)
1530+
: undefined;
15221531
const safeMessage = isTransientHttp
15231532
? sanitizeUserFacingText(message, { errorContext: true })
15241533
: message;
15251534
const trimmedMessage = safeMessage.replace(/\.\s*$/, "");
15261535
const fallbackText = isBilling
15271536
? BILLING_ERROR_USER_MESSAGE
1528-
: isRateLimit
1537+
: isRateLimit && !isOverloadedErrorMessage(message)
15291538
? buildRateLimitCooldownMessage(err)
1530-
: isContextOverflow
1531-
? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model."
1532-
: isRoleOrderingError
1533-
? "⚠️ Message ordering conflict - please try again. If this persists, use /new to start a fresh session."
1534-
: shouldSurfaceToControlUi
1535-
? `⚠️ Agent failed before reply: ${trimmedMessage}.\nLogs: openclaw logs --follow`
1536-
: buildExternalRunFailureText(message);
1539+
: rateLimitOrOverloadedCopy
1540+
? rateLimitOrOverloadedCopy
1541+
: isContextOverflow
1542+
? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model."
1543+
: isRoleOrderingError
1544+
? "⚠️ Message ordering conflict - please try again. If this persists, use /new to start a fresh session."
1545+
: shouldSurfaceToControlUi
1546+
? `⚠️ Agent failed before reply: ${trimmedMessage}.\nLogs: openclaw logs --follow`
1547+
: buildExternalRunFailureText(message);
15371548

15381549
params.replyOperation?.fail("run_failed", err);
15391550
return {
@@ -1590,16 +1601,13 @@ export async function runAgentTurnWithFallback(params: {
15901601
(p) => p.isError && hasNonEmptyString(p.text) && !p.text.startsWith("⚠️"),
15911602
)?.text ?? "";
15921603
const errorCandidate = metaErrorMsg || rawErrorPayloadText;
1593-
if (
1594-
errorCandidate &&
1595-
(isRateLimitErrorMessage(errorCandidate) || isOverloadedErrorMessage(errorCandidate))
1596-
) {
1597-
const isOverloaded = isOverloadedErrorMessage(errorCandidate);
1604+
const formattedErrorCandidate = errorCandidate
1605+
? formatRateLimitOrOverloadedErrorCopy(errorCandidate)
1606+
: undefined;
1607+
if (formattedErrorCandidate) {
15981608
runResult.payloads = [
15991609
{
1600-
text: isOverloaded
1601-
? "⚠️ The AI service is temporarily overloaded. Please try again in a moment."
1602-
: "⚠️ API rate limit reached — the model couldn't generate a response. Please try again in a moment.",
1610+
text: formattedErrorCandidate,
16031611
isError: true,
16041612
},
16051613
];

0 commit comments

Comments
 (0)