Skip to content

Commit 844c625

Browse files
Fix stale auto-fallback origin selection
1 parent d68ba5e commit 844c625

11 files changed

Lines changed: 922 additions & 29 deletions

src/agents/agent-command.live-model-switch.test.ts

Lines changed: 430 additions & 3 deletions
Large diffs are not rendered by default.

src/agents/agent-command.ts

Lines changed: 119 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import {
8888
import { isStoredCredentialCompatibleWithAuthProvider } from "./auth-profiles/order.js";
8989
import { clearSessionAuthProfileOverride } from "./auth-profiles/session-override.js";
9090
import { ensureAuthProfileStore } from "./auth-profiles/store.js";
91+
import { isStaleAutoFallbackOriginOverride } from "./auto-fallback-stale-origin.js";
9192
import { isHeartbeatLifecycleRunKind } from "./bootstrap-mode.js";
9293
import {
9394
createAgentAttemptLifecycleCallbacks,
@@ -409,6 +410,61 @@ const OVERRIDE_FIELDS_CLEARED_BY_DELETE: OverrideFieldClearedByDelete[] = [
409410

410411
const OVERRIDE_VALUE_MAX_LENGTH = 256;
411412

413+
type StaleAutoFallbackOriginCleanupGuard = Pick<
414+
SessionEntry,
415+
| "providerOverride"
416+
| "modelOverride"
417+
| "modelOverrideSource"
418+
| "modelOverrideFallbackOriginProvider"
419+
| "modelOverrideFallbackOriginModel"
420+
| "authProfileOverride"
421+
| "authProfileOverrideSource"
422+
| "authProfileOverrideCompactionCount"
423+
| "fallbackNoticeSelectedModel"
424+
| "fallbackNoticeActiveModel"
425+
| "fallbackNoticeReason"
426+
>;
427+
428+
function captureStaleAutoFallbackOriginCleanupGuard(
429+
entry: SessionEntry,
430+
): StaleAutoFallbackOriginCleanupGuard {
431+
return {
432+
providerOverride: entry.providerOverride,
433+
modelOverride: entry.modelOverride,
434+
modelOverrideSource: entry.modelOverrideSource,
435+
modelOverrideFallbackOriginProvider: entry.modelOverrideFallbackOriginProvider,
436+
modelOverrideFallbackOriginModel: entry.modelOverrideFallbackOriginModel,
437+
authProfileOverride: entry.authProfileOverride,
438+
authProfileOverrideSource: entry.authProfileOverrideSource,
439+
authProfileOverrideCompactionCount: entry.authProfileOverrideCompactionCount,
440+
fallbackNoticeSelectedModel: entry.fallbackNoticeSelectedModel,
441+
fallbackNoticeActiveModel: entry.fallbackNoticeActiveModel,
442+
fallbackNoticeReason: entry.fallbackNoticeReason,
443+
};
444+
}
445+
446+
function entryMatchesStaleAutoFallbackOriginCleanupGuard(
447+
entry: SessionEntry | undefined,
448+
guard: StaleAutoFallbackOriginCleanupGuard | undefined,
449+
): boolean {
450+
if (!entry || !guard) {
451+
return false;
452+
}
453+
return (
454+
entry.providerOverride === guard.providerOverride &&
455+
entry.modelOverride === guard.modelOverride &&
456+
entry.modelOverrideSource === guard.modelOverrideSource &&
457+
entry.modelOverrideFallbackOriginProvider === guard.modelOverrideFallbackOriginProvider &&
458+
entry.modelOverrideFallbackOriginModel === guard.modelOverrideFallbackOriginModel &&
459+
entry.authProfileOverride === guard.authProfileOverride &&
460+
entry.authProfileOverrideSource === guard.authProfileOverrideSource &&
461+
entry.authProfileOverrideCompactionCount === guard.authProfileOverrideCompactionCount &&
462+
entry.fallbackNoticeSelectedModel === guard.fallbackNoticeSelectedModel &&
463+
entry.fallbackNoticeActiveModel === guard.fallbackNoticeActiveModel &&
464+
entry.fallbackNoticeReason === guard.fallbackNoticeReason
465+
);
466+
}
467+
412468
async function persistSessionEntry(
413469
params: PersistSessionEntryParams & {
414470
shouldPersist?: (entry: SessionEntry | undefined) => boolean;
@@ -1402,7 +1458,7 @@ async function agentCommandInternal(
14021458
}
14031459
}
14041460

1405-
const storedProviderOverride = hasLegacyAutoFallbackOverrideWithoutOrigin
1461+
let storedProviderOverride = hasLegacyAutoFallbackOverrideWithoutOrigin
14061462
? undefined
14071463
: sessionEntry?.providerOverride?.trim();
14081464
let storedModelOverride = hasLegacyAutoFallbackOverrideWithoutOrigin
@@ -1449,6 +1505,21 @@ async function agentCommandInternal(
14491505
: null;
14501506
const primaryProvider = normalizedChannelOverride?.provider ?? defaultProvider;
14511507
const primaryModel = normalizedChannelOverride?.model ?? defaultModel;
1508+
const hasStaleAutoFallbackOriginOverride =
1509+
hasStoredOverride &&
1510+
!hasExplicitRunOverride &&
1511+
isStaleAutoFallbackOriginOverride({
1512+
entry: sessionEntry,
1513+
defaultProvider,
1514+
defaultModel,
1515+
primaryProvider,
1516+
primaryModel,
1517+
});
1518+
if (hasStaleAutoFallbackOriginOverride) {
1519+
storedProviderOverride = undefined;
1520+
storedModelOverride = undefined;
1521+
storedModelOverrideSource = undefined;
1522+
}
14521523
const hasEffectiveStoredOverride = Boolean(storedProviderOverride || storedModelOverride);
14531524
if (normalizedChannelOverride && !hasEffectiveStoredOverride) {
14541525
provider = normalizedChannelOverride.provider;
@@ -1483,6 +1554,14 @@ async function agentCommandInternal(
14831554
autoFallbackPrimaryProbeSessionEntry = { ...sessionEntry };
14841555
clearAutoFallbackPrimaryProbeSelection(autoFallbackPrimaryProbeSessionEntry);
14851556
}
1557+
let staleAutoFallbackOriginAttemptSessionEntry: SessionEntry | undefined;
1558+
let staleAutoFallbackOriginCleanupGuard: StaleAutoFallbackOriginCleanupGuard | undefined;
1559+
if (hasStaleAutoFallbackOriginOverride && sessionEntry) {
1560+
staleAutoFallbackOriginCleanupGuard =
1561+
captureStaleAutoFallbackOriginCleanupGuard(sessionEntry);
1562+
staleAutoFallbackOriginAttemptSessionEntry = { ...sessionEntry };
1563+
clearAutoFallbackPrimaryProbeSelection(staleAutoFallbackOriginAttemptSessionEntry);
1564+
}
14861565
let providerForAuthProfileValidation = provider;
14871566
if (hasExplicitRunOverride) {
14881567
const explicitRef = explicitModelOverride
@@ -1536,7 +1615,10 @@ async function agentCommandInternal(
15361615
workspaceDir,
15371616
});
15381617

1539-
let sessionEntryForAttempt = autoFallbackPrimaryProbeSessionEntry ?? sessionEntry;
1618+
let sessionEntryForAttempt =
1619+
autoFallbackPrimaryProbeSessionEntry ??
1620+
staleAutoFallbackOriginAttemptSessionEntry ??
1621+
sessionEntry;
15401622
if (sessionEntryForAttempt) {
15411623
const authProfileId = sessionEntryForAttempt.authProfileOverride;
15421624
if (authProfileId) {
@@ -1585,7 +1667,11 @@ async function agentCommandInternal(
15851667
}),
15861668
);
15871669
if (!profileMatchesRuntime) {
1588-
if (hasExplicitRunOverride || autoFallbackPrimaryProbe) {
1670+
if (
1671+
hasExplicitRunOverride ||
1672+
autoFallbackPrimaryProbe ||
1673+
staleAutoFallbackOriginAttemptSessionEntry
1674+
) {
15891675
sessionEntryForAttempt = {
15901676
...entry,
15911677
authProfileOverride: undefined,
@@ -2021,6 +2107,36 @@ async function agentCommandInternal(
20212107
});
20222108
sessionEntry = persistedEntry ?? sessionEntry;
20232109
}
2110+
if (
2111+
!fallbackExhausted &&
2112+
hasStaleAutoFallbackOriginOverride &&
2113+
sessionEntry &&
2114+
sessionStore &&
2115+
sessionKey &&
2116+
!suppressVisibleSessionEffects &&
2117+
!preserveUserFacingSessionModelState
2118+
) {
2119+
const staleCleanupSourceEntry = sessionStore[sessionKey] ?? sessionEntry;
2120+
const nextSessionEntry = { ...staleCleanupSourceEntry };
2121+
const { updated } = applyModelOverrideToSessionEntry({
2122+
entry: nextSessionEntry,
2123+
selection: { provider: primaryProvider, model: primaryModel, isDefault: true },
2124+
});
2125+
if (updated) {
2126+
const persistedEntry = await persistSessionEntry({
2127+
sessionStore,
2128+
sessionKey,
2129+
storePath,
2130+
entry: nextSessionEntry,
2131+
shouldPersist: (current) =>
2132+
entryMatchesStaleAutoFallbackOriginCleanupGuard(
2133+
current,
2134+
staleAutoFallbackOriginCleanupGuard,
2135+
),
2136+
});
2137+
sessionEntry = persistedEntry ?? sessionEntry;
2138+
}
2139+
}
20242140
if (fallbackResult.attempts.length > 0 && result.meta.agentMeta) {
20252141
result = {
20262142
...result,
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Shared stale-origin detection for auto-fallback model pins.
2+
import { hasSessionAutoModelFallbackProvenance } from "../config/sessions/model-override-provenance.js";
3+
import type { SessionEntry } from "../config/sessions/types.js";
4+
import {
5+
modelKey,
6+
normalizeModelRef,
7+
resolvePersistedOverrideModelRef,
8+
} from "./model-selection.js";
9+
10+
type AutoFallbackOriginEntry = Pick<
11+
SessionEntry,
12+
| "providerOverride"
13+
| "modelOverride"
14+
| "modelOverrideSource"
15+
| "modelOverrideFallbackOriginProvider"
16+
| "modelOverrideFallbackOriginModel"
17+
>;
18+
19+
function resolveOverrideKey(params: {
20+
defaultProvider: string;
21+
overrideProvider?: string;
22+
overrideModel?: string;
23+
}): string | null {
24+
const ref = resolvePersistedOverrideModelRef(params);
25+
if (!ref) {
26+
return null;
27+
}
28+
const normalized = normalizeModelRef(ref.provider, ref.model);
29+
return modelKey(normalized.provider, normalized.model);
30+
}
31+
32+
/** Detects auto-fallback pins whose recorded origin no longer matches the current primary. */
33+
export function isStaleAutoFallbackOriginOverride(params: {
34+
entry: AutoFallbackOriginEntry | null | undefined;
35+
defaultProvider: string;
36+
defaultModel: string;
37+
primaryProvider?: string;
38+
primaryModel?: string;
39+
}): boolean {
40+
const entry = params.entry;
41+
if (!entry) {
42+
return false;
43+
}
44+
const recoveredAutoFallbackOverride =
45+
entry.modelOverrideSource === undefined && hasSessionAutoModelFallbackProvenance(entry);
46+
if (entry.modelOverrideSource !== "auto" && !recoveredAutoFallbackOverride) {
47+
return false;
48+
}
49+
50+
const storedOverrideKey = resolveOverrideKey({
51+
defaultProvider: params.defaultProvider,
52+
overrideProvider: entry.providerOverride,
53+
overrideModel: entry.modelOverride,
54+
});
55+
const originKey = resolveOverrideKey({
56+
defaultProvider: params.defaultProvider,
57+
overrideProvider: entry.modelOverrideFallbackOriginProvider,
58+
overrideModel: entry.modelOverrideFallbackOriginModel,
59+
});
60+
const primaryKey = resolveOverrideKey({
61+
defaultProvider: params.defaultProvider,
62+
overrideProvider: params.primaryProvider ?? params.defaultProvider,
63+
overrideModel: params.primaryModel ?? params.defaultModel,
64+
});
65+
if (!storedOverrideKey || !originKey || !primaryKey) {
66+
return false;
67+
}
68+
return originKey !== primaryKey && storedOverrideKey !== primaryKey;
69+
}

src/auto-reply/reply/get-reply-directives-apply.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ describe("formatModelOverrideResetEvent", () => {
2727
formatModelOverrideResetEvent({
2828
rejectedRef: "openai/gpt-5.5",
2929
initialModelLabel: "openai/gpt-5.4",
30-
reason: "stale",
30+
reason: "stale-legacy-auto-fallback-without-origin",
3131
}),
3232
).toBe(
3333
"Stored model override openai/gpt-5.5 is stale for this session; reverted to openai/gpt-5.4. Pick a model again with /model if you still want to override the default.",

src/auto-reply/reply/get-reply-directives-apply.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { resolveModelSelectionFromDirective } from "./directive-handling.model-s
1212
import type { ApplyInlineDirectivesFastLaneParams } from "./directive-handling.params.js";
1313
import type { InlineDirectives } from "./directive-handling.parse.js";
1414
import { clearInlineDirectives } from "./get-reply-directives-utils.js";
15-
import type { createModelSelectionState } from "./model-selection.js";
15+
import type { createModelSelectionState, ModelOverrideResetReason } from "./model-selection.js";
1616
import type { TypingController } from "./typing.js";
1717

1818
type AgentDefaults = NonNullable<OpenClawConfig["agents"]>["defaults"];
@@ -68,9 +68,9 @@ function hasOnlyModelDirective(directives: InlineDirectives): boolean {
6868
export function formatModelOverrideResetEvent(params: {
6969
rejectedRef?: string;
7070
initialModelLabel: string;
71-
reason?: "disallowed" | "stale";
71+
reason?: ModelOverrideResetReason;
7272
}): string {
73-
if (params.reason === "stale") {
73+
if (params.reason && params.reason !== "disallowed") {
7474
if (params.rejectedRef) {
7575
return `Stored model override ${params.rejectedRef} is stale for this session; reverted to ${params.initialModelLabel}. Pick a model again with /model if you still want to override the default.`;
7676
}
@@ -196,7 +196,10 @@ export async function applyInlineDirectiveOverrides(params: {
196196

197197
let directiveAck: ReplyPayload | undefined;
198198

199-
if (modelState.resetModelOverride) {
199+
if (
200+
modelState.resetModelOverride &&
201+
modelState.resetModelOverrideReason !== "stale-auto-fallback-origin"
202+
) {
200203
enqueueSystemEvent(
201204
formatModelOverrideResetEvent({
202205
rejectedRef: modelState.resetModelOverrideRef,

src/auto-reply/reply/get-reply-directives.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,7 @@ export async function resolveReplyDirectives(params: {
540540
skipStoredModelOverride,
541541
hasResolvedHeartbeatModelOverride,
542542
isHeartbeat: opts?.isHeartbeat === true,
543+
deferModelOverrideReset: true,
543544
});
544545
provider = modelState.provider;
545546
model = modelState.model;

src/auto-reply/reply/get-reply.test-fixtures.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ export function createGetReplyContinueDirectivesResult(params: {
128128
provider: params.provider ?? "openai",
129129
model: params.model ?? "gpt-4o-mini",
130130
modelState: {
131+
persistModelOverrideReset: async () => false,
131132
resolveDefaultThinkingLevel: async () => undefined,
132133
resolveThinkingCatalog: async () => [],
133134
},

src/auto-reply/reply/get-reply.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -891,6 +891,7 @@ export async function getReplyFromConfig(
891891
directives = inlineActionResult.directives;
892892
cleanedBody = inlineActionResult.cleanedBody;
893893
abortedLastRun = inlineActionResult.abortedLastRun ?? abortedLastRun;
894+
await modelState.persistModelOverrideReset();
894895
const runAutoFallbackPrimaryProbe = directives.hasModelDirective
895896
? undefined
896897
: autoFallbackPrimaryProbe;

0 commit comments

Comments
 (0)