From 447fe8ac7ab6b9cb3ebf18c3637960d66d44c306 Mon Sep 17 00:00:00 2001 From: Jerry-Xin Date: Mon, 16 Mar 2026 15:32:27 +0800 Subject: [PATCH 1/2] fix(agent): prevent fallback model from permanently overwriting agent config When the primary model fails and a fallback handles the request, the fallback model was being stored in the session and then persisted back to openclaw.json, permanently overwriting the configured primary model. This caused subsequent requests to use the fallback model even after the primary recovered. This fix adds a `modelIsFromFallback` flag to track when a session's runtime model was selected by the fallback chain rather than being the configured primary. When this flag is true, `resolveSessionModelRef` skips the session-stored model and returns the configured primary, ensuring it gets retried on subsequent requests. Changes: - Add `modelIsFromFallback` field to `SessionEntry` type - Update `setSessionRuntimeModel` to accept optional `isFromFallback` - Update `resolveSessionModelRef` to skip session model when from fallback - Update all callers to pass the fallback flag when setting models Fixes #47705 --- src/agents/agent-command.ts | 3 ++ src/agents/command/session-store.ts | 3 ++ src/auto-reply/reply/agent-runner.ts | 3 ++ src/auto-reply/reply/followup-runner.ts | 4 ++ src/auto-reply/reply/session-usage.ts | 4 ++ src/config/sessions/sessions.test.ts | 69 +++++++++++++++++++++++++ src/config/sessions/types.ts | 10 +++- src/cron/isolated-agent/run.ts | 3 ++ src/gateway/session-utils.test.ts | 52 +++++++++++++++++++ src/gateway/session-utils.ts | 13 ++++- 10 files changed, 161 insertions(+), 3 deletions(-) diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 5ed69abd71f..4f2f85f036f 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -1187,6 +1187,8 @@ async function agentCommandInternal( // Update token+model fields in the session store. if (sessionStore && sessionKey) { + // Model is from fallback if the successfully-used provider/model differs from the primary. + const isFromFallback = fallbackProvider !== provider || fallbackModel !== model; await updateSessionStoreAfterAgentRun({ cfg, contextTokensOverride: agentCfg?.contextTokens, @@ -1198,6 +1200,7 @@ async function agentCommandInternal( defaultModel: model, fallbackProvider, fallbackModel, + isFromFallback, result, }); } diff --git a/src/agents/command/session-store.ts b/src/agents/command/session-store.ts index e4746845ed7..27fb234d6fb 100644 --- a/src/agents/command/session-store.ts +++ b/src/agents/command/session-store.ts @@ -24,6 +24,8 @@ export async function updateSessionStoreAfterAgentRun(params: { defaultModel: string; fallbackProvider?: string; fallbackModel?: string; + /** True when the model was selected by the fallback chain rather than being the primary. */ + isFromFallback?: boolean; result: RunResult; }) { const { @@ -66,6 +68,7 @@ export async function updateSessionStoreAfterAgentRun(params: { setSessionRuntimeModel(next, { provider: providerUsed, model: modelUsed, + isFromFallback: params.isFromFallback ?? false, }); if (isCliProvider(providerUsed, cfg)) { const cliSessionId = result.meta.agentMeta?.sessionId?.trim(); diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 76d86c45b05..612b2e90df6 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -465,6 +465,8 @@ export async function runReplyAgent(params: { activeSessionEntry?.contextTokens ?? DEFAULT_CONTEXT_TOKENS; + // Model is from fallback if the successfully-used provider/model differs from the primary. + const isFromFallback = providerUsed !== selectedProvider || modelUsed !== selectedModel; await persistRunSessionUsage({ storePath, sessionKey, @@ -473,6 +475,7 @@ export async function runReplyAgent(params: { promptTokens, modelUsed, providerUsed, + isFromFallback, contextTokensUsed, systemPromptReport: runResult.meta?.systemPromptReport, cliSessionId, diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 339883e730b..8d3e2193590 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -265,6 +265,9 @@ export function createFollowupRunner(params: { DEFAULT_CONTEXT_TOKENS; if (storePath && sessionKey) { + // Model is from fallback if the successfully-used provider/model differs from the primary. + const isFromFallback = + fallbackProvider !== queued.run.provider || fallbackModel !== queued.run.model; await persistRunSessionUsage({ storePath, sessionKey, @@ -273,6 +276,7 @@ export function createFollowupRunner(params: { promptTokens, modelUsed, providerUsed: fallbackProvider, + isFromFallback, contextTokensUsed, systemPromptReport: runResult.meta?.systemPromptReport, logLabel: "followup", diff --git a/src/auto-reply/reply/session-usage.ts b/src/auto-reply/reply/session-usage.ts index 6638a6738ef..f6cb3071c08 100644 --- a/src/auto-reply/reply/session-usage.ts +++ b/src/auto-reply/reply/session-usage.ts @@ -45,6 +45,8 @@ export async function persistSessionUsageUpdate(params: { lastCallUsage?: NormalizedUsage; modelUsed?: string; providerUsed?: string; + /** True when the model was selected by the fallback chain rather than being the primary. */ + isFromFallback?: boolean; contextTokensUsed?: number; promptTokens?: number; systemPromptReport?: SessionSystemPromptReport; @@ -86,6 +88,7 @@ export async function persistSessionUsageUpdate(params: { const patch: Partial = { modelProvider: params.providerUsed ?? entry.modelProvider, model: params.modelUsed ?? entry.model, + modelIsFromFallback: params.isFromFallback ?? false, contextTokens: resolvedContextTokens, systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport, updatedAt: Date.now(), @@ -121,6 +124,7 @@ export async function persistSessionUsageUpdate(params: { const patch: Partial = { modelProvider: params.providerUsed ?? entry.modelProvider, model: params.modelUsed ?? entry.model, + modelIsFromFallback: params.isFromFallback ?? false, contextTokens: params.contextTokensUsed ?? entry.contextTokens, systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport, updatedAt: Date.now(), diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index 2773b6d0fe7..b04d6dfb9f8 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -9,6 +9,7 @@ import { loadSessionStore, mergeSessionEntry, resolveAndPersistSessionFile, + setSessionRuntimeModel, updateSessionStore, } from "../sessions.js"; import type { SessionConfig } from "../types.base.js"; @@ -453,3 +454,71 @@ describe("resolveAndPersistSessionFile", () => { expect(saved[sessionKey]?.sessionFile).toBe(fallbackSessionFile); }); }); + +describe("setSessionRuntimeModel", () => { + it("sets model and provider on session entry", () => { + const entry: SessionEntry = { sessionId: "s1", updatedAt: Date.now() }; + + const result = setSessionRuntimeModel(entry, { + provider: "anthropic", + model: "claude-opus-4-6", + }); + + expect(result).toBe(true); + expect(entry.modelProvider).toBe("anthropic"); + expect(entry.model).toBe("claude-opus-4-6"); + expect(entry.modelIsFromFallback).toBe(false); + }); + + it("sets modelIsFromFallback when isFromFallback is true (#47705)", () => { + const entry: SessionEntry = { sessionId: "s1", updatedAt: Date.now() }; + + setSessionRuntimeModel(entry, { + provider: "xai", + model: "grok-4-1-fast-reasoning", + isFromFallback: true, + }); + + expect(entry.modelIsFromFallback).toBe(true); + }); + + it("clears modelIsFromFallback when isFromFallback is false", () => { + const entry: SessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + modelIsFromFallback: true, + }; + + setSessionRuntimeModel(entry, { + provider: "openai-codex", + model: "gpt-5.3-codex", + isFromFallback: false, + }); + + expect(entry.modelIsFromFallback).toBe(false); + }); + + it("returns false for empty provider", () => { + const entry: SessionEntry = { sessionId: "s1", updatedAt: Date.now() }; + + const result = setSessionRuntimeModel(entry, { + provider: "", + model: "claude-opus-4-6", + }); + + expect(result).toBe(false); + expect(entry.model).toBeUndefined(); + }); + + it("returns false for empty model", () => { + const entry: SessionEntry = { sessionId: "s1", updatedAt: Date.now() }; + + const result = setSessionRuntimeModel(entry, { + provider: "anthropic", + model: "", + }); + + expect(result).toBe(false); + expect(entry.modelProvider).toBeUndefined(); + }); +}); diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 4ba9b336127..2e2a9c26eb7 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -142,6 +142,13 @@ export type SessionEntry = { cacheWrite?: number; modelProvider?: string; model?: string; + /** + * True when the session's runtime model was selected by the fallback chain + * rather than being the configured primary. Used to prevent fallback models + * from being persisted back to agent config and to ensure the primary model + * is retried on subsequent requests. + */ + modelIsFromFallback?: boolean; /** * Last selected/runtime model pair for which a fallback notice was emitted. * Used to avoid repeating the same fallback notice every turn. @@ -220,7 +227,7 @@ export function normalizeSessionRuntimeModelFields(entry: SessionEntry): Session export function setSessionRuntimeModel( entry: SessionEntry, - runtime: { provider: string; model: string }, + runtime: { provider: string; model: string; isFromFallback?: boolean }, ): boolean { const provider = runtime.provider.trim(); const model = runtime.model.trim(); @@ -229,6 +236,7 @@ export function setSessionRuntimeModel( } entry.modelProvider = provider; entry.model = model; + entry.modelIsFromFallback = runtime.isFromFallback ?? false; return true; } diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 78f045d03cf..fd2a5e00094 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -744,9 +744,12 @@ export async function runCronIsolatedAgentTurn(params: { const contextTokens = agentCfg?.contextTokens ?? lookupContextTokens(modelUsed) ?? DEFAULT_CONTEXT_TOKENS; + // Model is from fallback if the successfully-used provider/model differs from the primary. + const isFromFallback = providerUsed !== provider || modelUsed !== model; setSessionRuntimeModel(cronSession.sessionEntry, { provider: providerUsed, model: modelUsed, + isFromFallback, }); cronSession.sessionEntry.contextTokens = contextTokens; if (isCliProvider(providerUsed, cfgWithAgentDefaults)) { diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 3c69ce1bcd7..9a291868765 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -479,6 +479,58 @@ describe("resolveSessionModelRef", () => { expect(resolved).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" }); }); + + test("ignores session-stored model when modelIsFromFallback is true (#47705)", () => { + // When a fallback model is stored in the session, resolveSessionModelRef should + // skip it and return the configured primary model instead, so the primary is + // retried on subsequent requests. + const cfg = createModelDefaultsConfig({ + primary: "openai-codex/gpt-5.3-codex", + }); + + const resolved = resolveSessionModelRef(cfg, { + sessionId: "fallback-session", + updatedAt: Date.now(), + modelProvider: "xai", + model: "grok-4-1-fast-reasoning", + modelIsFromFallback: true, + }); + + expect(resolved).toEqual({ provider: "openai-codex", model: "gpt-5.3-codex" }); + }); + + test("uses session-stored model when modelIsFromFallback is false", () => { + // When the model was explicitly set (not from fallback), it should be used. + const cfg = createModelDefaultsConfig({ + primary: "openai-codex/gpt-5.3-codex", + }); + + const resolved = resolveSessionModelRef(cfg, { + sessionId: "explicit-session", + updatedAt: Date.now(), + modelProvider: "anthropic", + model: "claude-opus-4-6", + modelIsFromFallback: false, + }); + + expect(resolved).toEqual({ provider: "anthropic", model: "claude-opus-4-6" }); + }); + + test("uses session-stored model when modelIsFromFallback is undefined (legacy)", () => { + // Legacy sessions without the flag should still work as before. + const cfg = createModelDefaultsConfig({ + primary: "openai-codex/gpt-5.3-codex", + }); + + const resolved = resolveSessionModelRef(cfg, { + sessionId: "legacy-session", + updatedAt: Date.now(), + modelProvider: "anthropic", + model: "claude-opus-4-6", + }); + + expect(resolved).toEqual({ provider: "anthropic", model: "claude-opus-4-6" }); + }); }); describe("resolveSessionModelIdentityRef", () => { diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 00a2cb7747e..48557dff79e 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -756,7 +756,10 @@ export function resolveSessionModelRef( cfg: OpenClawConfig, entry?: | SessionEntry - | Pick, + | Pick< + SessionEntry, + "model" | "modelProvider" | "modelOverride" | "providerOverride" | "modelIsFromFallback" + >, agentId?: string, ): { provider: string; model: string } { const resolved = agentId @@ -767,13 +770,19 @@ export function resolveSessionModelRef( defaultModel: DEFAULT_MODEL, }); + // Skip session-stored runtime model if it came from the fallback chain. + // This ensures the primary model is retried on subsequent requests rather + // than permanently sticking with the fallback. See #47705. + const isFromFallback = + entry && "modelIsFromFallback" in entry && entry.modelIsFromFallback === true; + // Prefer the last runtime model recorded on the session entry. // This is the actual model used by the latest run and must win over defaults. let provider = resolved.provider; let model = resolved.model; const runtimeModel = entry?.model?.trim(); const runtimeProvider = entry?.modelProvider?.trim(); - if (runtimeModel) { + if (runtimeModel && !isFromFallback) { if (runtimeProvider) { // Provider is explicitly recorded — use it directly. Re-parsing the // model string through parseModelRef would incorrectly split OpenRouter From 8f670357faee7437463a54a959d7f59bd308475c Mon Sep 17 00:00:00 2001 From: Jerry-Xin Date: Mon, 16 Mar 2026 16:08:24 +0800 Subject: [PATCH 2/2] fix(agent): preserve modelIsFromFallback when callers omit isFromFallback Align modelIsFromFallback with the existing fallback pattern used by modelProvider and model fields: fall back to the existing entry value instead of hardcoding false when the caller does not supply the flag. This prevents a contextTokensUsed-only update from silently clearing a valid fallback state. --- src/auto-reply/reply/session-usage.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/auto-reply/reply/session-usage.ts b/src/auto-reply/reply/session-usage.ts index f6cb3071c08..9c0725c129f 100644 --- a/src/auto-reply/reply/session-usage.ts +++ b/src/auto-reply/reply/session-usage.ts @@ -88,7 +88,7 @@ export async function persistSessionUsageUpdate(params: { const patch: Partial = { modelProvider: params.providerUsed ?? entry.modelProvider, model: params.modelUsed ?? entry.model, - modelIsFromFallback: params.isFromFallback ?? false, + modelIsFromFallback: params.isFromFallback ?? entry.modelIsFromFallback, contextTokens: resolvedContextTokens, systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport, updatedAt: Date.now(), @@ -124,7 +124,7 @@ export async function persistSessionUsageUpdate(params: { const patch: Partial = { modelProvider: params.providerUsed ?? entry.modelProvider, model: params.modelUsed ?? entry.model, - modelIsFromFallback: params.isFromFallback ?? false, + modelIsFromFallback: params.isFromFallback ?? entry.modelIsFromFallback, contextTokens: params.contextTokensUsed ?? entry.contextTokens, systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport, updatedAt: Date.now(),