refactor(agent): centralize fallback run helpers
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user