From 6dcc48538974222e7ea489cf0f99f982a831ede4 Mon Sep 17 00:00:00 2001 From: Jeff Scott Ward Date: Thu, 16 Jul 2026 17:20:08 -0400 Subject: [PATCH] fix(task): inherit default subagent fallback --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/sdk.ts | 9 + packages/coding-agent/src/task/executor.ts | 41 +++- ...sue-2750-subagent-runtime-fallback.test.ts | 189 +++++++++++++++++- .../test/sdk-model-selection.test.ts | 26 +++ 5 files changed, 263 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ad20be261..367f543ab 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed single-model task agents ignoring an explicitly configured default retry fallback chain, which left subagents failed after their selected provider became unreachable instead of advancing to the configured fallback model. + ## [17.0.3] - 2026-07-17 ### Changed diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index e7130b753..345dcfea1 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -389,6 +389,8 @@ export interface CreateAgentSessionOptions { modelPatternAuthFallback?: string; /** Role name used to install retry fallbacks after deferred subagent patterns resolve. */ modelPatternFallbackRole?: string; + /** Validated default retry chain to install when a deferred singleton pattern resolves. */ + modelPatternDefaultFallbackChain?: string[]; /** Thinking selector. Default: from settings, else unset */ thinkingLevel?: ConfiguredThinkingLevel; /** Models available for cycling (Ctrl+P in interactive mode) */ @@ -2118,6 +2120,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} seenSelectors.add(fallbackSelector); fallbackSelectors.push(fallbackSelector); } + if (fallbackSelectors.length === 0) { + for (const selector of options.modelPatternDefaultFallbackChain ?? []) { + if (typeof selector !== "string" || seenSelectors.has(selector)) continue; + seenSelectors.add(selector); + fallbackSelectors.push(selector); + } + } if (fallbackSelectors.length > 0) { const modelRoles: Record = {}; const existingRoles = settings.getModelRoles(); diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 12917c5ee..18792aa50 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -15,6 +15,7 @@ import { formatModelSelectorValue, formatModelStringWithRouting, resolveAgentPrewalkPattern, + resolveConfiguredModelPatterns, resolveModelOverride, resolveModelOverrideWithAuthFallback, } from "../config/model-resolver"; @@ -161,22 +162,44 @@ function resolveSubagentRetryFallbackCandidates( return candidates; } +function resolveSubagentDefaultRetryFallbackChain(settings: Settings): string[] | undefined { + const fallbackChain = settings.get("retry.fallbackChains")?.default; + if ( + !Array.isArray(fallbackChain) || + fallbackChain.length === 0 || + !fallbackChain.every(entry => typeof entry === "string") + ) { + return undefined; + } + return fallbackChain; +} + function installSubagentRetryFallbackChain(args: { settings: Settings; id: string; candidates: SubagentRetryFallbackCandidate[]; + defaultFallbackChain: string[] | undefined; model: Model | undefined; authFallbackUsed: boolean; }): string | undefined { - const { settings, id, candidates, model, authFallbackUsed } = args; - if (!model || authFallbackUsed || candidates.length <= 1) return undefined; + const { settings, id, candidates, defaultFallbackChain, model, authFallbackUsed } = args; + if (!model || authFallbackUsed || candidates.length === 0) return undefined; const selectedIndex = candidates.findIndex( candidate => candidate.model.provider === model.provider && candidate.model.id === model.id, ); if (selectedIndex < 0) return undefined; const fallbackSelectors = candidates.slice(selectedIndex + 1).map(candidate => candidate.selector); - if (fallbackSelectors.length === 0) return undefined; + const existingFallbackChains = settings.get("retry.fallbackChains"); + // A single explicit model may reuse a configured default chain, but never an implicit parent fallback. + const fallbackChain = fallbackSelectors.length > 0 ? fallbackSelectors : defaultFallbackChain; + if ( + !Array.isArray(fallbackChain) || + fallbackChain.length === 0 || + !fallbackChain.every(entry => typeof entry === "string") + ) { + return undefined; + } const role = `${SUBAGENT_RETRY_FALLBACK_ROLE_PREFIX}${id}`; const modelRoles: Record = {}; @@ -189,10 +212,10 @@ function installSubagentRetryFallbackChain(args: { } modelRoles[role] = candidates[selectedIndex].selector; settings.override("modelRoles", modelRoles); + // Insert the task-specific role first so another role assigned to the same model cannot capture fallback routing. const fallbackChains: Record = { - [role]: fallbackSelectors, + [role]: fallbackChain, }; - const existingFallbackChains = settings.get("retry.fallbackChains"); for (const existingRole in existingFallbackChains) { if (existingRole !== role) { fallbackChains[existingRole] = existingFallbackChains[existingRole]; @@ -2319,6 +2342,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise { expect(result.resolvedModel).toBe("fallback/working-model"); }); + it("inherits an explicitly configured default fallback chain for a single subagent model", async () => { + const primary = model("lm-studio", "local-reviewer"); + const fallback = model("openai-codex", "gpt-5.6-sol"); + let childFallbackChains: Record | undefined; + let childFallbackChainKeys: string[] = []; + let childModelRole: string | undefined; + vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async options => { + if (!options) throw new Error("Expected createAgentSession options"); + childFallbackChains = options.settings?.get("retry.fallbackChains") as Record | undefined; + childFallbackChainKeys = Object.keys(childFallbackChains ?? {}); + childModelRole = options.settings?.getModelRoles()["subagent:single-model-configured-fallback"]; + return { session: createYieldingSession(), extensionsResult: {}, setToolUIContext: () => {} } as never; + }); + + const agent: AgentDefinition = { name: "task", description: "test", systemPrompt: "test", source: "bundled" }; + await runSubprocess({ + cwd: "/tmp", + agent, + task: "work", + index: 0, + id: "single-model-configured-fallback", + modelOverride: "lm-studio/local-reviewer", + settings: Settings.isolated({ + modelRoles: { "existing-local-role": "lm-studio/local-reviewer" }, + "retry.fallbackChains": { + default: ["openai-codex/gpt-5.6-sol"], + "existing-local-role": ["other-provider/other-model"], + }, + }), + modelRegistry: { + refresh: async () => {}, + getAvailable: () => [primary, fallback], + getApiKey: async () => "test-key", + } as never, + enableLsp: false, + }); + + expect(childModelRole).toBe("lm-studio/local-reviewer"); + expect(childFallbackChainKeys[0]).toBe("subagent:single-model-configured-fallback"); + expect(childFallbackChains?.["subagent:single-model-configured-fallback"]).toEqual(["openai-codex/gpt-5.6-sol"]); + expect(childFallbackChains?.default).toEqual(["openai-codex/gpt-5.6-sol"]); + expect(childFallbackChains?.["existing-local-role"]).toEqual(["other-provider/other-model"]); + }); + + it("does not inherit the default chain when multiple requested models collapse to one candidate", async () => { + const primary = model("lm-studio", "local-reviewer"); + const fallback = model("openai-codex", "gpt-5.6-sol"); + let childFallbackChains: unknown; + let childModelRole: string | undefined; + vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async options => { + if (!options) throw new Error("Expected createAgentSession options"); + childFallbackChains = options.settings?.get("retry.fallbackChains"); + childModelRole = options.settings?.getModelRoles()["subagent:collapsed-multiple-models"]; + return { session: createYieldingSession(), extensionsResult: {}, setToolUIContext: () => {} } as never; + }); + + const settings = Settings.isolated({ + "retry.fallbackChains": { + default: ["openai-codex/gpt-5.6-sol"], + }, + }); + settings.setModelRole("default", "openai-codex/gpt-5.6-sol"); + const agent: AgentDefinition = { name: "task", description: "test", systemPrompt: "test", source: "bundled" }; + await runSubprocess({ + cwd: "/tmp", + agent, + task: "work", + index: 0, + id: "collapsed-multiple-models", + modelOverride: ["missing/provider", "lm-studio/local-reviewer"], + settings, + modelRegistry: { + refresh: async () => {}, + getAvailable: () => [primary, fallback], + getApiKey: async () => "test-key", + } as never, + enableLsp: false, + }); + + expect(childModelRole).toBeUndefined(); + expect(childFallbackChains).toEqual({ + default: ["openai-codex/gpt-5.6-sol"], + }); + }); + + it("keeps a single local subagent model pinned without a configured fallback chain", async () => { + const primary = model("lm-studio", "local-reviewer"); + const parent = model("openai-codex", "gpt-5.6-sol"); + let childModelRole: string | undefined; + vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async options => { + if (!options) throw new Error("Expected createAgentSession options"); + childModelRole = options.settings?.getModelRoles()["subagent:single-model-no-fallback"]; + return { session: createYieldingSession(), extensionsResult: {}, setToolUIContext: () => {} } as never; + }); + + const agent: AgentDefinition = { name: "task", description: "test", systemPrompt: "test", source: "bundled" }; + await runSubprocess({ + cwd: "/tmp", + agent, + task: "work", + index: 0, + id: "single-model-no-fallback", + modelOverride: "lm-studio/local-reviewer", + parentActiveModelPattern: "openai-codex/gpt-5.6-sol", + settings: Settings.isolated(), + modelRegistry: { + refresh: async () => {}, + getAvailable: () => [primary, parent], + getApiKey: async () => "test-key", + } as never, + enableLsp: false, + }); + + expect(childModelRole).toBeUndefined(); + }); + + it("preserves malformed fallback configuration for child validation", async () => { + const primary = model("lm-studio", "local-reviewer"); + let childFallbackChains: unknown; + let childModelRole: string | undefined; + vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async options => { + if (!options) throw new Error("Expected createAgentSession options"); + childFallbackChains = options.settings?.get("retry.fallbackChains"); + childModelRole = options.settings?.getModelRoles()["subagent:single-model-malformed-fallback"]; + return { session: createYieldingSession(), extensionsResult: {}, setToolUIContext: () => {} } as never; + }); + + const agent: AgentDefinition = { name: "task", description: "test", systemPrompt: "test", source: "bundled" }; + await runSubprocess({ + cwd: "/tmp", + agent, + task: "work", + index: 0, + id: "single-model-malformed-fallback", + modelOverride: "lm-studio/local-reviewer", + settings: Settings.isolated({ "retry.fallbackChains": null as never }), + modelRegistry: { + refresh: async () => {}, + getAvailable: () => [primary], + getApiKey: async () => "test-key", + } as never, + enableLsp: false, + }); + + expect(childFallbackChains).toBeNull(); + expect(childModelRole).toBeUndefined(); + }); + + it("leaves malformed default fallback entries for child validation", async () => { + const primary = model("lm-studio", "local-reviewer"); + let childFallbackChains: unknown; + let childModelRole: string | undefined; + vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async options => { + if (!options) throw new Error("Expected createAgentSession options"); + childFallbackChains = options.settings?.get("retry.fallbackChains"); + childModelRole = options.settings?.getModelRoles()["subagent:single-model-invalid-default-fallback"]; + return { session: createYieldingSession(), extensionsResult: {}, setToolUIContext: () => {} } as never; + }); + + const agent: AgentDefinition = { name: "task", description: "test", systemPrompt: "test", source: "bundled" }; + await runSubprocess({ + cwd: "/tmp", + agent, + task: "work", + index: 0, + id: "single-model-invalid-default-fallback", + modelOverride: "lm-studio/local-reviewer", + settings: Settings.isolated({ "retry.fallbackChains": { default: [123] } as never }), + modelRegistry: { + refresh: async () => {}, + getAvailable: () => [primary], + getApiKey: async () => "test-key", + } as never, + enableLsp: false, + }); + + expect(childFallbackChains).toEqual({ default: [123] }); + expect(childModelRole).toBeUndefined(); + }); + it("preserves upstream routing selectors in the child retry fallback chain", async () => { const routedModel = model("openrouter", "z-ai/glm-4.7"); let childFallbackChains: Record | undefined; @@ -156,12 +336,14 @@ describe("subagent runtime model resolution", () => { let childModelPattern: unknown; let childModelPatternAuthFallback: unknown; let childModelPatternFallbackRole: unknown; + let childModelPatternDefaultFallbackChain: unknown; vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async options => { if (!options) throw new Error("Expected createAgentSession options"); childModel = options.model; childModelPattern = options.modelPattern; childModelPatternAuthFallback = options.modelPatternAuthFallback; childModelPatternFallbackRole = options.modelPatternFallbackRole; + childModelPatternDefaultFallbackChain = options.modelPatternDefaultFallbackChain; return { session: createYieldingSession(), extensionsResult: {}, setToolUIContext: () => {} } as never; }); @@ -174,7 +356,11 @@ describe("subagent runtime model resolution", () => { id: "issue-4421", modelOverride: ["openai-codex/gpt-5.5:auto"], parentActiveModelPattern: "openai-codex/gpt-5.5", - settings: Settings.isolated(), + settings: Settings.isolated({ + "retry.fallbackChains": { + default: ["openai-codex/gpt-5.6-sol"], + }, + }), modelRegistry: { refresh: async () => {}, getAvailable: () => [defaultModel], @@ -187,5 +373,6 @@ describe("subagent runtime model resolution", () => { expect(childModelPattern).toEqual(["openai-codex/gpt-5.5:auto"]); expect(childModelPatternAuthFallback).toBe("openai-codex/gpt-5.5"); expect(childModelPatternFallbackRole).toBe("subagent:issue-4421"); + expect(childModelPatternDefaultFallbackChain).toEqual(["openai-codex/gpt-5.6-sol"]); }); }); diff --git a/packages/coding-agent/test/sdk-model-selection.test.ts b/packages/coding-agent/test/sdk-model-selection.test.ts index 278ef70b0..b1c5a6d0a 100644 --- a/packages/coding-agent/test/sdk-model-selection.test.ts +++ b/packages/coding-agent/test/sdk-model-selection.test.ts @@ -244,6 +244,32 @@ describe("createAgentSession deferred model pattern resolution", () => { } }); + test("installs an inherited fallback chain for a deferred singleton modelPattern", async () => { + const settings = Settings.isolated({ + "retry.fallbackChains": { + default: ["runtime-provider/runtime-reasoning-model"], + }, + }); + settings.setModelRole("default", "runtime-provider/runtime-reasoning-model"); + const { session } = await createAgentSession({ + ...(await buildSessionOptions("runtime-provider/runtime-model")), + settings, + modelPatternFallbackRole: "subagent:deferred-default", + modelPatternDefaultFallbackChain: ["runtime-provider/runtime-reasoning-model"], + }); + + try { + expect(session.model?.provider).toBe("runtime-provider"); + expect(session.model?.id).toBe("runtime-model"); + expect(session.settings.getModelRole("subagent:deferred-default")).toBe("runtime-provider/runtime-model"); + expect(session.settings.get("retry.fallbackChains")["subagent:deferred-default"]).toEqual([ + "runtime-provider/runtime-reasoning-model", + ]); + } finally { + await session.dispose(); + } + }); + test("splits deferred comma-delimited modelPattern and installs fallback chain", async () => { const { session } = await createAgentSession({ ...(await buildSessionOptions("runtime-provider/runtime-model,runtime-provider/runtime-reasoning-model")),