refactor(agent): centralize fallback run helpers

This commit is contained in:
Sebastian
2026-02-15 11:44:24 -05:00
parent bf61d94083
commit 2da512e24d
3 changed files with 233 additions and 88 deletions

View File

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

View File

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

View File

@@ -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<string, SessionEntry>;
sessionKey: string;
storePath: string;
entry: SessionEntry;
};
async function persistSessionEntry(params: PersistSessionEntryParams): Promise<void> {
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<typeof loadConfig>;
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<typeof resolveAgentRunContext>;
spawnedBy: string | undefined;
messageChannel: ReturnType<typeof resolveMessageChannel>;
skillsSnapshot: ReturnType<typeof buildWorkspaceSkillSnapshot> | undefined;
resolvedVerboseLevel: VerboseLevel | undefined;
agentDir: string;
onAgentEvent: (evt: { stream: string; data?: Record<string, unknown> }) => 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 (