diff --git a/src/agents/agent-scope.test.ts b/src/agents/agent-scope.test.ts index f921a131576..ad4e0f56fd0 100644 --- a/src/agents/agent-scope.test.ts +++ b/src/agents/agent-scope.test.ts @@ -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: { diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 31fe49c0b76..bdc88065696 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -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["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; diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index 240668ecca2..b75eb8de4bf 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -63,7 +63,8 @@ function shouldRethrowAbort(err: unknown): boolean { function createModelCandidateCollector(allowlist: Set | null | undefined): { candidates: ModelCandidate[]; - addCandidate: (candidate: ModelCandidate, enforceAllowlist: boolean) => void; + addExplicitCandidate: (candidate: ModelCandidate) => void; + addAllowlistedCandidate: (candidate: ModelCandidate) => void; } { const seen = new Set(); const candidates: ModelCandidate[] = []; @@ -83,7 +84,14 @@ function createModelCandidateCollector(allowlist: Set | 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; diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 7a3cd76297e..06df4cb4351 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -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 diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index 1476b1f65a2..350c6b63e47 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -2,19 +2,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { FollowupRun } from "./queue.js"; const hoisted = vi.hoisted(() => { - const resolveAgentModelFallbacksOverrideMock = vi.fn(); - const resolveAgentIdFromSessionKeyMock = vi.fn(); - return { resolveAgentModelFallbacksOverrideMock, resolveAgentIdFromSessionKeyMock }; + const resolveRunModelFallbacksOverrideMock = vi.fn(); + return { resolveRunModelFallbacksOverrideMock }; }); vi.mock("../../agents/agent-scope.js", () => ({ - resolveAgentModelFallbacksOverride: (...args: unknown[]) => - hoisted.resolveAgentModelFallbacksOverrideMock(...args), -})); - -vi.mock("../../config/sessions.js", () => ({ - resolveAgentIdFromSessionKey: (...args: unknown[]) => - hoisted.resolveAgentIdFromSessionKeyMock(...args), + resolveRunModelFallbacksOverride: (...args: unknown[]) => + hoisted.resolveRunModelFallbacksOverrideMock(...args), })); const { @@ -50,22 +44,20 @@ function makeRun(overrides: Partial = {}): FollowupRun["run" describe("agent-runner-utils", () => { beforeEach(() => { - hoisted.resolveAgentModelFallbacksOverrideMock.mockClear(); - hoisted.resolveAgentIdFromSessionKeyMock.mockClear(); + hoisted.resolveRunModelFallbacksOverrideMock.mockClear(); }); it("resolves model fallback options from run context", () => { - hoisted.resolveAgentIdFromSessionKeyMock.mockReturnValue("agent-id"); - hoisted.resolveAgentModelFallbacksOverrideMock.mockReturnValue(["fallback-model"]); + hoisted.resolveRunModelFallbacksOverrideMock.mockReturnValue(["fallback-model"]); const run = makeRun(); const resolved = resolveModelFallbackOptions(run); - expect(hoisted.resolveAgentIdFromSessionKeyMock).not.toHaveBeenCalled(); - expect(hoisted.resolveAgentModelFallbacksOverrideMock).toHaveBeenCalledWith( - run.config, - run.agentId, - ); + expect(hoisted.resolveRunModelFallbacksOverrideMock).toHaveBeenCalledWith({ + cfg: run.config, + agentId: run.agentId, + sessionKey: run.sessionKey, + }); expect(resolved).toEqual({ cfg: run.config, provider: run.provider, @@ -75,18 +67,17 @@ describe("agent-runner-utils", () => { }); }); - it("falls back to sessionKey agent id when run.agentId is missing", () => { - hoisted.resolveAgentIdFromSessionKeyMock.mockReturnValue("agent-from-session-key"); - hoisted.resolveAgentModelFallbacksOverrideMock.mockReturnValue(["fallback-model"]); + it("passes through missing agentId for helper-based fallback resolution", () => { + hoisted.resolveRunModelFallbacksOverrideMock.mockReturnValue(["fallback-model"]); const run = makeRun({ agentId: undefined }); const resolved = resolveModelFallbackOptions(run); - expect(hoisted.resolveAgentIdFromSessionKeyMock).toHaveBeenCalledWith(run.sessionKey); - expect(hoisted.resolveAgentModelFallbacksOverrideMock).toHaveBeenCalledWith( - run.config, - "agent-from-session-key", - ); + expect(hoisted.resolveRunModelFallbacksOverrideMock).toHaveBeenCalledWith({ + cfg: run.config, + agentId: undefined, + sessionKey: run.sessionKey, + }); expect(resolved.fallbacksOverride).toEqual(["fallback-model"]); }); diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index 3ec5c27566b..ace68914e18 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -1,10 +1,9 @@ -import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js"; +import { resolveRunModelFallbacksOverride } from "../../agents/agent-scope.js"; import type { NormalizedUsage } from "../../agents/usage.js"; import { getChannelDock } from "../../channels/dock.js"; import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js"; import { normalizeAnyChannelId, normalizeChannelId } from "../../channels/registry.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { resolveAgentIdFromSessionKey } from "../../config/sessions.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { estimateUsageCost, formatTokenCount, formatUsd } from "../../utils/usage-format.js"; import type { TemplateContext } from "../templating.js"; @@ -147,13 +146,16 @@ export const resolveEnforceFinalTag = (run: FollowupRun["run"], provider: string Boolean(run.enforceFinalTag || isReasoningTagProvider(provider)); export function resolveModelFallbackOptions(run: FollowupRun["run"]) { - const fallbackAgentId = run.agentId ?? resolveAgentIdFromSessionKey(run.sessionKey); return { cfg: run.config, provider: run.provider, model: run.model, agentDir: run.agentDir, - fallbacksOverride: resolveAgentModelFallbacksOverride(run.config, fallbackAgentId), + fallbacksOverride: resolveRunModelFallbacksOverride({ + cfg: run.config, + agentId: run.agentId, + sessionKey: run.sessionKey, + }), }; } diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 872fc8cebb7..ba78b7abf21 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -1,10 +1,10 @@ import crypto from "node:crypto"; -import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js"; +import { resolveRunModelFallbacksOverride } 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 { resolveAgentIdFromSessionKey, type SessionEntry } from "../../config/sessions.js"; +import type { SessionEntry } from "../../config/sessions.js"; import type { TypingMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; @@ -133,10 +133,11 @@ export function createFollowupRunner(params: { provider: queued.run.provider, model: queued.run.model, agentDir: queued.run.agentDir, - fallbacksOverride: resolveAgentModelFallbacksOverride( - queued.run.config, - queued.run.agentId ?? resolveAgentIdFromSessionKey(queued.run.sessionKey), - ), + fallbacksOverride: resolveRunModelFallbacksOverride({ + cfg: queued.run.config, + agentId: queued.run.agentId, + sessionKey: queued.run.sessionKey, + }), run: (provider, model) => { const authProfile = resolveRunAuthProfile(queued.run, provider); return runEmbeddedPiAgent({