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:
parent
527a1919ea
commit
447fe8ac7a
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user