diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e2b5fae3..6d340a5be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Cron: accept ISO timestamps for one-shot schedules (UTC) and allow optional delete-after-run; wired into CLI + macOS editor. - Gateway: add Tailscale binary discovery, custom bind mode, and probe auth retry for password changes. (#740 — thanks @jeffersonwarrior) - Agents: add compaction mode config with optional safeguard summarization for long histories. (#700 — thanks @thewilloftheshadow) +- Agents: support per-agent model fallbacks via `agents.list[].model`. (#583 — thanks @mitschabaude-bot) - Tools: add tool profiles plus group shorthands for tool policy allow/deny (global, per-agent, sandbox). - Thinking: allow xhigh for GPT-5.2 + Codex models and downgrade on unsupported switches. (#444 — thanks @grp06) diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 2ec9444ce..87938edb6 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 }` (fallbacks override `agents.defaults.model.fallbacks`; `[]` disables global fallbacks for that agent) - `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..ea846eabb 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -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 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..a25c55d92 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import fs from "node:fs"; +import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js"; import { runCliAgent } from "../../agents/cli-runner.js"; import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js"; import { lookupContextTokens } from "../../agents/context.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 1ae1b0831..7b77a3f17 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -1,6 +1,6 @@ import { - resolveAgentConfig, resolveAgentDir, + resolveAgentModelPrimary, resolveDefaultAgentId, resolveSessionAgentId, } from "../../agents/agent-scope.js"; @@ -1629,7 +1629,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 1769fe976..875a9976e 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"; @@ -345,9 +347,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, }); @@ -477,6 +498,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 abc7a5ff5..ee5c261b8 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 fbb387c38..9287e49e7 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 bc22813ff..b0c651513 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"; @@ -458,6 +459,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(