Merge 8f670357faee7437463a54a959d7f59bd308475c into 598f1826d8b2bc969aace2c6459824737667218c

This commit is contained in:
Jerry-Xin 2026-03-21 12:03:50 +08:00 committed by GitHub
commit c3c660c1a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 161 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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