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
This commit is contained in:
Jerry-Xin 2026-03-16 15:32:27 +08:00
parent 527a1919ea
commit 447fe8ac7a
10 changed files with 161 additions and 3 deletions

View File

@ -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,
});
}

View File

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

View File

@ -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,

View File

@ -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",

View File

@ -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<SessionEntry> = {
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<SessionEntry> = {
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(),

View File

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

View File

@ -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;
}

View File

@ -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)) {

View File

@ -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", () => {

View File

@ -756,7 +756,10 @@ export function resolveSessionModelRef(
cfg: OpenClawConfig,
entry?:
| SessionEntry
| Pick<SessionEntry, "model" | "modelProvider" | "modelOverride" | "providerOverride">,
| 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