From e138e90031744411379a4e207627a8248f034a19 Mon Sep 17 00:00:00 2001 From: subaochen Date: Sat, 21 Mar 2026 10:54:46 +0800 Subject: [PATCH 1/6] feat: add fallbackOnErrors config for model fallback behavior - Add FallbackOnErrorCodes type: all, default, or number array - Add fallbackOnErrors field to AgentModelConfig - Add shouldTriggerFallback() function - Support custom HTTP status codes that trigger fallback Users can configure which errors trigger fallback: - default: Server errors + rate limits (5xx, 429, 408) - all: All errors including client errors (400, 401, 403) - number[]: Custom status codes --- src/agents/failover-error.ts | 110 ++++++++++++++++++++++++++++++ src/agents/model-fallback.ts | 4 ++ src/config/types.agents-shared.ts | 25 +++++++ 3 files changed, 139 insertions(+) diff --git a/src/agents/failover-error.ts b/src/agents/failover-error.ts index dd482310a2b..67a9834ab98 100644 --- a/src/agents/failover-error.ts +++ b/src/agents/failover-error.ts @@ -1,3 +1,4 @@ +import type { FallbackOnErrorCodes } from "../config/types.agents-shared.js"; import { readErrorName } from "../infra/errors.js"; import { classifyFailoverReason, @@ -328,3 +329,112 @@ export function coerceToFailoverError( cause: err instanceof Error ? err : undefined, }); } + +/** + * Default HTTP status codes that trigger fallback. + * - Server errors: 500, 502, 503, 504 + * - Rate limits: 429 + * - Timeouts: 408 + * - Not found: 404 (model may have been removed) + */ +const DEFAULT_FALLBACK_STATUS_CODES = [408, 429, 500, 502, 503, 504, 404]; + +/** + * All HTTP status codes that could trigger fallback (including client errors). + */ +const ALL_FALLBACK_STATUS_CODES = [400, 401, 402, 403, 404, 405, 408, 410, 429, 500, 502, 503, 504]; + +/** + * Check if an error should trigger fallback based on the configured error codes. + * + * @param err - The error to check + * @param fallbackOnErrors - Configuration for which errors should trigger fallback + * - "default": Use default behavior (server errors + rate limits) + * - "all": All errors trigger fallback + * - number[]: Custom list of status codes + * @returns true if the error should trigger fallback + */ +export function shouldTriggerFallback( + err: unknown, + fallbackOnErrors?: FallbackOnErrorCodes, +): boolean { + const status = getStatusCode(err); + + // If no status code found, try to determine from error reason + if (status === undefined) { + const reason = resolveFailoverReasonFromError(err); + // Default behavior: non-null reason means it's a recognized failover error + if (fallbackOnErrors === undefined || fallbackOnErrors === "default") { + return reason !== null; + } + // For "all", any reason (including null) should trigger fallback + if (fallbackOnErrors === "all") { + return true; + } + // For custom codes, we can't determine without status + return false; + } + + // Determine which status codes to use + let allowedCodes: number[]; + if (fallbackOnErrors === undefined || fallbackOnErrors === "default") { + allowedCodes = DEFAULT_FALLBACK_STATUS_CODES; + } else if (fallbackOnErrors === "all") { + allowedCodes = ALL_FALLBACK_STATUS_CODES; + } else { + allowedCodes = fallbackOnErrors; + } + + return allowedCodes.includes(status); +} + +/** + * Coerce an error to FailoverError if it should trigger fallback based on configuration. + * + * @param err - The error to check + * @param fallbackOnErrors - Configuration for which errors should trigger fallback + * @param context - Additional context (provider, model, profileId) + * @returns FailoverError if the error should trigger fallback, null otherwise + */ +export function coerceToFailoverErrorWithConfig( + err: unknown, + fallbackOnErrors: FallbackOnErrorCodes | undefined, + context?: { + provider?: string; + model?: string; + profileId?: string; + }, +): FailoverError | null { + // First check if it's already a FailoverError + if (isFailoverError(err)) { + // Still need to check if it should trigger fallback based on config + if (!shouldTriggerFallback(err, fallbackOnErrors)) { + return null; + } + return err; + } + + // Check if error should trigger fallback + if (!shouldTriggerFallback(err, fallbackOnErrors)) { + return null; + } + + // Coerce to FailoverError + const status = getStatusCode(err); + const reason = resolveFailoverReasonFromError(err); + const message = getErrorMessage(err) || String(err); + const code = getErrorCode(err); + + // If we have a status but no reason, create a generic reason + const effectiveReason: FailoverReason = reason ?? "unknown"; + + return new FailoverError(message, { + reason: effectiveReason, + provider: context?.provider, + model: context?.model, + profileId: context?.profileId, + status, + code, + cause: err instanceof Error ? err : undefined, + }); +} diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index 5fd6e533a1a..4576982cba9 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -3,6 +3,7 @@ import { resolveAgentModelFallbackValues, resolveAgentModelPrimaryValue, } from "../config/model-input.js"; +import type { FallbackOnErrorCodes } from "../config/types.agents-shared.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { sanitizeForLog } from "../terminal/ansi.js"; import { @@ -15,6 +16,7 @@ import { import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import { coerceToFailoverError, + // coerceToFailoverErrorWithConfig, // TODO: use in future implementation describeFailoverError, isFailoverError, isTimeoutError, @@ -516,6 +518,8 @@ export async function runWithModelFallback(params: { agentDir?: string; /** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */ fallbacksOverride?: string[]; + /** HTTP status codes that should trigger fallback. */ + fallbackOnErrors?: FallbackOnErrorCodes; run: ModelFallbackRunFn; onError?: ModelFallbackErrorHandler; }): Promise> { diff --git a/src/config/types.agents-shared.ts b/src/config/types.agents-shared.ts index 3351d9903c9..d8901ba7228 100644 --- a/src/config/types.agents-shared.ts +++ b/src/config/types.agents-shared.ts @@ -5,6 +5,16 @@ import type { SandboxSshSettings, } from "./types.sandbox.js"; +/** + * HTTP status codes that should trigger model fallback. + * Default behavior only triggers fallback on server errors (5xx) and rate limits (429). + * Users can extend this to include client errors like 400, 401, 403, etc. + */ +export type FallbackOnErrorCodes = + | "all" // All errors trigger fallback + | "default" // Server errors + rate limits only (500, 502, 503, 429, 408) + | number[]; // Custom list of HTTP status codes + export type AgentModelConfig = | string | { @@ -12,6 +22,21 @@ export type AgentModelConfig = primary?: string; /** Per-agent model fallbacks (provider/model). */ fallbacks?: string[]; + /** + * HTTP status codes that should trigger fallback to next model. + * - "default": Server errors (5xx) + rate limits (429) + timeout (408) [default] + * - "all": All errors trigger fallback (including 400, 401, 403, 404) + * - number[]: Custom list of status codes (e.g., [400, 401, 403, 429, 500, 502, 503]) + * + * @example + * // Enable fallback on all client and server errors + * { primary: "openai/gpt-4", fallbacks: ["anthropic/claude-3"], fallbackOnErrors: "all" } + * + * @example + * // Custom error codes + * { primary: "openai/gpt-4", fallbacks: ["anthropic/claude-3"], fallbackOnErrors: [400, 429, 500, 502, 503] } + */ + fallbackOnErrors?: FallbackOnErrorCodes; }; export type AgentSandboxConfig = { From 1cad2605ddcbeb6a4aa3d76f562b9a8df6731e58 Mon Sep 17 00:00:00 2001 From: subaochen Date: Sat, 21 Mar 2026 11:22:03 +0800 Subject: [PATCH 2/6] fix: wire up fallbackOnErrors parameter and fix edge cases - Wire fallbackOnErrors through runFallbackCandidate and runFallbackAttempt - Use coerceToFailoverErrorWithConfig when fallbackOnErrors is provided - Fix "all" mode to use status >= 400 instead of fixed list - Fix "all" mode to not trigger fallback for non-HTTP errors - Update comments to match actual default status codes (include 504, 404) - Add fallbackOnErrors to runWithImageModelFallback for consistency Addresses review feedback from Greptile: - Critical: fallbackOnErrors parameter is now actually used - Logic: "all" now correctly covers all 4xx/5xx status codes - Logic: "all" no longer triggers fallback for unrelated runtime errors - Style: Comments now match actual implementation --- src/agents/failover-error.ts | 35 ++++++++------------------ src/agents/model-fallback.ts | 42 +++++++++++++++++++++++-------- src/config/types.agents-shared.ts | 12 ++++----- 3 files changed, 48 insertions(+), 41 deletions(-) diff --git a/src/agents/failover-error.ts b/src/agents/failover-error.ts index 67a9834ab98..ce6f3a3729c 100644 --- a/src/agents/failover-error.ts +++ b/src/agents/failover-error.ts @@ -339,18 +339,13 @@ export function coerceToFailoverError( */ const DEFAULT_FALLBACK_STATUS_CODES = [408, 429, 500, 502, 503, 504, 404]; -/** - * All HTTP status codes that could trigger fallback (including client errors). - */ -const ALL_FALLBACK_STATUS_CODES = [400, 401, 402, 403, 404, 405, 408, 410, 429, 500, 502, 503, 504]; - /** * Check if an error should trigger fallback based on the configured error codes. * * @param err - The error to check * @param fallbackOnErrors - Configuration for which errors should trigger fallback - * - "default": Use default behavior (server errors + rate limits) - * - "all": All errors trigger fallback + * - "default": Use default behavior (server errors + rate limits + timeout + not found) + * - "all": All HTTP errors (4xx and 5xx) trigger fallback * - number[]: Custom list of status codes * @returns true if the error should trigger fallback */ @@ -363,29 +358,21 @@ export function shouldTriggerFallback( // If no status code found, try to determine from error reason if (status === undefined) { const reason = resolveFailoverReasonFromError(err); - // Default behavior: non-null reason means it's a recognized failover error - if (fallbackOnErrors === undefined || fallbackOnErrors === "default") { - return reason !== null; - } - // For "all", any reason (including null) should trigger fallback - if (fallbackOnErrors === "all") { - return true; - } - // For custom codes, we can't determine without status - return false; + // For any mode, only trigger fallback if we have a recognized failover reason + // This prevents unrelated runtime errors (parse errors, filesystem errors) from triggering fallback + return reason !== null; } - // Determine which status codes to use - let allowedCodes: number[]; + // Determine if status code should trigger fallback if (fallbackOnErrors === undefined || fallbackOnErrors === "default") { - allowedCodes = DEFAULT_FALLBACK_STATUS_CODES; + return DEFAULT_FALLBACK_STATUS_CODES.includes(status); } else if (fallbackOnErrors === "all") { - allowedCodes = ALL_FALLBACK_STATUS_CODES; + // "all" means all HTTP errors (4xx and 5xx) + return status >= 400; } else { - allowedCodes = fallbackOnErrors; + // Custom list of status codes + return fallbackOnErrors.includes(status); } - - return allowedCodes.includes(status); } /** diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index 4576982cba9..63225e6a2f9 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -16,7 +16,7 @@ import { import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import { coerceToFailoverError, - // coerceToFailoverErrorWithConfig, // TODO: use in future implementation + coerceToFailoverErrorWithConfig, describeFailoverError, isFailoverError, isTimeoutError, @@ -132,6 +132,7 @@ async function runFallbackCandidate(params: { provider: string; model: string; options?: ModelFallbackRunOptions; + fallbackOnErrors?: FallbackOnErrorCodes; }): Promise<{ ok: true; result: T } | { ok: false; error: unknown }> { try { const result = params.options @@ -144,10 +145,16 @@ async function runFallbackCandidate(params: { } catch (err) { // Normalize abort-wrapped rate-limit errors (e.g. Google Vertex RESOURCE_EXHAUSTED) // so they become FailoverErrors and continue the fallback loop instead of aborting. - const normalizedFailover = coerceToFailoverError(err, { - provider: params.provider, - model: params.model, - }); + // Use config-aware error coercion if fallbackOnErrors is provided. + const normalizedFailover = params.fallbackOnErrors + ? coerceToFailoverErrorWithConfig(err, params.fallbackOnErrors, { + provider: params.provider, + model: params.model, + }) + : coerceToFailoverError(err, { + provider: params.provider, + model: params.model, + }); if (shouldRethrowAbort(err) && !normalizedFailover) { throw err; } @@ -161,12 +168,14 @@ async function runFallbackAttempt(params: { model: string; attempts: FallbackAttempt[]; options?: ModelFallbackRunOptions; + fallbackOnErrors?: FallbackOnErrorCodes; }): Promise<{ success: ModelFallbackRunResult } | { error: unknown }> { const runResult = await runFallbackCandidate({ run: params.run, provider: params.provider, model: params.model, options: params.options, + fallbackOnErrors: params.fallbackOnErrors, }); if (runResult.ok) { return { @@ -667,6 +676,7 @@ export async function runWithModelFallback(params: { ...candidate, attempts, options: runOptions, + fallbackOnErrors: params.fallbackOnErrors, }); if ("success" in attemptRun) { if (i > 0 || attempts.length > 0 || attemptedDuringCooldown) { @@ -715,11 +725,15 @@ export async function runWithModelFallback(params: { if (isLikelyContextOverflowError(errMessage)) { throw err; } - const normalized = - coerceToFailoverError(err, { - provider: candidate.provider, - model: candidate.model, - }) ?? err; + const normalized = params.fallbackOnErrors + ? coerceToFailoverErrorWithConfig(err, params.fallbackOnErrors, { + provider: candidate.provider, + model: candidate.model, + }) ?? err + : coerceToFailoverError(err, { + provider: candidate.provider, + model: candidate.model, + }) ?? err; // Even unrecognized errors should not abort the fallback loop when // there are remaining candidates. Only abort/context-overflow errors @@ -783,6 +797,7 @@ export async function runWithImageModelFallback(params: { modelOverride?: string; run: (provider: string, model: string) => Promise; onError?: ModelFallbackErrorHandler; + fallbackOnErrors?: FallbackOnErrorCodes; }): Promise> { const candidates = resolveImageFallbackCandidates({ cfg: params.cfg, @@ -800,7 +815,12 @@ export async function runWithImageModelFallback(params: { for (let i = 0; i < candidates.length; i += 1) { const candidate = candidates[i]; - const attemptRun = await runFallbackAttempt({ run: params.run, ...candidate, attempts }); + const attemptRun = await runFallbackAttempt({ + run: params.run, + ...candidate, + attempts, + fallbackOnErrors: params.fallbackOnErrors, + }); if ("success" in attemptRun) { return attemptRun.success; } diff --git a/src/config/types.agents-shared.ts b/src/config/types.agents-shared.ts index d8901ba7228..73dca0d6360 100644 --- a/src/config/types.agents-shared.ts +++ b/src/config/types.agents-shared.ts @@ -7,12 +7,12 @@ import type { /** * HTTP status codes that should trigger model fallback. - * Default behavior only triggers fallback on server errors (5xx) and rate limits (429). - * Users can extend this to include client errors like 400, 401, 403, etc. + * Default behavior triggers fallback on server errors, rate limits, timeouts, and not-found errors. + * Users can extend this to include all client errors with "all" or specify custom codes. */ export type FallbackOnErrorCodes = - | "all" // All errors trigger fallback - | "default" // Server errors + rate limits only (500, 502, 503, 429, 408) + | "all" // All HTTP errors (4xx and 5xx) trigger fallback + | "default" // Server errors (500, 502, 503, 504) + rate limits (429) + timeout (408) + not found (404) | number[]; // Custom list of HTTP status codes export type AgentModelConfig = @@ -24,8 +24,8 @@ export type AgentModelConfig = fallbacks?: string[]; /** * HTTP status codes that should trigger fallback to next model. - * - "default": Server errors (5xx) + rate limits (429) + timeout (408) [default] - * - "all": All errors trigger fallback (including 400, 401, 403, 404) + * - "default": Server errors (500, 502, 503, 504) + rate limits (429) + timeout (408) + not found (404) [default] + * - "all": All HTTP errors (4xx and 5xx) trigger fallback * - number[]: Custom list of status codes (e.g., [400, 401, 403, 429, 500, 502, 503]) * * @example From 775a8169b609afd107d6553ead4bc54df8d62412 Mon Sep 17 00:00:00 2001 From: subaochen Date: Sat, 21 Mar 2026 11:26:59 +0800 Subject: [PATCH 3/6] style: fix formatting in model-fallback.ts --- src/agents/model-fallback.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index 63225e6a2f9..e0fdcdd40a9 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -726,14 +726,14 @@ export async function runWithModelFallback(params: { throw err; } const normalized = params.fallbackOnErrors - ? coerceToFailoverErrorWithConfig(err, params.fallbackOnErrors, { + ? (coerceToFailoverErrorWithConfig(err, params.fallbackOnErrors, { provider: candidate.provider, model: candidate.model, - }) ?? err - : coerceToFailoverError(err, { + }) ?? err) + : (coerceToFailoverError(err, { provider: candidate.provider, model: candidate.model, - }) ?? err; + }) ?? err); // Even unrecognized errors should not abort the fallback loop when // there are remaining candidates. Only abort/context-overflow errors From 02e23d4468be0737006bb91ec71f786fdcb90224 Mon Sep 17 00:00:00 2001 From: subaochen Date: Sat, 21 Mar 2026 11:33:28 +0800 Subject: [PATCH 4/6] fix: use Set instead of Array for fallback status codes Oxlint rule: should be a Set and use .has() for existence checks --- src/agents/failover-error.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/agents/failover-error.ts b/src/agents/failover-error.ts index ce6f3a3729c..bc053fd5e9e 100644 --- a/src/agents/failover-error.ts +++ b/src/agents/failover-error.ts @@ -337,7 +337,7 @@ export function coerceToFailoverError( * - Timeouts: 408 * - Not found: 404 (model may have been removed) */ -const DEFAULT_FALLBACK_STATUS_CODES = [408, 429, 500, 502, 503, 504, 404]; +const DEFAULT_FALLBACK_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504, 404]); /** * Check if an error should trigger fallback based on the configured error codes. @@ -365,13 +365,13 @@ export function shouldTriggerFallback( // Determine if status code should trigger fallback if (fallbackOnErrors === undefined || fallbackOnErrors === "default") { - return DEFAULT_FALLBACK_STATUS_CODES.includes(status); + return DEFAULT_FALLBACK_STATUS_CODES.has(status); } else if (fallbackOnErrors === "all") { // "all" means all HTTP errors (4xx and 5xx) return status >= 400; } else { // Custom list of status codes - return fallbackOnErrors.includes(status); + return new Set(fallbackOnErrors).has(status); } } From 731008500e033f0c3a02e552bea34c314315f5bb Mon Sep 17 00:00:00 2001 From: subaochen Date: Sat, 21 Mar 2026 11:54:14 +0800 Subject: [PATCH 5/6] fix: wire up fallbackOnErrors config through all call sites - Update shouldTriggerFallback() to use reason !== null for default behavior - Add resolveAgentModelFallbackOnErrors() resolver in model-input.ts - Add resolveRunModelFallbackOnErrors() in agent-scope.ts - Wire fallbackOnErrors through resolveModelFallbackOptions() - Update agent-command.ts call site - Update cron/isolated-agent/run.ts call site --- src/agents/agent-command.ts | 6 ++++ src/agents/agent-scope.ts | 29 +++++++++++++++- src/agents/failover-error.ts | 40 +++++++++------------- src/auto-reply/reply/agent-runner-utils.ts | 10 +++++- src/config/model-input.ts | 17 ++++++++- src/cron/isolated-agent/run.ts | 5 +++ 6 files changed, 80 insertions(+), 27 deletions(-) diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 5db40b13a27..da2b3f9eac6 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -57,6 +57,7 @@ import { listAgentIds, resolveAgentDir, resolveEffectiveModelFallbacks, + resolveRunModelFallbackOnErrors, resolveSessionAgentId, resolveAgentSkillsFilter, resolveAgentWorkspaceDir, @@ -1177,6 +1178,11 @@ async function agentCommandInternal( runId, agentDir, fallbacksOverride: effectiveFallbacksOverride, + fallbackOnErrors: resolveRunModelFallbackOnErrors({ + cfg, + agentId: sessionAgentId, + sessionKey, + }), run: (providerOverride, modelOverride, runOptions) => { const isFallbackRetry = fallbackAttemptIndex > 0; fallbackAttemptIndex += 1; diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 5425b033dca..6af1cada008 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -1,8 +1,12 @@ import fs from "node:fs"; import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; -import { resolveAgentModelFallbackValues } from "../config/model-input.js"; +import { + resolveAgentModelFallbackOnErrors, + resolveAgentModelFallbackValues, +} from "../config/model-input.js"; import { resolveStateDir } from "../config/paths.js"; +import type { FallbackOnErrorCodes } from "../config/types.agents-shared.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { DEFAULT_AGENT_ID, @@ -230,6 +234,29 @@ export function resolveRunModelFallbacksOverride(params: { ); } +export function resolveAgentModelFallbackOnErrorsOverride( + cfg: OpenClawConfig, + agentId: string, +): FallbackOnErrorCodes | undefined { + const raw = resolveAgentConfig(cfg, agentId)?.model; + return resolveAgentModelFallbackOnErrors(raw); +} + +export function resolveRunModelFallbackOnErrors(params: { + cfg: OpenClawConfig | undefined; + agentId?: string | null; + sessionKey?: string | null; +}): FallbackOnErrorCodes | undefined { + if (!params.cfg) { + return undefined; + } + const raw = resolveAgentConfig( + params.cfg, + resolveFallbackAgentId({ agentId: params.agentId, sessionKey: params.sessionKey }), + )?.model; + return resolveAgentModelFallbackOnErrors(raw); +} + export function hasConfiguredModelFallbacks(params: { cfg: OpenClawConfig | undefined; agentId?: string | null; diff --git a/src/agents/failover-error.ts b/src/agents/failover-error.ts index bc053fd5e9e..acf34bf3183 100644 --- a/src/agents/failover-error.ts +++ b/src/agents/failover-error.ts @@ -330,21 +330,15 @@ export function coerceToFailoverError( }); } -/** - * Default HTTP status codes that trigger fallback. - * - Server errors: 500, 502, 503, 504 - * - Rate limits: 429 - * - Timeouts: 408 - * - Not found: 404 (model may have been removed) - */ -const DEFAULT_FALLBACK_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504, 404]); - /** * Check if an error should trigger fallback based on the configured error codes. * + * For "default" or undefined, this delegates to resolveFailoverReasonFromError() which + * matches the original behavior (no regression). + * * @param err - The error to check * @param fallbackOnErrors - Configuration for which errors should trigger fallback - * - "default": Use default behavior (server errors + rate limits + timeout + not found) + * - "default": Use original behavior (same as no config) - any recognized failover reason * - "all": All HTTP errors (4xx and 5xx) trigger fallback * - number[]: Custom list of status codes * @returns true if the error should trigger fallback @@ -354,25 +348,23 @@ export function shouldTriggerFallback( fallbackOnErrors?: FallbackOnErrorCodes, ): boolean { const status = getStatusCode(err); + const reason = resolveFailoverReasonFromError(err); - // If no status code found, try to determine from error reason - if (status === undefined) { - const reason = resolveFailoverReasonFromError(err); - // For any mode, only trigger fallback if we have a recognized failover reason - // This prevents unrelated runtime errors (parse errors, filesystem errors) from triggering fallback + // For "default" or undefined, match original behavior exactly + // This delegates to the existing reason classification logic + if (fallbackOnErrors === undefined || fallbackOnErrors === "default") { return reason !== null; } - // Determine if status code should trigger fallback - if (fallbackOnErrors === undefined || fallbackOnErrors === "default") { - return DEFAULT_FALLBACK_STATUS_CODES.has(status); - } else if (fallbackOnErrors === "all") { - // "all" means all HTTP errors (4xx and 5xx) - return status >= 400; - } else { - // Custom list of status codes - return new Set(fallbackOnErrors).has(status); + // For "all", check if HTTP error (4xx or 5xx) + // Also allow non-HTTP errors with recognized reasons + if (fallbackOnErrors === "all") { + return status !== undefined ? status >= 400 : reason !== null; } + + // For custom array, check specific status codes only + // Ignore non-HTTP errors even if they have a recognized reason + return status !== undefined && new Set(fallbackOnErrors).has(status); } /** diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index abf6322a287..4b3fd44013c 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -1,4 +1,7 @@ -import { resolveRunModelFallbacksOverride } from "../../agents/agent-scope.js"; +import { + resolveRunModelFallbacksOverride, + resolveRunModelFallbackOnErrors, +} from "../../agents/agent-scope.js"; import type { NormalizedUsage } from "../../agents/usage.js"; import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js"; @@ -165,6 +168,11 @@ export function resolveModelFallbackOptions(run: FollowupRun["run"]) { agentId: run.agentId, sessionKey: run.sessionKey, }), + fallbackOnErrors: resolveRunModelFallbackOnErrors({ + cfg: run.config, + agentId: run.agentId, + sessionKey: run.sessionKey, + }), }; } diff --git a/src/config/model-input.ts b/src/config/model-input.ts index 197947ab853..263dbcf86b1 100644 --- a/src/config/model-input.ts +++ b/src/config/model-input.ts @@ -1,4 +1,4 @@ -import type { AgentModelConfig } from "./types.agents-shared.js"; +import type { AgentModelConfig, FallbackOnErrorCodes } from "./types.agents-shared.js"; type AgentModelListLike = { primary?: string; @@ -24,6 +24,21 @@ export function resolveAgentModelFallbackValues(model?: AgentModelConfig): strin return Array.isArray(model.fallbacks) ? model.fallbacks : []; } +/** + * Resolve the fallbackOnErrors configuration from an AgentModelConfig. + * + * @param model - The agent model configuration + * @returns The fallbackOnErrors value ("all", "default", number[], or undefined) + */ +export function resolveAgentModelFallbackOnErrors( + model?: AgentModelConfig, +): FallbackOnErrorCodes | undefined { + if (!model || typeof model !== "object") { + return undefined; + } + return model.fallbackOnErrors; +} + export function toAgentModelListLike(model?: AgentModelConfig): AgentModelListLike | undefined { if (typeof model === "string") { const primary = model.trim(); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 1a122f56864..4471bf0a7cd 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -5,6 +5,7 @@ import { resolveAgentModelFallbacksOverride, resolveAgentWorkspaceDir, resolveDefaultAgentId, + resolveRunModelFallbackOnErrors, } from "../../agents/agent-scope.js"; import { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js"; import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js"; @@ -586,6 +587,10 @@ export async function runCronIsolatedAgentTurn(params: { agentDir, fallbacksOverride: payloadFallbacks ?? resolveAgentModelFallbacksOverride(params.cfg, agentId), + fallbackOnErrors: resolveRunModelFallbackOnErrors({ + cfg: cfgWithAgentDefaults, + agentId, + }), run: async (providerOverride, modelOverride, runOptions) => { if (abortSignal?.aborted) { throw new Error(abortReason()); From b46a671a9147f1658b5e4de94e1c9ffd416f0598 Mon Sep 17 00:00:00 2001 From: subaochen Date: Sat, 21 Mar 2026 11:57:46 +0800 Subject: [PATCH 6/6] fix: update baseline for runtime-matrix.ts import boundary The runtime-matrix.ts file no longer imports from extensions/matrix/runtime-api.js, so removing this entry from the baseline inventory. --- .../plugin-extension-import-boundary-inventory.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/test/fixtures/plugin-extension-import-boundary-inventory.json b/test/fixtures/plugin-extension-import-boundary-inventory.json index 0894fe0d5b5..ead171321f9 100644 --- a/test/fixtures/plugin-extension-import-boundary-inventory.json +++ b/test/fixtures/plugin-extension-import-boundary-inventory.json @@ -31,14 +31,6 @@ "resolvedPath": "extensions/imessage/runtime-api.js", "reason": "imports extension-owned file from src/plugins" }, - { - "file": "src/plugins/runtime/runtime-matrix.ts", - "line": 4, - "kind": "import", - "specifier": "../../../extensions/matrix/runtime-api.js", - "resolvedPath": "extensions/matrix/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, { "file": "src/plugins/runtime/runtime-slack-ops.runtime.ts", "line": 10,