diff --git a/CHANGELOG.md b/CHANGELOG.md index 549ea9b434f..9fe149e57a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/models: keep per-agent primary models strict when `fallbacks` is omitted, so probe-only custom providers are not tried as hidden fallback candidates unless the agent explicitly opts in. Fixes #73332. Thanks @haumanto. - Cron/Telegram: preserve explicit `:topic:` delivery targets over stale session-derived thread IDs when isolated cron announces to Telegram forum topics. Carries forward #59069; refs #49704 and #43808. Thanks @roytong9. - Build/runtime: write the runtime-postbuild stamp after `pnpm build` writes the build stamp, so the next CLI invocation does not re-sync runtime artifacts after a successful build. Fixes #73151. Thanks @bittoby. - CLI/model probes: reject empty or whitespace-only `infer model run --prompt` values before calling local providers or the Gateway, so smoke checks do not spend provider calls on invalid turns. Fixes #73185. Thanks @iot2edge. diff --git a/docs/concepts/model-failover.md b/docs/concepts/model-failover.md index d9d5bd22533..620484526ff 100644 --- a/docs/concepts/model-failover.md +++ b/docs/concepts/model-failover.md @@ -58,7 +58,8 @@ That prevents a failed fallback retry from overwriting newer unrelated session m OpenClaw separates the selected provider/model from why it was selected. That source controls whether the fallback chain is allowed: -- **Configured default**: `agents.defaults.model.primary` (or an agent-specific primary) uses the configured fallback chain. +- **Configured default**: `agents.defaults.model.primary` uses `agents.defaults.model.fallbacks`. +- **Agent primary**: `agents.list[].model` is strict unless that agent model object includes its own `fallbacks`. Use `fallbacks: []` to make the strict behavior explicit, or provide a non-empty list to opt that agent into model fallback. - **Auto fallback override**: a runtime fallback writes `providerOverride`, `modelOverride`, and `modelOverrideSource: "auto"` before retrying. That auto override can keep walking the configured fallback chain and is cleared by `/new`, `/reset`, and `sessions.reset`. - **User session override**: `/model`, the model picker, `session_status(model=...)`, and `sessions.patch` write `modelOverrideSource: "user"`. That is an exact session selection. If the selected provider/model fails before producing a reply, OpenClaw reports the failure instead of answering from an unrelated configured fallback. - **Legacy session override**: older session entries may have `modelOverride` without `modelOverrideSource`. OpenClaw treats those as user overrides so an explicit old selection is not silently converted into fallback behavior. @@ -217,7 +218,7 @@ If all profiles for a provider fail, OpenClaw moves to the next model in `agents Overloaded and rate-limit errors are handled more aggressively than billing cooldowns. By default, OpenClaw allows one same-provider auth-profile retry, then switches to the next configured model fallback without waiting. Provider-busy signals such as `ModelNotReadyException` land in that overloaded bucket. Tune this with `auth.cooldowns.overloadedProfileRotations`, `auth.cooldowns.overloadedBackoffMs`, and `auth.cooldowns.rateLimitedProfileRotations`. -When a run starts from the configured primary, a cron job primary, or an auto-selected fallback override, OpenClaw can walk the configured fallback chain. Explicit user selections (for example `/model ollama/qwen3.5:27b`, the model picker, `sessions.patch`, or one-off CLI provider/model overrides) are strict: if that provider/model is unreachable or fails before producing a reply, OpenClaw reports the failure instead of answering from an unrelated fallback. +When a run starts from the configured default primary, a cron job primary, an agent primary with explicit fallbacks, or an auto-selected fallback override, OpenClaw can walk the matching configured fallback chain. Agent primaries without explicit fallbacks and explicit user selections (for example `/model ollama/qwen3.5:27b`, the model picker, `sessions.patch`, or one-off CLI provider/model overrides) are strict: if that provider/model is unreachable or fails before producing a reply, OpenClaw reports the failure instead of answering from an unrelated fallback. ### Candidate chain rules diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 87cbfac7fea..ffd5090301d 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -969,7 +969,7 @@ for provider examples and precedence. - `id`: stable agent id (required). - `default`: when multiple are set, first wins (warning logged). If none set, first list entry is default. -- `model`: string form overrides `primary` only; object form `{ primary, fallbacks }` overrides both (`[]` disables global fallbacks). Cron jobs that only override `primary` still inherit default fallbacks unless you set `fallbacks: []`. +- `model`: string form sets a strict per-agent primary with no model fallback; object form `{ primary }` is also strict unless you add `fallbacks`. Use `{ primary, fallbacks: [...] }` to opt that agent into fallback, or `{ primary, fallbacks: [] }` to make strict behavior explicit. Cron jobs that only override `primary` still inherit default fallbacks unless you set `fallbacks: []`. - `params`: per-agent stream params merged over the selected model entry in `agents.defaults.models`. Use this for agent-specific overrides like `cacheRetention`, `temperature`, or `maxTokens` without duplicating the whole model catalog. - `tts`: optional per-agent text-to-speech overrides. The block deep-merges over `messages.tts`, so keep shared provider credentials and fallback policy in `messages.tts` and set only persona-specific values such as provider, voice, model, style, or auto mode here. - `skills`: optional per-agent skill allowlist. If omitted, the agent inherits `agents.defaults.skills` when set; an explicit list replaces defaults instead of merging, and `[]` means no skills. diff --git a/src/agents/agent-scope.test.ts b/src/agents/agent-scope.test.ts index 7914ab4da72..d46ceecefe9 100644 --- a/src/agents/agent-scope.test.ts +++ b/src/agents/agent-scope.test.ts @@ -182,7 +182,7 @@ describe("resolveAgentConfig", () => { expect(resolveAgentEffectiveModelPrimary(cfg, "linus")).toBe("anthropic/claude-sonnet-4-6"); expect(resolveAgentModelFallbacksOverride(cfg, "linus")).toEqual(["openai/gpt-5.4"]); - // If fallbacks isn't present, we don't override the global fallbacks. + // If an agent owns a primary, missing fallbacks means no model fallback. const cfgNoOverride: OpenClawConfig = { agents: { list: [ @@ -195,7 +195,55 @@ describe("resolveAgentConfig", () => { ], }, }; - expect(resolveAgentModelFallbacksOverride(cfgNoOverride, "linus")).toBe(undefined); + expect(resolveAgentModelFallbacksOverride(cfgNoOverride, "linus")).toEqual([]); + expect( + resolveEffectiveModelFallbacks({ + cfg: cfgNoOverride, + agentId: "linus", + hasSessionModelOverride: false, + }), + ).toEqual([]); + + const cfgStringModel: OpenClawConfig = { + agents: { + list: [ + { + id: "linus", + model: "anthropic/claude-sonnet-4-6", + }, + ], + }, + }; + expect(resolveAgentModelFallbacksOverride(cfgStringModel, "linus")).toEqual([]); + + const cfgStrictAgentWithDefaultFallbacks: OpenClawConfig = { + agents: { + defaults: { + model: { + fallbacks: ["custom-opencode-go-extras/deepseek-v4-flash"], + }, + }, + list: [ + { + id: "linus", + model: { + primary: "opencode-go/minimax-m2.7", + }, + }, + ], + }, + }; + expect(resolveAgentModelFallbacksOverride(cfgStrictAgentWithDefaultFallbacks, "linus")).toEqual( + [], + ); + expect( + resolveEffectiveModelFallbacks({ + cfg: cfgStrictAgentWithDefaultFallbacks, + agentId: "linus", + hasSessionModelOverride: true, + modelOverrideSource: "auto", + }), + ).toEqual([]); // Explicit empty list disables global fallbacks for that agent. const cfgDisable: OpenClawConfig = { @@ -251,26 +299,19 @@ describe("resolveAgentConfig", () => { }), ).toEqual([]); - const cfgInheritDefaults: OpenClawConfig = { + const cfgInheritDefaultsWithoutAgentModel: OpenClawConfig = { agents: { defaults: { model: { fallbacks: ["openai/gpt-5.4"], }, }, - list: [ - { - id: "linus", - model: { - primary: "anthropic/claude-sonnet-4-6", - }, - }, - ], + list: [{ id: "linus" }], }, }; expect( resolveEffectiveModelFallbacks({ - cfg: cfgInheritDefaults, + cfg: cfgInheritDefaultsWithoutAgentModel, agentId: "linus", hasSessionModelOverride: true, modelOverrideSource: "auto", diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 482da5afa9e..9c53e36601e 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -156,12 +156,15 @@ export function resolveAgentModelFallbacksOverride( agentId: string, ): string[] | undefined { const raw = resolveAgentConfig(cfg, agentId)?.model; - if (!raw || typeof raw === "string") { + if (!raw) { return undefined; } + if (typeof raw === "string") { + return resolvePrimaryStringValue(raw) ? [] : undefined; + } // Important: treat an explicitly provided empty array as an override to disable global fallbacks. if (!Object.hasOwn(raw, "fallbacks")) { - return undefined; + return Object.hasOwn(raw, "primary") && resolvePrimaryStringValue(raw) ? [] : undefined; } return Array.isArray(raw.fallbacks) ? raw.fallbacks : undefined; }