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());