From 0ee348069086ab70fc13568258fd842952963c34 Mon Sep 17 00:00:00 2001 From: Mahsum Aktas Date: Mon, 16 Feb 2026 19:20:26 +0300 Subject: [PATCH] fix(cron): preserve model fallbacks when agent overrides primary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an agent config specifies `model: { primary: "..." }` without an explicit `fallbacks` array, the existing code replaced the entire model object from `agents.defaults`—discarding the default fallbacks. This caused cron jobs (and agent sessions) to have only one model candidate (the pinned model) plus the global primary as a final fallback, skipping all intermediate fallback models. The fix merges the agent model override into the existing defaults model object using spread, so that keys like `fallbacks` survive when the agent only overrides `primary`. Agents can still explicitly override or clear fallbacks by providing their own `fallbacks` array. Reproduction scenario: - `agents.defaults.model = { primary: "codex", fallbacks: ["opus", "flash", "deepseek"] }` - Agent config: `model: { primary: "codex" }` - Cron job pins: `model: "flash"` - Before fix: fallback candidates = [flash, codex] (3 models lost) - After fix: fallback candidates = [flash, opus, deepseek, ..., codex] --- .../run.model-fallback-preservation.test.ts | 106 ++++++++++++++++++ src/cron/isolated-agent/run.ts | 8 +- 2 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 src/cron/isolated-agent/run.model-fallback-preservation.test.ts diff --git a/src/cron/isolated-agent/run.model-fallback-preservation.test.ts b/src/cron/isolated-agent/run.model-fallback-preservation.test.ts new file mode 100644 index 00000000000..3dc744ff9f1 --- /dev/null +++ b/src/cron/isolated-agent/run.model-fallback-preservation.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import type { AgentDefaultsConfig } from "../../config/types.js"; + +/** + * Tests for the model merge fix in runCronIsolatedAgentTurn. + * + * Bug: When an agent config defines `model: { primary: "..." }` without + * `fallbacks`, the merge into `agentCfg` replaced the entire model object + * from defaults—losing `fallbacks`. This caused cron jobs to have only + * one model candidate (the pinned model) plus the global primary, skipping + * all intermediate fallbacks. + * + * Fix: Spread the existing `agentCfg.model` before applying the override, + * so keys like `fallbacks` from `agents.defaults.model` survive when the + * agent only overrides `primary`. + */ +describe("agent model override preserves default fallbacks", () => { + // Simulates the merge logic extracted from run.ts lines 148–159 + function mergeAgentModel( + defaults: AgentDefaultsConfig, + overrideModel: { primary?: string; fallbacks?: string[] } | string | undefined, + ): AgentDefaultsConfig { + const agentCfg: AgentDefaultsConfig = { ...defaults }; + + // --- FIX: merge instead of replace --- + const existingModel = + agentCfg.model && typeof agentCfg.model === "object" ? agentCfg.model : {}; + if (typeof overrideModel === "string") { + agentCfg.model = { ...existingModel, primary: overrideModel }; + } else if (overrideModel) { + agentCfg.model = { ...existingModel, ...overrideModel }; + } + return agentCfg; + } + + const defaultFallbacks = [ + "anthropic/claude-opus-4-6", + "google-gemini-cli/gemini-3-pro-preview", + "nvidia/deepseek-ai/deepseek-v3.2", + ]; + + const defaults: AgentDefaultsConfig = { + model: { + primary: "openai-codex/gpt-5.3-codex", + fallbacks: defaultFallbacks, + }, + }; + + it("preserves fallbacks when agent overrides primary as string", () => { + const result = mergeAgentModel(defaults, "anthropic/claude-sonnet-4-5"); + const model = result.model as { primary?: string; fallbacks?: string[] }; + + expect(model.primary).toBe("anthropic/claude-sonnet-4-5"); + expect(model.fallbacks).toEqual(defaultFallbacks); + }); + + it("preserves fallbacks when agent overrides primary as object", () => { + const result = mergeAgentModel(defaults, { + primary: "anthropic/claude-sonnet-4-5", + }); + const model = result.model as { primary?: string; fallbacks?: string[] }; + + expect(model.primary).toBe("anthropic/claude-sonnet-4-5"); + expect(model.fallbacks).toEqual(defaultFallbacks); + }); + + it("allows agent to explicitly override fallbacks", () => { + const customFallbacks = ["nvidia/deepseek-ai/deepseek-v3.2"]; + const result = mergeAgentModel(defaults, { + primary: "anthropic/claude-sonnet-4-5", + fallbacks: customFallbacks, + }); + const model = result.model as { primary?: string; fallbacks?: string[] }; + + expect(model.primary).toBe("anthropic/claude-sonnet-4-5"); + expect(model.fallbacks).toEqual(customFallbacks); + }); + + it("allows agent to explicitly clear fallbacks with empty array", () => { + const result = mergeAgentModel(defaults, { + primary: "anthropic/claude-sonnet-4-5", + fallbacks: [], + }); + const model = result.model as { primary?: string; fallbacks?: string[] }; + + expect(model.primary).toBe("anthropic/claude-sonnet-4-5"); + expect(model.fallbacks).toEqual([]); + }); + + it("leaves model untouched when override is undefined", () => { + const result = mergeAgentModel(defaults, undefined); + const model = result.model as { primary?: string; fallbacks?: string[] }; + + expect(model.primary).toBe("openai-codex/gpt-5.3-codex"); + expect(model.fallbacks).toEqual(defaultFallbacks); + }); + + it("handles missing defaults model gracefully", () => { + const emptyDefaults: AgentDefaultsConfig = {}; + const result = mergeAgentModel(emptyDefaults, "anthropic/claude-sonnet-4-5"); + const model = result.model as { primary?: string; fallbacks?: string[] }; + + expect(model.primary).toBe("anthropic/claude-sonnet-4-5"); + expect(model.fallbacks).toBeUndefined(); + }); +}); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 8aba703dfbd..9c5bd29278f 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -149,10 +149,14 @@ export async function runCronIsolatedAgentTurn(params: { params.cfg.agents?.defaults, agentOverrideRest as Partial, ); + // Merge agent model override with defaults instead of replacing, so that + // `fallbacks` from `agents.defaults.model` are preserved when the agent + // (or its per-cron model pin) only specifies `primary`. + const existingModel = agentCfg.model && typeof agentCfg.model === "object" ? agentCfg.model : {}; if (typeof overrideModel === "string") { - agentCfg.model = { primary: overrideModel }; + agentCfg.model = { ...existingModel, primary: overrideModel }; } else if (overrideModel) { - agentCfg.model = overrideModel; + agentCfg.model = { ...existingModel, ...overrideModel }; } const cfgWithAgentDefaults: OpenClawConfig = { ...params.cfg,