From 6729637f61839176a0cb465e79b96f7c0c7dbba7 Mon Sep 17 00:00:00 2001 From: Gregor's Bot Date: Fri, 9 Jan 2026 14:59:02 +0100 Subject: [PATCH] Config: support per-agent model fallbacks --- docs/gateway/configuration.md | 4 +- src/agents/agent-scope.test.ts | 68 +++++++++++++++++++++- src/agents/agent-scope.ts | 30 +++++++++- src/agents/model-fallback.test.ts | 32 ++++++++++ src/agents/model-fallback.ts | 18 +++++- src/auto-reply/reply/agent-runner.ts | 9 +++ src/auto-reply/reply/directive-handling.ts | 4 +- src/auto-reply/reply/followup-runner.ts | 6 ++ src/commands/agent.ts | 27 ++++++++- src/commands/agents.ts | 10 +++- src/commands/auth-choice.ts | 4 +- src/config/types.ts | 11 +++- src/config/zod-schema.ts | 10 +++- src/cron/isolated-agent.ts | 5 ++ 14 files changed, 224 insertions(+), 14 deletions(-) diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 2ec9444ce..ffcbf3f77 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -569,7 +569,9 @@ Inbound messages are routed to an agent via bindings. - `name`: display name for the agent. - `workspace`: default `~/clawd-` (for `main`, falls back to `agents.defaults.workspace`). - `agentDir`: default `~/.clawdbot/agents//agent`. - - `model`: per-agent default model (provider/model), overrides `agents.defaults.model` for that agent. + - `model`: per-agent default model, overrides `agents.defaults.model` for that agent. + - string form: `"provider/model"`, overrides only `agents.defaults.model.primary` + - object form: `{ primary, fallbacks }` overrides fallbacks as well - `identity`: per-agent name/theme/emoji (used for mention patterns + ack reactions). - `groupChat`: per-agent mention-gating (`mentionPatterns`). - `sandbox`: per-agent sandbox config (overrides `agents.defaults.sandbox`). diff --git a/src/agents/agent-scope.test.ts b/src/agents/agent-scope.test.ts index 069b1c67a..585c7bca9 100644 --- a/src/agents/agent-scope.test.ts +++ b/src/agents/agent-scope.test.ts @@ -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: { diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 4e9d9f2b2..fa32a5fd1 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -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(); diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 17a8835c7..097044754 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -124,6 +124,38 @@ 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("falls back on missing API key errors", async () => { const cfg = makeCfg(); const run = vi diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index ed8188842..b61324388 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -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(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; onError?: (attempt: { provider: string; @@ -202,7 +211,12 @@ export async function runWithModelFallback(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; diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 018adeeb0..771b111c9 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -2,6 +2,7 @@ import crypto from "node:crypto"; import fs from "node:fs"; import { runCliAgent } from "../../agents/cli-runner.js"; import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js"; +import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js"; import { lookupContextTokens } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { resolveModelAuthMode } from "../../agents/model-auth.js"; @@ -394,6 +395,10 @@ export async function runReplyAgent(params: { cfg: followupRun.run.config, provider: followupRun.run.provider, model: followupRun.run.model, + fallbacksOverride: resolveAgentModelFallbacksOverride( + followupRun.run.config, + resolveAgentIdFromSessionKey(followupRun.run.sessionKey), + ), run: (provider, model) => runEmbeddedPiAgent({ sessionId: followupRun.run.sessionId, @@ -586,6 +591,10 @@ export async function runReplyAgent(params: { cfg: followupRun.run.config, provider: followupRun.run.provider, model: followupRun.run.model, + fallbacksOverride: resolveAgentModelFallbacksOverride( + followupRun.run.config, + resolveAgentIdFromSessionKey(followupRun.run.sessionKey), + ), run: (provider, model) => { if (isCliProvider(provider, followupRun.run.config)) { const startedAt = Date.now(); diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index 192fabdd4..9b1748324 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -1,8 +1,10 @@ +import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js"; import { resolveAgentConfig, resolveAgentDir, resolveDefaultAgentId, resolveSessionAgentId, + resolveAgentModelPrimary, } from "../../agents/agent-scope.js"; import { isProfileInCooldown, @@ -1593,7 +1595,7 @@ export function resolveDefaultModel(params: { aliasIndex: ModelAliasIndex; } { const agentModelOverride = params.agentId - ? resolveAgentConfig(params.cfg, params.agentId)?.model?.trim() + ? resolveAgentModelPrimary(params.cfg, params.agentId) : undefined; const cfg = agentModelOverride && agentModelOverride.length > 0 diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index d5a240e32..e0ae80b8b 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -1,10 +1,12 @@ import crypto from "node:crypto"; +import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js"; import { lookupContextTokens } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { runWithModelFallback } from "../../agents/model-fallback.js"; import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; import { hasNonzeroUsage } from "../../agents/usage.js"; import { + resolveAgentIdFromSessionKey, type SessionEntry, updateSessionStoreEntry, } from "../../config/sessions.js"; @@ -136,6 +138,10 @@ export function createFollowupRunner(params: { cfg: queued.run.config, provider: queued.run.provider, model: queued.run.model, + fallbacksOverride: resolveAgentModelFallbacksOverride( + queued.run.config, + resolveAgentIdFromSessionKey(queued.run.sessionKey), + ), run: (provider, model) => runEmbeddedPiAgent({ sessionId: queued.run.sessionId, diff --git a/src/commands/agent.ts b/src/commands/agent.ts index d7fc58897..f08483a1d 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -1,6 +1,8 @@ import crypto from "node:crypto"; import { resolveAgentDir, + resolveAgentModelFallbacksOverride, + resolveAgentModelPrimary, resolveAgentWorkspaceDir, } from "../agents/agent-scope.js"; import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; @@ -333,9 +335,28 @@ export async function agentCommand( await saveSessionStore(storePath, sessionStore); } + const agentModelPrimary = resolveAgentModelPrimary(cfg, sessionAgentId); + const cfgForModelSelection = agentModelPrimary + ? { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + model: { + ...(typeof cfg.agents?.defaults?.model === "object" + ? cfg.agents.defaults.model + : undefined), + primary: agentModelPrimary, + }, + }, + }, + } + : cfg; + const { provider: defaultProvider, model: defaultModel } = resolveConfiguredModelRef({ - cfg, + cfg: cfgForModelSelection, defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, }); @@ -442,6 +463,10 @@ export async function agentCommand( cfg, provider, model, + fallbacksOverride: resolveAgentModelFallbacksOverride( + cfg, + sessionAgentId, + ), run: (providerOverride, modelOverride) => { if (isCliProvider(providerOverride, cfg)) { const cliSessionId = getCliSessionId(sessionEntry, providerOverride); diff --git a/src/commands/agents.ts b/src/commands/agents.ts index cc6d0eebc..0c33ac6a3 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -142,7 +142,15 @@ function resolveAgentModel(cfg: ClawdbotConfig, agentId: string) { const entry = listAgentEntries(cfg).find( (agent) => normalizeAgentId(agent.id) === normalizeAgentId(agentId), ); - if (entry?.model?.trim()) return entry.model.trim(); + if (entry?.model) { + if (typeof entry.model === "string" && entry.model.trim()) { + return entry.model.trim(); + } + if (typeof entry.model === "object") { + const primary = entry.model.primary?.trim(); + if (primary) return primary; + } + } const raw = cfg.agents?.defaults?.model; if (typeof raw === "string") return raw; return raw?.primary?.trim() || undefined; diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index 4bb6cce48..c1edddde9 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -1,5 +1,5 @@ import { loginOpenAICodex, type OAuthCredentials } from "@mariozechner/pi-ai"; -import { resolveAgentConfig } from "../agents/agent-scope.js"; +import { resolveAgentModelPrimary } from "../agents/agent-scope.js"; import { CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID, @@ -152,7 +152,7 @@ export async function warnIfModelConfigLooksOff( options?: { agentId?: string; agentDir?: string }, ) { const agentModelOverride = options?.agentId - ? resolveAgentConfig(config, options.agentId)?.model?.trim() + ? resolveAgentModelPrimary(config, options.agentId) : undefined; const configWithModel = agentModelOverride && agentModelOverride.length > 0 diff --git a/src/config/types.ts b/src/config/types.ts index 9838c8ffd..99b580df0 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1127,13 +1127,22 @@ export type ToolsConfig = { }; }; +export type AgentModelConfig = + | string + | { + /** Primary model (provider/model). */ + primary?: string; + /** Per-agent model fallbacks (provider/model). */ + fallbacks?: string[]; + }; + export type AgentConfig = { id: string; default?: boolean; name?: string; workspace?: string; agentDir?: string; - model?: string; + model?: AgentModelConfig; memorySearch?: MemorySearchConfig; /** Human-like delay between block replies for this agent. */ humanDelay?: HumanDelayConfig; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 75921029f..a1122bcd3 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -940,14 +940,20 @@ const MemorySearchSchema = z .optional(), }) .optional(); - +const AgentModelSchema = z.union([ + z.string(), + z.object({ + primary: z.string().optional(), + fallbacks: z.array(z.string()).optional(), + }), +]); const AgentEntrySchema = z.object({ id: z.string(), default: z.boolean().optional(), name: z.string().optional(), workspace: z.string().optional(), agentDir: z.string().optional(), - model: z.string().optional(), + model: AgentModelSchema.optional(), memorySearch: MemorySearchSchema, humanDelay: HumanDelaySchema.optional(), identity: IdentitySchema, diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index bbc164945..dcda99123 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import { resolveAgentConfig, + resolveAgentModelFallbacksOverride, resolveAgentWorkspaceDir, resolveDefaultAgentId, } from "../agents/agent-scope.js"; @@ -449,6 +450,10 @@ export async function runCronIsolatedAgentTurn(params: { cfg: cfgWithAgentDefaults, provider, model, + fallbacksOverride: resolveAgentModelFallbacksOverride( + params.cfg, + agentId, + ), run: (providerOverride, modelOverride) => { if (isCliProvider(providerOverride, cfgWithAgentDefaults)) { const cliSessionId = getCliSessionId(