fix(model-selection): clear auto-failover overrides so primary is retried on each turn

When runWithModelFallback falls back to a secondary provider it writes
providerOverride/modelOverride/modelOverrideSource:"auto" to the session.
On subsequent turns createModelSelectionState read this stored override and
passed the fallback provider directly to runWithModelFallback, so the
configured primary was never retried — the session was permanently pinned to
the fallback even after the primary recovered.

Fix: at model-selection ingress, when the direct session override has
modelOverrideSource "auto" (set by a previous automatic fallback, not a user
/model command), clear the override and retry the configured primary. If the
primary is still down runWithModelFallback will fall back and re-set the auto
override for that turn. Once the primary recovers the override stays clear.

User-selected overrides (modelOverrideSource "user" or legacy undefined+model)
are preserved unchanged.

Covered by four new unit tests in model-selection.test.ts:
- auto-failover override cleared and primary retried
- user-selected override preserved
- legacy override without source field preserved
- parent-session auto-override applied to child (not cleared by child logic)
This commit is contained in:
Kevin O'Neill
2026-04-20 09:43:30 -05:00
committed by Peter Steinberger
parent 76d72d48f3
commit f2abe28d40
2 changed files with 142 additions and 1 deletions

View File

@@ -529,6 +529,122 @@ describe("createModelSelectionState respects session model override", () => {
});
});
describe("createModelSelectionState auto-failover override self-healing", () => {
const defaultProvider = "mac-studio";
const defaultModel = "MiniMax-M2.7-MLX";
const sessionKey = "agent:main:telegram:direct:1";
async function resolveStateWithOverride(params: {
providerOverride: string;
modelOverride: string;
modelOverrideSource: "auto" | "user" | undefined;
}) {
const cfg = {} as OpenClawConfig;
const sessionEntry = makeEntry({
providerOverride: params.providerOverride,
modelOverride: params.modelOverride,
modelOverrideSource: params.modelOverrideSource,
});
const sessionStore = { [sessionKey]: sessionEntry };
const state = await createModelSelectionState({
cfg,
agentCfg: cfg.agents?.defaults,
sessionEntry,
sessionStore,
sessionKey,
defaultProvider,
defaultModel,
provider: defaultProvider,
model: defaultModel,
hasModelDirective: false,
});
return { state, sessionEntry, sessionStore };
}
it("clears auto-failover override and retries the configured primary", async () => {
const { state, sessionStore } = await resolveStateWithOverride({
providerOverride: "openrouter",
modelOverride: "minimax/minimax-m2.7",
modelOverrideSource: "auto",
});
// Provider/model should revert to the configured primary, not the fallback.
expect(state.provider).toBe(defaultProvider);
expect(state.model).toBe(defaultModel);
// The auto override should be cleared from session state.
expect(sessionStore[sessionKey]?.providerOverride).toBeUndefined();
expect(sessionStore[sessionKey]?.modelOverride).toBeUndefined();
expect(sessionStore[sessionKey]?.modelOverrideSource).toBeUndefined();
expect(state.resetModelOverride).toBe(true);
});
it("preserves a user-selected override across turns", async () => {
const { state, sessionStore } = await resolveStateWithOverride({
providerOverride: "openrouter",
modelOverride: "minimax/minimax-m2.7",
modelOverrideSource: "user",
});
// User-selected override must persist.
expect(state.provider).toBe("openrouter");
expect(state.model).toBe("minimax/minimax-m2.7");
expect(sessionStore[sessionKey]?.providerOverride).toBe("openrouter");
expect(sessionStore[sessionKey]?.modelOverride).toBe("minimax/minimax-m2.7");
expect(state.resetModelOverride).toBe(false);
});
it("preserves a legacy override with no modelOverrideSource (treated as user)", async () => {
// Sessions persisted before modelOverrideSource was introduced lack the field.
// Backward-compat rule: missing source + present override = user selection.
const { state, sessionStore } = await resolveStateWithOverride({
providerOverride: "openrouter",
modelOverride: "minimax/minimax-m2.7",
modelOverrideSource: undefined,
});
expect(state.provider).toBe("openrouter");
expect(state.model).toBe("minimax/minimax-m2.7");
expect(sessionStore[sessionKey]?.modelOverride).toBe("minimax/minimax-m2.7");
expect(state.resetModelOverride).toBe(false);
});
it("does not touch an auto-failover override inherited from a parent session", async () => {
// Auto clearing only applies to a direct session override, not one inherited
// from a parent. The parent's own session state is managed separately.
const cfg = {} as OpenClawConfig;
const parentKey = "agent:main:telegram:direct:1";
const childKey = "agent:main:telegram:direct:1:thread:99";
const parentEntry = makeEntry({
providerOverride: "openrouter",
modelOverride: "minimax/minimax-m2.7",
modelOverrideSource: "auto",
});
const childEntry = makeEntry(); // no override of its own
const sessionStore = { [parentKey]: parentEntry, [childKey]: childEntry };
const state = await createModelSelectionState({
cfg,
agentCfg: cfg.agents?.defaults,
sessionEntry: childEntry,
sessionStore,
sessionKey: childKey,
parentSessionKey: parentKey,
defaultProvider,
defaultModel,
provider: defaultProvider,
model: defaultModel,
hasModelDirective: false,
});
// Parent auto-override is applied to the child (it has no direct override).
expect(state.provider).toBe("openrouter");
expect(state.model).toBe("minimax/minimax-m2.7");
// Parent session entry is not modified by the child's selection logic.
expect(sessionStore[parentKey]?.providerOverride).toBe("openrouter");
expect(state.resetModelOverride).toBe(false);
});
});
describe("createModelSelectionState resolveDefaultReasoningLevel", () => {
it("returns on when catalog model has reasoning true", async () => {
const { loadModelCatalog } = await import("../../agents/model-catalog.runtime.js");

View File

@@ -375,7 +375,32 @@ export async function createModelSelectionState(params: {
// was resolved. Heartbeat runs without heartbeat.model should still inherit
// the regular session/parent model override behavior.
const skipStoredOverride = params.hasResolvedHeartbeatModelOverride === true;
if (storedOverride?.model && !skipStoredOverride) {
// Auto-failover overrides are transient: on the next turn, retry the configured
// primary so the session self-heals when the primary recovers. The fallback loop
// in runWithModelFallback will re-set the override if the primary is still down.
// User-selected overrides (/model command) are preserved across turns.
const isAutoSessionOverride =
storedOverride?.source === "session" && sessionEntry?.modelOverrideSource === "auto";
if (isAutoSessionOverride && sessionEntry && sessionStore && sessionKey && !resetModelOverride) {
const { updated } = applyModelOverrideToSessionEntry({
entry: sessionEntry,
selection: { provider: defaultProvider, model: defaultModel, isDefault: true },
});
if (updated) {
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await (
await loadSessionStoreRuntime()
).updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
resetModelOverride = true;
}
}
if (storedOverride?.model && !skipStoredOverride && !isAutoSessionOverride) {
const normalizedStoredOverride = normalizeModelRef(
storedOverride.provider || defaultProvider,
storedOverride.model,