diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 487aee24f..d142a7b61 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -28,6 +28,9 @@ ### Fixed - Fixed Python eval shell helpers (`!cmd`, `%%bash`, `%pip`) letting child processes inherit the runner's stdin — the host's NDJSON control channel — which could steal protocol frames and deadlocked nested interpreters on Windows; children now get `stdin=DEVNULL`. `%%bash` also resolves Git Bash on Windows instead of hardcoding `/bin/bash`. +### 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 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");