From c211fd112ca07078046d676c3e22196fc3126399 Mon Sep 17 00:00:00 2001 From: misterdas Date: Sun, 15 Feb 2026 20:55:47 +0530 Subject: [PATCH] fix(subagents): add model fallback support to sessions_spawn tool (#17197) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 5d20c2cd37ea374631bf5d8e73db4b135775eaed Co-authored-by: misterdas <170702047+misterdas@users.noreply.github.com> Co-authored-by: sebslight <19554889+sebslight@users.noreply.github.com> Reviewed-by: @sebslight --- CHANGELOG.md | 1 + src/commands/agent.e2e.test.ts | 65 ++++++++++++++++++++++++++++++++++ src/commands/agent.ts | 14 +++++--- 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d10d73822..c3bf751cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model. - Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204. - TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come. - TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane. diff --git a/src/commands/agent.e2e.test.ts b/src/commands/agent.e2e.test.ts index 914bde96f..81fcbe9c3 100644 --- a/src/commands/agent.e2e.test.ts +++ b/src/commands/agent.e2e.test.ts @@ -196,6 +196,71 @@ describe("agentCommand", () => { }); }); + it("uses default fallback list for session model overrides", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + fs.mkdirSync(path.dirname(store), { recursive: true }); + fs.writeFileSync( + store, + JSON.stringify( + { + "agent:main:subagent:test": { + sessionId: "session-subagent", + updatedAt: Date.now(), + providerOverride: "anthropic", + modelOverride: "claude-opus-4-5", + }, + }, + null, + 2, + ), + ); + + mockConfig(home, store, { + model: { + primary: "openai/gpt-4.1-mini", + fallbacks: ["openai/gpt-5.2"], + }, + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + "openai/gpt-5.2": {}, + }, + }); + + vi.mocked(loadModelCatalog).mockResolvedValueOnce([ + { id: "claude-opus-4-5", name: "Opus", provider: "anthropic" }, + { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, + { id: "gpt-5.2", name: "GPT-5.2", provider: "openai" }, + ]); + vi.mocked(runEmbeddedPiAgent) + .mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 })) + .mockResolvedValueOnce({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "session-subagent", provider: "openai", model: "gpt-5.2" }, + }, + }); + + await agentCommand( + { + message: "hi", + sessionKey: "agent:main:subagent:test", + }, + runtime, + ); + + const attempts = vi + .mocked(runEmbeddedPiAgent) + .mock.calls.map((call) => ({ provider: call[0]?.provider, model: call[0]?.model })); + expect(attempts).toEqual([ + { provider: "anthropic", model: "claude-opus-4-5" }, + { provider: "openai", model: "gpt-5.2" }, + ]); + }); + }); + it("keeps explicit sessionKey even when sessionId exists elsewhere", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index b30472566..adeaf865a 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -396,13 +396,17 @@ export async function agentCommand( opts.replyChannel ?? opts.channel, ); const spawnedBy = opts.spawnedBy ?? sessionEntry?.spawnedBy; - // When a session has an explicit model override, prevent the fallback logic - // from silently appending the global primary model as a backstop. Passing an - // empty array (instead of undefined) tells resolveFallbackCandidates to skip - // the implicit primary append, so the session stays on its overridden model. + // When a session has an explicit model override, keep the candidate chain + // anchored to that override (no implicit configured-primary append), while + // still preserving configured fallback lists unless the agent explicitly + // overrides fallbacks with its own list (including an empty list to disable). const agentFallbacksOverride = resolveAgentModelFallbacksOverride(cfg, sessionAgentId); + const defaultFallbacks = + typeof cfg.agents?.defaults?.model === "object" + ? (cfg.agents.defaults.model.fallbacks ?? []) + : []; const effectiveFallbacksOverride = storedModelOverride - ? (agentFallbacksOverride ?? []) + ? (agentFallbacksOverride ?? defaultFallbacks) : agentFallbacksOverride; // Track model fallback attempts so retries on an existing session don't