From b279f068497c9ae839fba993d1de0c329649907a Mon Sep 17 00:00:00 2001 From: enieuwy <121954036+enieuwy@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:13:42 +0800 Subject: [PATCH] fix(task): inherit the aliased role's retry fallback chain for subagents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single-model subagent has no fallbacks of its own, so it inherits one and is pinned to a `subagent:` role. That pin is inserted first in `retry.fallbackChains` so no other role can capture its routing — which also means it shadows every configured role chain at runtime. Inheritance was hardcoded to `chains.default`, so a subagent spawned through a role alias (the bundled scout's `model: "@smol"`) retried on the default role's chain instead of its own. With `smol` chained to composer/grok/luna and `default` chained to gpt-5.6-sol, every scout fell back onto sol. Resolve the inherited chain from the role identity still present in the raw pattern (`@smol` -> `smol`), falling back to `default` when that role configures no chain. An explicitly empty role chain still means "no fallbacks", mirroring `expandDefaultRetryFallbackChains`. Explicit model selectors keep inheriting `default`: they carry no role identity, and a role assigned the same model must not capture the child's routing. --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/task/executor.ts | 35 +++++--- ...sue-2750-subagent-runtime-fallback.test.ts | 84 +++++++++++++++++++ 3 files changed, 113 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7ba445a2b..0a38ce9b0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed subagents spawned through a model-role alias (e.g. the bundled `scout`'s `model: "@smol"`) falling back onto the `default` role's `retry.fallbackChains` entry instead of their own role's chain: the child is pinned to a `subagent:` role whose chain shadows every configured role chain, and that pin inherited `default` unconditionally, so a `@smol` scout retried on the default chain's first model instead of the smol chain's. + ## [17.2.9] - 2026-08-05 ### Breaking Changes diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index c4db555f3..478d42510 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -185,11 +185,26 @@ function resolveSubagentRetryFallbackCandidates( return candidates; } -function resolveSubagentDefaultRetryFallbackChain( +/** + * Chain a single-model subagent inherits when its own model patterns supply no + * fallbacks of their own. The child is pinned to a `subagent:` role whose + * chain shadows every configured role chain (see + * {@link installSubagentRetryFallbackChain}), so a role-alias request (`@smol`) + * MUST inherit that role's chain — otherwise the pin silently re-routes the + * child onto the `default` role's chain. Explicit model selectors keep + * inheriting `default`: they carry no role identity, and a role that happens to + * be assigned the same model must not capture the child's fallback routing. + */ +function resolveSubagentInheritedRetryFallbackChain( settings: Settings, modelRegistry: ModelRegistry, + modelPatterns: string[], ): string[] | undefined { - const fallbackChain = settings.get("retry.fallbackChains")?.default; + const configuredChains = settings.get("retry.fallbackChains"); + const role = resolveExplicitModelRole(modelPatterns, settings); + // An explicitly emptied role chain means "no fallbacks", not "inherit + // default" — mirrors expandDefaultRetryFallbackChains. + const fallbackChain = (role !== undefined ? configuredChains?.[role] : undefined) ?? configuredChains?.default; if ( !Array.isArray(fallbackChain) || fallbackChain.length === 0 || @@ -208,11 +223,11 @@ function installSubagentRetryFallbackChain(args: { settings: Settings; id: string; candidates: SubagentRetryFallbackCandidate[]; - defaultFallbackChain: string[] | undefined; + inheritedFallbackChain: string[] | undefined; model: Model | undefined; authFallbackUsed: boolean; }): string | undefined { - const { settings, id, candidates, defaultFallbackChain, model, authFallbackUsed } = args; + const { settings, id, candidates, inheritedFallbackChain, model, authFallbackUsed } = args; if (!model || authFallbackUsed || candidates.length === 0) return undefined; const selectedIndex = candidates.findIndex( @@ -221,8 +236,8 @@ function installSubagentRetryFallbackChain(args: { if (selectedIndex < 0) return undefined; const fallbackSelectors = candidates.slice(selectedIndex + 1).map(candidate => candidate.selector); 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; + // A single configured model may reuse its role's (or the default) configured chain, but never an implicit parent fallback. + const fallbackChain = fallbackSelectors.length > 0 ? fallbackSelectors : inheritedFallbackChain; if ( !Array.isArray(fallbackChain) || fallbackChain.length === 0 || @@ -2818,9 +2833,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise { expect(childFallbackChains?.["existing-local-role"]).toEqual(["other-provider/other-model"]); }); + it("inherits the aliased role's chain, not the default chain, for a role-alias subagent model", async () => { + const fast = model("fast", "hy3"); + const slow = model("slow", "opus"); + let childFallbackChains: Record | undefined; + 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; + childModelRole = options.settings?.getModelRoles()["subagent:role-alias-chain"]; + return { session: createYieldingSession(), extensionsResult: {}, setToolUIContext: () => {} } as never; + }); + + // Mirrors the bundled scout agent (`model: "@smol"`). + const agent: AgentDefinition = { + name: "scout", + description: "test", + systemPrompt: "test", + source: "bundled", + model: ["@smol"], + }; + await runSubprocess({ + cwd: "/tmp", + agent, + task: "work", + index: 0, + id: "role-alias-chain", + settings: Settings.isolated({ + modelRoles: { default: "slow/opus", smol: "fast/hy3" }, + "retry.fallbackChains": { + default: ["slow/opus-backup"], + smol: ["fast/composer"], + }, + }), + modelRegistry: { + refresh: async () => {}, + getAvailable: () => [fast, slow], + getApiKey: async () => "test-key", + } as never, + enableLsp: false, + }); + + expect(childModelRole).toBe("fast/hy3"); + expect(childFallbackChains?.["subagent:role-alias-chain"]).toEqual(["fast/composer"]); + expect(childFallbackChains?.default).toEqual(["slow/opus-backup"]); + }); + + it("inherits the default chain for a role alias whose role configures no chain", async () => { + const fast = model("fast", "hy3"); + const slow = model("slow", "opus"); + let childFallbackChains: Record | 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; + return { session: createYieldingSession(), extensionsResult: {}, setToolUIContext: () => {} } as never; + }); + + const agent: AgentDefinition = { + name: "scout", + description: "test", + systemPrompt: "test", + source: "bundled", + model: ["@smol"], + }; + await runSubprocess({ + cwd: "/tmp", + agent, + task: "work", + index: 0, + id: "role-alias-default-chain", + settings: Settings.isolated({ + modelRoles: { default: "slow/opus", smol: "fast/hy3" }, + "retry.fallbackChains": { default: ["slow/opus-backup"] }, + }), + modelRegistry: { + refresh: async () => {}, + getAvailable: () => [fast, slow], + getApiKey: async () => "test-key", + } as never, + enableLsp: false, + }); + + expect(childFallbackChains?.["subagent:role-alias-default-chain"]).toEqual(["slow/opus-backup"]); + }); + 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");