From 2da512e24d5a3f7552f5ec1369cc9f73c0e8a39d Mon Sep 17 00:00:00 2001 From: Sebastian <19554889+sebslight@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:44:24 -0500 Subject: [PATCH] refactor(agent): centralize fallback run helpers --- src/agents/agent-scope.e2e.test.ts | 55 +++++++ src/agents/agent-scope.ts | 16 ++ src/commands/agent.ts | 250 +++++++++++++++++++---------- 3 files changed, 233 insertions(+), 88 deletions(-) diff --git a/src/agents/agent-scope.e2e.test.ts b/src/agents/agent-scope.e2e.test.ts index 8720d54d4..d1d3c900a 100644 --- a/src/agents/agent-scope.e2e.test.ts +++ b/src/agents/agent-scope.e2e.test.ts @@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveAgentConfig, resolveAgentDir, + resolveEffectiveModelFallbacks, resolveAgentModelFallbacksOverride, resolveAgentModelPrimary, resolveAgentWorkspaceDir, @@ -112,6 +113,60 @@ describe("resolveAgentConfig", () => { }, }; expect(resolveAgentModelFallbacksOverride(cfgDisable, "linus")).toEqual([]); + + expect( + resolveEffectiveModelFallbacks({ + cfg, + agentId: "linus", + hasSessionModelOverride: false, + }), + ).toEqual(["openai/gpt-5.2"]); + expect( + resolveEffectiveModelFallbacks({ + cfg, + agentId: "linus", + hasSessionModelOverride: true, + }), + ).toEqual(["openai/gpt-5.2"]); + expect( + resolveEffectiveModelFallbacks({ + cfg: cfgNoOverride, + agentId: "linus", + hasSessionModelOverride: true, + }), + ).toEqual([]); + + const cfgInheritDefaults: OpenClawConfig = { + agents: { + defaults: { + model: { + fallbacks: ["openai/gpt-4.1"], + }, + }, + list: [ + { + id: "linus", + model: { + primary: "anthropic/claude-opus-4", + }, + }, + ], + }, + }; + expect( + resolveEffectiveModelFallbacks({ + cfg: cfgInheritDefaults, + agentId: "linus", + hasSessionModelOverride: true, + }), + ).toEqual(["openai/gpt-4.1"]); + expect( + resolveEffectiveModelFallbacks({ + cfg: cfgDisable, + agentId: "linus", + hasSessionModelOverride: true, + }), + ).toEqual([]); }); it("should return agent-specific sandbox config", () => { diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index fe7f0f6a5..1af692678 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -163,6 +163,22 @@ export function resolveAgentModelFallbacksOverride( return Array.isArray(raw.fallbacks) ? raw.fallbacks : undefined; } +export function resolveEffectiveModelFallbacks(params: { + cfg: OpenClawConfig; + agentId: string; + hasSessionModelOverride: boolean; +}): string[] | undefined { + const agentFallbacksOverride = resolveAgentModelFallbacksOverride(params.cfg, params.agentId); + if (!params.hasSessionModelOverride) { + return agentFallbacksOverride; + } + const defaultFallbacks = + typeof params.cfg.agents?.defaults?.model === "object" + ? (params.cfg.agents.defaults.model.fallbacks ?? []) + : []; + return agentFallbacksOverride ?? defaultFallbacks; +} + export function resolveAgentWorkspaceDir(cfg: OpenClawConfig, agentId: string) { const id = normalizeAgentId(agentId); const configured = resolveAgentConfig(cfg, id)?.workspace?.trim(); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index adeaf865a..e8de4b4d8 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -2,7 +2,7 @@ import type { AgentCommandOpts } from "./agent/types.js"; import { listAgentIds, resolveAgentDir, - resolveAgentModelFallbacksOverride, + resolveEffectiveModelFallbacks, resolveAgentModelPrimary, resolveAgentSkillsFilter, resolveAgentWorkspaceDir, @@ -63,6 +63,124 @@ import { resolveAgentRunContext } from "./agent/run-context.js"; import { updateSessionStoreAfterAgentRun } from "./agent/session-store.js"; import { resolveSession } from "./agent/session.js"; +type PersistSessionEntryParams = { + sessionStore: Record; + sessionKey: string; + storePath: string; + entry: SessionEntry; +}; + +async function persistSessionEntry(params: PersistSessionEntryParams): Promise { + params.sessionStore[params.sessionKey] = params.entry; + await updateSessionStore(params.storePath, (store) => { + store[params.sessionKey] = params.entry; + }); +} + +function resolveFallbackRetryPrompt(params: { body: string; isFallbackRetry: boolean }): string { + if (!params.isFallbackRetry) { + return params.body; + } + return "Continue where you left off. The previous model attempt failed or timed out."; +} + +function runAgentAttempt(params: { + providerOverride: string; + modelOverride: string; + cfg: ReturnType; + sessionEntry: SessionEntry | undefined; + sessionId: string; + sessionKey: string | undefined; + sessionAgentId: string; + sessionFile: string; + workspaceDir: string; + body: string; + isFallbackRetry: boolean; + resolvedThinkLevel: ThinkLevel; + timeoutMs: number; + runId: string; + opts: AgentCommandOpts; + runContext: ReturnType; + spawnedBy: string | undefined; + messageChannel: ReturnType; + skillsSnapshot: ReturnType | undefined; + resolvedVerboseLevel: VerboseLevel | undefined; + agentDir: string; + onAgentEvent: (evt: { stream: string; data?: Record }) => void; + primaryProvider: string; +}) { + const effectivePrompt = resolveFallbackRetryPrompt({ + body: params.body, + isFallbackRetry: params.isFallbackRetry, + }); + if (isCliProvider(params.providerOverride, params.cfg)) { + const cliSessionId = getCliSessionId(params.sessionEntry, params.providerOverride); + return runCliAgent({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + agentId: params.sessionAgentId, + sessionFile: params.sessionFile, + workspaceDir: params.workspaceDir, + config: params.cfg, + prompt: effectivePrompt, + provider: params.providerOverride, + model: params.modelOverride, + thinkLevel: params.resolvedThinkLevel, + timeoutMs: params.timeoutMs, + runId: params.runId, + extraSystemPrompt: params.opts.extraSystemPrompt, + cliSessionId, + images: params.isFallbackRetry ? undefined : params.opts.images, + streamParams: params.opts.streamParams, + }); + } + + const authProfileId = + params.providerOverride === params.primaryProvider + ? params.sessionEntry?.authProfileOverride + : undefined; + return runEmbeddedPiAgent({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + agentId: params.sessionAgentId, + messageChannel: params.messageChannel, + agentAccountId: params.runContext.accountId, + messageTo: params.opts.replyTo ?? params.opts.to, + messageThreadId: params.opts.threadId, + groupId: params.runContext.groupId, + groupChannel: params.runContext.groupChannel, + groupSpace: params.runContext.groupSpace, + spawnedBy: params.spawnedBy, + currentChannelId: params.runContext.currentChannelId, + currentThreadTs: params.runContext.currentThreadTs, + replyToMode: params.runContext.replyToMode, + hasRepliedRef: params.runContext.hasRepliedRef, + senderIsOwner: true, + sessionFile: params.sessionFile, + workspaceDir: params.workspaceDir, + config: params.cfg, + skillsSnapshot: params.skillsSnapshot, + prompt: effectivePrompt, + images: params.isFallbackRetry ? undefined : params.opts.images, + clientTools: params.opts.clientTools, + provider: params.providerOverride, + model: params.modelOverride, + authProfileId, + authProfileIdSource: authProfileId ? params.sessionEntry?.authProfileOverrideSource : undefined, + thinkLevel: params.resolvedThinkLevel, + verboseLevel: params.resolvedVerboseLevel, + timeoutMs: params.timeoutMs, + runId: params.runId, + lane: params.opts.lane, + abortSignal: params.opts.abortSignal, + extraSystemPrompt: params.opts.extraSystemPrompt, + inputProvenance: params.opts.inputProvenance, + streamParams: params.opts.streamParams, + agentDir: params.agentDir, + onAgentEvent: params.onAgentEvent, + }); +} + export async function agentCommand( opts: AgentCommandOpts, runtime: RuntimeEnv = defaultRuntime, @@ -217,9 +335,11 @@ export async function agentCommand( updatedAt: Date.now(), skillsSnapshot, }; - sessionStore[sessionKey] = next; - await updateSessionStore(storePath, (store) => { - store[sessionKey] = next; + await persistSessionEntry({ + sessionStore, + sessionKey, + storePath, + entry: next, }); sessionEntry = next; } @@ -233,9 +353,11 @@ export async function agentCommand( next.thinkingLevel = thinkOverride; } applyVerboseOverride(next, verboseOverride); - sessionStore[sessionKey] = next; - await updateSessionStore(storePath, (store) => { - store[sessionKey] = next; + await persistSessionEntry({ + sessionStore, + sessionKey, + storePath, + entry: next, }); } @@ -307,9 +429,11 @@ export async function agentCommand( selection: { provider: defaultProvider, model: defaultModel, isDefault: true }, }); if (updated) { - sessionStore[sessionKey] = entry; - await updateSessionStore(storePath, (store) => { - store[sessionKey] = entry; + await persistSessionEntry({ + sessionStore, + sessionKey, + storePath, + entry, }); } } @@ -373,9 +497,11 @@ export async function agentCommand( const entry = sessionEntry; entry.thinkingLevel = "high"; entry.updatedAt = Date.now(); - sessionStore[sessionKey] = entry; - await updateSessionStore(storePath, (store) => { - store[sessionKey] = entry; + await persistSessionEntry({ + sessionStore, + sessionKey, + storePath, + entry, }); } } @@ -396,18 +522,13 @@ export async function agentCommand( opts.replyChannel ?? opts.channel, ); const spawnedBy = opts.spawnedBy ?? sessionEntry?.spawnedBy; - // When a session has an explicit model override, keep the candidate chain - // anchored to that override (no implicit configured-primary append), while - // still preserving configured fallback lists unless the agent explicitly - // overrides fallbacks with its own list (including an empty list to disable). - const agentFallbacksOverride = resolveAgentModelFallbacksOverride(cfg, sessionAgentId); - const defaultFallbacks = - typeof cfg.agents?.defaults?.model === "object" - ? (cfg.agents.defaults.model.fallbacks ?? []) - : []; - const effectiveFallbacksOverride = storedModelOverride - ? (agentFallbacksOverride ?? defaultFallbacks) - : agentFallbacksOverride; + // Keep fallback candidate resolution centralized so session model overrides, + // per-agent overrides, and default fallbacks stay consistent across callers. + const effectiveFallbacksOverride = resolveEffectiveModelFallbacks({ + cfg, + agentId: sessionAgentId, + hasSessionModelOverride: Boolean(storedModelOverride), + }); // Track model fallback attempts so retries on an existing session don't // re-inject the original prompt as a duplicate user message. @@ -421,76 +542,29 @@ export async function agentCommand( run: (providerOverride, modelOverride) => { const isFallbackRetry = fallbackAttemptIndex > 0; fallbackAttemptIndex += 1; - // On fallback retries the session file already contains the original - // prompt from the first attempt. Re-injecting the full prompt would - // create a duplicate user message. Use a short continuation hint - // instead so the model picks up where it left off. - const effectivePrompt = isFallbackRetry - ? "Continue where you left off. The previous model attempt failed or timed out." - : body; - if (isCliProvider(providerOverride, cfg)) { - const cliSessionId = getCliSessionId(sessionEntry, providerOverride); - return runCliAgent({ - sessionId, - sessionKey, - agentId: sessionAgentId, - sessionFile, - workspaceDir, - config: cfg, - prompt: effectivePrompt, - provider: providerOverride, - model: modelOverride, - thinkLevel: resolvedThinkLevel, - timeoutMs, - runId, - extraSystemPrompt: opts.extraSystemPrompt, - cliSessionId, - images: isFallbackRetry ? undefined : opts.images, - streamParams: opts.streamParams, - }); - } - const authProfileId = - providerOverride === provider ? sessionEntry?.authProfileOverride : undefined; - return runEmbeddedPiAgent({ + return runAgentAttempt({ + providerOverride, + modelOverride, + cfg, + sessionEntry, sessionId, sessionKey, - agentId: sessionAgentId, - messageChannel, - agentAccountId: runContext.accountId, - messageTo: opts.replyTo ?? opts.to, - messageThreadId: opts.threadId, - groupId: runContext.groupId, - groupChannel: runContext.groupChannel, - groupSpace: runContext.groupSpace, - spawnedBy, - currentChannelId: runContext.currentChannelId, - currentThreadTs: runContext.currentThreadTs, - replyToMode: runContext.replyToMode, - hasRepliedRef: runContext.hasRepliedRef, - senderIsOwner: true, + sessionAgentId, sessionFile, workspaceDir, - config: cfg, - skillsSnapshot, - prompt: effectivePrompt, - images: isFallbackRetry ? undefined : opts.images, - clientTools: opts.clientTools, - provider: providerOverride, - model: modelOverride, - authProfileId, - authProfileIdSource: authProfileId - ? sessionEntry?.authProfileOverrideSource - : undefined, - thinkLevel: resolvedThinkLevel, - verboseLevel: resolvedVerboseLevel, + body, + isFallbackRetry, + resolvedThinkLevel, timeoutMs, runId, - lane: opts.lane, - abortSignal: opts.abortSignal, - extraSystemPrompt: opts.extraSystemPrompt, - inputProvenance: opts.inputProvenance, - streamParams: opts.streamParams, + opts, + runContext, + spawnedBy, + messageChannel, + skillsSnapshot, + resolvedVerboseLevel, agentDir, + primaryProvider: provider, onAgentEvent: (evt) => { // Track lifecycle end for fallback emission below. if (