diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 5db40b13a27..fc1446bbe62 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -1257,6 +1257,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, @@ -1268,6 +1270,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 0df9d66dc72..c7adc53e7d6 100644 --- a/src/agents/command/session-store.ts +++ b/src/agents/command/session-store.ts @@ -29,6 +29,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 { @@ -71,6 +73,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 fbdad1be160..bd197a284b6 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -472,6 +472,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, @@ -481,6 +483,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 2fd21607095..18edefb2a0a 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -268,6 +268,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, @@ -277,6 +280,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 d3594fcdf42..e212bdcd27b 100644 --- a/src/auto-reply/reply/session-usage.ts +++ b/src/auto-reply/reply/session-usage.ts @@ -70,6 +70,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; @@ -119,6 +121,7 @@ export async function persistSessionUsageUpdate(params: { const patch: Partial = { modelProvider: params.providerUsed ?? entry.modelProvider, model: params.modelUsed ?? entry.model, + modelIsFromFallback: params.isFromFallback ?? entry.modelIsFromFallback, contextTokens: resolvedContextTokens, systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport, updatedAt: Date.now(), @@ -159,6 +162,7 @@ export async function persistSessionUsageUpdate(params: { const patch: Partial = { modelProvider: params.providerUsed ?? entry.modelProvider, model: params.modelUsed ?? entry.model, + modelIsFromFallback: params.isFromFallback ?? entry.modelIsFromFallback, 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 ff38953aae2..3e5bf47339e 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -11,6 +11,7 @@ import { loadSessionStore, mergeSessionEntry, resolveAndPersistSessionFile, + setSessionRuntimeModel, updateSessionStore, } from "../sessions.js"; import type { SessionConfig } from "../types.base.js"; @@ -596,3 +597,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 6513fc81b37..7315e0af537 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -153,6 +153,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. @@ -231,7 +238,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(); @@ -240,6 +247,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 1a122f56864..9a01991f602 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -752,9 +752,12 @@ export async function runCronIsolatedAgentTurn(params: { lookupContextTokens(modelUsed, { allowAsyncLoad: false }) ?? 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 7f26059b813..d758103e364 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -488,6 +488,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 52c6f54b1ca..616413f1e2b 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -912,7 +912,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 @@ -923,13 +926,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