refactor(agents): centralize model fallback resolution

This commit is contained in:
Peter Steinberger
2026-02-25 04:32:25 +00:00
parent dd6ad0da8c
commit 9beec48e9c
7 changed files with 205 additions and 61 deletions

View File

@@ -2,13 +2,16 @@ import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
hasConfiguredModelFallbacks,
resolveAgentConfig,
resolveAgentDir,
resolveAgentEffectiveModelPrimary,
resolveAgentExplicitModelPrimary,
resolveFallbackAgentId,
resolveEffectiveModelFallbacks,
resolveAgentModelFallbacksOverride,
resolveAgentModelPrimary,
resolveRunModelFallbacksOverride,
resolveAgentWorkspaceDir,
} from "./agent-scope.js";
@@ -210,6 +213,109 @@ describe("resolveAgentConfig", () => {
).toEqual([]);
});
it("resolves fallback agent id from explicit agent id first", () => {
expect(
resolveFallbackAgentId({
agentId: "Support",
sessionKey: "agent:main:session",
}),
).toBe("support");
});
it("resolves fallback agent id from session key when explicit id is missing", () => {
expect(
resolveFallbackAgentId({
sessionKey: "agent:worker:session",
}),
).toBe("worker");
});
it("resolves run fallback overrides via shared helper", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
model: {
fallbacks: ["openai/gpt-4.1"],
},
},
list: [
{
id: "support",
model: {
fallbacks: ["openai/gpt-5.2"],
},
},
],
},
};
expect(
resolveRunModelFallbacksOverride({
cfg,
agentId: "support",
sessionKey: "agent:main:session",
}),
).toEqual(["openai/gpt-5.2"]);
expect(
resolveRunModelFallbacksOverride({
cfg,
agentId: undefined,
sessionKey: "agent:support:session",
}),
).toEqual(["openai/gpt-5.2"]);
});
it("computes whether any model fallbacks are configured via shared helper", () => {
const cfgDefaultsOnly: OpenClawConfig = {
agents: {
defaults: {
model: {
fallbacks: ["openai/gpt-4.1"],
},
},
list: [{ id: "main" }],
},
};
expect(
hasConfiguredModelFallbacks({
cfg: cfgDefaultsOnly,
sessionKey: "agent:main:session",
}),
).toBe(true);
const cfgAgentOverrideOnly: OpenClawConfig = {
agents: {
defaults: {
model: {
fallbacks: [],
},
},
list: [
{
id: "support",
model: {
fallbacks: ["openai/gpt-5.2"],
},
},
],
},
};
expect(
hasConfiguredModelFallbacks({
cfg: cfgAgentOverrideOnly,
agentId: "support",
sessionKey: "agent:support:session",
}),
).toBe(true);
expect(
hasConfiguredModelFallbacks({
cfg: cfgAgentOverrideOnly,
agentId: "main",
sessionKey: "agent:main:session",
}),
).toBe(false);
});
it("should return agent-specific sandbox config", () => {
const cfg: OpenClawConfig = {
agents: {

View File

@@ -7,6 +7,7 @@ import {
DEFAULT_AGENT_ID,
normalizeAgentId,
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
} from "../routing/session-key.js";
import { resolveUserPath } from "../utils.js";
import { normalizeSkillFilter } from "./skills/filter.js";
@@ -19,7 +20,7 @@ function stripNullBytes(s: string): string {
return s.replace(/\0/g, "");
}
export { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
export { resolveAgentIdFromSessionKey };
type AgentEntry = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[number];
@@ -203,6 +204,41 @@ export function resolveAgentModelFallbacksOverride(
return Array.isArray(raw.fallbacks) ? raw.fallbacks : undefined;
}
export function resolveFallbackAgentId(params: {
agentId?: string | null;
sessionKey?: string | null;
}): string {
const explicitAgentId = typeof params.agentId === "string" ? params.agentId.trim() : "";
if (explicitAgentId) {
return normalizeAgentId(explicitAgentId);
}
return resolveAgentIdFromSessionKey(params.sessionKey);
}
export function resolveRunModelFallbacksOverride(params: {
cfg: OpenClawConfig | undefined;
agentId?: string | null;
sessionKey?: string | null;
}): string[] | undefined {
if (!params.cfg) {
return undefined;
}
return resolveAgentModelFallbacksOverride(
params.cfg,
resolveFallbackAgentId({ agentId: params.agentId, sessionKey: params.sessionKey }),
);
}
export function hasConfiguredModelFallbacks(params: {
cfg: OpenClawConfig | undefined;
agentId?: string | null;
sessionKey?: string | null;
}): boolean {
const fallbacksOverride = resolveRunModelFallbacksOverride(params);
const defaultFallbacks = resolveAgentModelFallbackValues(params.cfg?.agents?.defaults?.model);
return (fallbacksOverride ?? defaultFallbacks).length > 0;
}
export function resolveEffectiveModelFallbacks(params: {
cfg: OpenClawConfig;
agentId: string;

View File

@@ -63,7 +63,8 @@ function shouldRethrowAbort(err: unknown): boolean {
function createModelCandidateCollector(allowlist: Set<string> | null | undefined): {
candidates: ModelCandidate[];
addCandidate: (candidate: ModelCandidate, enforceAllowlist: boolean) => void;
addExplicitCandidate: (candidate: ModelCandidate) => void;
addAllowlistedCandidate: (candidate: ModelCandidate) => void;
} {
const seen = new Set<string>();
const candidates: ModelCandidate[] = [];
@@ -83,7 +84,14 @@ function createModelCandidateCollector(allowlist: Set<string> | null | undefined
candidates.push(candidate);
};
return { candidates, addCandidate };
const addExplicitCandidate = (candidate: ModelCandidate) => {
addCandidate(candidate, false);
};
const addAllowlistedCandidate = (candidate: ModelCandidate) => {
addCandidate(candidate, true);
};
return { candidates, addExplicitCandidate, addAllowlistedCandidate };
}
type ModelFallbackErrorHandler = (attempt: {
@@ -138,9 +146,10 @@ function resolveImageFallbackCandidates(params: {
cfg: params.cfg,
defaultProvider: params.defaultProvider,
});
const { candidates, addCandidate } = createModelCandidateCollector(allowlist);
const { candidates, addExplicitCandidate, addAllowlistedCandidate } =
createModelCandidateCollector(allowlist);
const addRaw = (raw: string, enforceAllowlist: boolean) => {
const addRaw = (raw: string, opts?: { allowlist?: boolean }) => {
const resolved = resolveModelRefFromString({
raw: String(raw ?? ""),
defaultProvider: params.defaultProvider,
@@ -149,15 +158,19 @@ function resolveImageFallbackCandidates(params: {
if (!resolved) {
return;
}
addCandidate(resolved.ref, enforceAllowlist);
if (opts?.allowlist) {
addAllowlistedCandidate(resolved.ref);
return;
}
addExplicitCandidate(resolved.ref);
};
if (params.modelOverride?.trim()) {
addRaw(params.modelOverride, false);
addRaw(params.modelOverride);
} else {
const primary = resolveAgentModelPrimaryValue(params.cfg?.agents?.defaults?.imageModel);
if (primary?.trim()) {
addRaw(primary, false);
addRaw(primary);
}
}
@@ -166,7 +179,7 @@ function resolveImageFallbackCandidates(params: {
for (const raw of imageFallbacks) {
// Explicitly configured image fallbacks should remain reachable even when a
// model allowlist is present.
addRaw(raw, false);
addRaw(raw);
}
return candidates;
@@ -200,9 +213,9 @@ function resolveFallbackCandidates(params: {
cfg: params.cfg,
defaultProvider,
});
const { candidates, addCandidate } = createModelCandidateCollector(allowlist);
const { candidates, addExplicitCandidate } = createModelCandidateCollector(allowlist);
addCandidate(normalizedPrimary, false);
addExplicitCandidate(normalizedPrimary);
const modelFallbacks = (() => {
if (params.fallbacksOverride !== undefined) {
@@ -239,11 +252,11 @@ function resolveFallbackCandidates(params: {
}
// Fallbacks are explicit user intent; do not silently filter them by the
// model allowlist.
addCandidate(resolved.ref, false);
addExplicitCandidate(resolved.ref);
}
if (params.fallbacksOverride === undefined && primary?.provider && primary.model) {
addCandidate({ provider: primary.provider, model: primary.model }, false);
addExplicitCandidate({ provider: primary.provider, model: primary.model });
}
return candidates;

View File

@@ -1,14 +1,13 @@
import { randomBytes } from "node:crypto";
import fs from "node:fs/promises";
import type { ThinkLevel } from "../../auto-reply/thinking.js";
import { resolveAgentModelFallbackValues } from "../../config/model-input.js";
import { generateSecureToken } from "../../infra/secure-random.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import type { PluginHookBeforeAgentStartResult } from "../../plugins/types.js";
import { enqueueCommandInLane } from "../../process/command-queue.js";
import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js";
import { resolveOpenClawAgentDir } from "../agent-paths.js";
import { resolveAgentModelFallbacksOverride } from "../agent-scope.js";
import { hasConfiguredModelFallbacks } from "../agent-scope.js";
import {
isProfileInCooldown,
markAuthProfileFailure,
@@ -232,15 +231,11 @@ export async function runEmbeddedPiAgent(
let provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
let modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
const agentDir = params.agentDir ?? resolveOpenClawAgentDir();
const agentFallbacksOverride =
params.config && params.agentId
? resolveAgentModelFallbacksOverride(params.config, params.agentId)
: undefined;
const fallbackConfigured =
(
agentFallbacksOverride ??
resolveAgentModelFallbackValues(params.config?.agents?.defaults?.model)
).length > 0;
const fallbackConfigured = hasConfiguredModelFallbacks({
cfg: params.config,
agentId: params.agentId,
sessionKey: params.sessionKey,
});
await ensureOpenClawModelsJson(params.config, agentDir);
// Run before_model_resolve hooks early so plugins can override the