From 5d20c2cd37ea374631bf5d8e73db4b135775eaed Mon Sep 17 00:00:00 2001 From: Sebastian <19554889+sebslight@users.noreply.github.com> Date: Sun, 15 Feb 2026 09:34:20 -0500 Subject: [PATCH] fix(subagents): preserve default fallbacks on model overrides --- CHANGELOG.md | 1 + src/agents/tools/sessions-spawn-tool.ts | 61 ++--------------------- src/commands/agent.e2e.test.ts | 65 +++++++++++++++++++++++++ src/commands/agent.ts | 14 ++++-- 4 files changed, 79 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d10d738226a..c3bf751cfbd 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/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index d40a9591e10..11486c025e3 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -7,10 +7,9 @@ import { loadConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; import { normalizeDeliveryContext } from "../../utils/delivery-context.js"; -import { resolveAgentConfig, resolveAgentModelFallbacksOverride } from "../agent-scope.js"; +import { resolveAgentConfig } from "../agent-scope.js"; import { AGENT_LANE_SUBAGENT } from "../lanes.js"; import { resolveDefaultModelForAgent } from "../model-selection.js"; -import { runWithModelFallback } from "../model-fallback.js"; import { optionalStringEnum } from "../schema/typebox.js"; import { buildSubagentSystemPrompt } from "../subagent-announce.js"; import { getSubagentDepthFromSessionStore } from "../subagent-depth.js"; @@ -268,23 +267,9 @@ export function createSessionsSpawnTool(opts?: { maxSpawnDepth, }); - // Get fallbacks for this agent - const fallbacks = resolveAgentModelFallbacksOverride(cfg, targetAgentId); - - // Generate idempotency key upfront const childIdem = crypto.randomUUID(); let childRunId: string = childIdem; - - // Build the run function that will be executed with fallback support - const runSubagentWithModel = async (modelProvider: string, modelName: string) => { - // Patch the session with the current model - await callGateway({ - method: "sessions.patch", - params: { key: childSessionKey, model: `${modelProvider}/${modelName}` }, - timeoutMs: 10_000, - }); - - // Start the agent + try { const response = await callGateway<{ runId: string }>({ method: "agent", params: { @@ -309,47 +294,9 @@ export function createSessionsSpawnTool(opts?: { }, timeoutMs: 10_000, }); - - return response; - }; - - // Extract provider and model from resolvedModel - const { provider: primaryProvider, model: primaryModel } = splitModelRef(resolvedModel); - - try { - // Run with fallback support - const fallbackResult = await runWithModelFallback({ - cfg, - provider: primaryProvider ?? "unknown", - model: primaryModel ?? "default", - agentDir: undefined, - fallbacksOverride: fallbacks, - run: async (provider, model) => { - // Patch session depth first - await callGateway({ - method: "sessions.patch", - params: { key: childSessionKey, spawnDepth: childDepth }, - timeoutMs: 10_000, - }); - // Patch thinking if set - if (thinkingOverride !== undefined) { - await callGateway({ - method: "sessions.patch", - params: { - key: childSessionKey, - thinkingLevel: thinkingOverride === "off" ? null : thinkingOverride, - }, - timeoutMs: 10_000, - }); - } - return runSubagentWithModel(provider, model); - }, - }); - - if (typeof fallbackResult.result?.runId === "string" && fallbackResult.result.runId) { - childRunId = fallbackResult.result.runId; + if (typeof response?.runId === "string" && response.runId) { + childRunId = response.runId; } - modelApplied = true; } catch (err) { const messageText = err instanceof Error ? err.message : typeof err === "string" ? err : "error"; diff --git a/src/commands/agent.e2e.test.ts b/src/commands/agent.e2e.test.ts index 914bde96fcd..81fcbe9c33f 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 b3047256610..adeaf865ad9 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