Merge pull request #583 from mitschabaude-bot/feat/agent-model-fallbacks

Config: per-agent model fallbacks
This commit is contained in:
Peter Steinberger
2026-01-13 06:54:00 +00:00
committed by GitHub
15 changed files with 292 additions and 15 deletions

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveAgentConfig } from "./agent-scope.js";
import {
resolveAgentConfig,
resolveAgentModelFallbacksOverride,
resolveAgentModelPrimary,
} from "./agent-scope.js";
describe("resolveAgentConfig", () => {
it("should return undefined when no agents config exists", () => {
@@ -47,6 +51,68 @@ describe("resolveAgentConfig", () => {
});
});
it("supports per-agent model primary+fallbacks", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
model: {
primary: "anthropic/claude-sonnet-4",
fallbacks: ["openai/gpt-4.1"],
},
},
list: [
{
id: "linus",
model: {
primary: "anthropic/claude-opus-4",
fallbacks: ["openai/gpt-5.2"],
},
},
],
},
};
expect(resolveAgentModelPrimary(cfg, "linus")).toBe(
"anthropic/claude-opus-4",
);
expect(resolveAgentModelFallbacksOverride(cfg, "linus")).toEqual([
"openai/gpt-5.2",
]);
// If fallbacks isn't present, we don't override the global fallbacks.
const cfgNoOverride: ClawdbotConfig = {
agents: {
list: [
{
id: "linus",
model: {
primary: "anthropic/claude-opus-4",
},
},
],
},
};
expect(resolveAgentModelFallbacksOverride(cfgNoOverride, "linus")).toBe(
undefined,
);
// Explicit empty list disables global fallbacks for that agent.
const cfgDisable: ClawdbotConfig = {
agents: {
list: [
{
id: "linus",
model: {
primary: "anthropic/claude-opus-4",
fallbacks: [],
},
},
],
},
};
expect(resolveAgentModelFallbacksOverride(cfgDisable, "linus")).toEqual([]);
});
it("should return agent-specific sandbox config", () => {
const cfg: ClawdbotConfig = {
agents: {

View File

@@ -21,7 +21,7 @@ type ResolvedAgentConfig = {
name?: string;
workspace?: string;
agentDir?: string;
model?: string;
model?: AgentEntry["model"];
memorySearch?: AgentEntry["memorySearch"];
humanDelay?: AgentEntry["humanDelay"];
identity?: AgentEntry["identity"];
@@ -95,7 +95,11 @@ export function resolveAgentConfig(
workspace:
typeof entry.workspace === "string" ? entry.workspace : undefined,
agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined,
model: typeof entry.model === "string" ? entry.model : undefined,
model:
typeof entry.model === "string" ||
(entry.model && typeof entry.model === "object")
? entry.model
: undefined,
memorySearch: entry.memorySearch,
humanDelay: entry.humanDelay,
identity: entry.identity,
@@ -109,6 +113,28 @@ export function resolveAgentConfig(
};
}
export function resolveAgentModelPrimary(
cfg: ClawdbotConfig,
agentId: string,
): string | undefined {
const raw = resolveAgentConfig(cfg, agentId)?.model;
if (!raw) return undefined;
if (typeof raw === "string") return raw.trim() || undefined;
const primary = raw.primary?.trim();
return primary || undefined;
}
export function resolveAgentModelFallbacksOverride(
cfg: ClawdbotConfig,
agentId: string,
): string[] | undefined {
const raw = resolveAgentConfig(cfg, agentId)?.model;
if (!raw || typeof raw === "string") return undefined;
// Important: treat an explicitly provided empty array as an override to disable global fallbacks.
if (!Object.hasOwn(raw, "fallbacks")) return undefined;
return Array.isArray(raw.fallbacks) ? raw.fallbacks : undefined;
}
export function resolveAgentWorkspaceDir(cfg: ClawdbotConfig, agentId: string) {
const id = normalizeAgentId(agentId);
const configured = resolveAgentConfig(cfg, id)?.workspace?.trim();

View File

@@ -124,6 +124,106 @@ describe("runWithModelFallback", () => {
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("does not append configured primary when fallbacksOverride is set", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
},
},
},
});
const run = vi
.fn()
.mockImplementation(() =>
Promise.reject(Object.assign(new Error("nope"), { status: 401 })),
);
await expect(
runWithModelFallback({
cfg,
provider: "anthropic",
model: "claude-opus-4-5",
fallbacksOverride: ["anthropic/claude-haiku-3-5"],
run,
}),
).rejects.toThrow("All models failed");
expect(run.mock.calls).toEqual([
["anthropic", "claude-opus-4-5"],
["anthropic", "claude-haiku-3-5"],
]);
});
it("uses fallbacksOverride instead of agents.defaults.model.fallbacks", async () => {
const cfg = {
agents: {
defaults: {
model: {
fallbacks: ["openai/gpt-5.2"],
},
},
},
} as ClawdbotConfig;
const calls: Array<{ provider: string; model: string }> = [];
const res = await runWithModelFallback({
cfg,
provider: "anthropic",
model: "claude-opus-4-5",
fallbacksOverride: ["openai/gpt-4.1"],
run: async (provider, model) => {
calls.push({ provider, model });
if (provider === "anthropic") {
throw Object.assign(new Error("nope"), { status: 401 });
}
if (provider === "openai" && model === "gpt-4.1") {
return "ok";
}
throw new Error(`unexpected candidate: ${provider}/${model}`);
},
});
expect(res.result).toBe("ok");
expect(calls).toEqual([
{ provider: "anthropic", model: "claude-opus-4-5" },
{ provider: "openai", model: "gpt-4.1" },
]);
});
it("treats an empty fallbacksOverride as disabling global fallbacks", async () => {
const cfg = {
agents: {
defaults: {
model: {
fallbacks: ["openai/gpt-5.2"],
},
},
},
} as ClawdbotConfig;
const calls: Array<{ provider: string; model: string }> = [];
await expect(
runWithModelFallback({
cfg,
provider: "anthropic",
model: "claude-opus-4-5",
fallbacksOverride: [],
run: async (provider, model) => {
calls.push({ provider, model });
throw new Error("primary failed");
},
}),
).rejects.toThrow("primary failed");
expect(calls).toEqual([
{ provider: "anthropic", model: "claude-opus-4-5" },
]);
});
it("falls back on missing API key errors", async () => {
const cfg = makeCfg();
const run = vi

View File

@@ -126,6 +126,8 @@ function resolveFallbackCandidates(params: {
cfg: ClawdbotConfig | undefined;
provider: string;
model: string;
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
fallbacksOverride?: string[];
}): ModelCandidate[] {
const provider = params.provider.trim() || DEFAULT_PROVIDER;
const model = params.model.trim() || DEFAULT_MODEL;
@@ -159,6 +161,7 @@ function resolveFallbackCandidates(params: {
addCandidate({ provider, model }, false);
const modelFallbacks = (() => {
if (params.fallbacksOverride !== undefined) return params.fallbacksOverride;
const model = params.cfg?.agents?.defaults?.model as
| { fallbacks?: string[] }
| string
@@ -177,7 +180,11 @@ function resolveFallbackCandidates(params: {
addCandidate(resolved.ref, true);
}
if (primary?.provider && primary.model) {
if (
params.fallbacksOverride === undefined &&
primary?.provider &&
primary.model
) {
addCandidate({ provider: primary.provider, model: primary.model }, false);
}
@@ -188,6 +195,8 @@ export async function runWithModelFallback<T>(params: {
cfg: ClawdbotConfig | undefined;
provider: string;
model: string;
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
fallbacksOverride?: string[];
run: (provider: string, model: string) => Promise<T>;
onError?: (attempt: {
provider: string;
@@ -202,7 +211,12 @@ export async function runWithModelFallback<T>(params: {
model: string;
attempts: FallbackAttempt[];
}> {
const candidates = resolveFallbackCandidates(params);
const candidates = resolveFallbackCandidates({
cfg: params.cfg,
provider: params.provider,
model: params.model,
fallbacksOverride: params.fallbacksOverride,
});
const attempts: FallbackAttempt[] = [];
let lastError: unknown;