Merge PR #7694: fix(task): inherit the aliased role's retry fallback chain for subagents (@enieuwy)

This commit is contained in:
can1357
2026-08-05 21:50:24 +02:00
3 changed files with 112 additions and 10 deletions
+3
View File
@@ -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:<id>` 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
+25 -10
View File
@@ -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:<id>` 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<Api> | 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<SingleRes
checkAbort();
const configuredModelPatterns = resolveConfiguredModelPatterns(modelPatterns, settings);
const defaultRetryFallbackChain =
const inheritedRetryFallbackChain =
configuredModelPatterns.length === 1
? resolveSubagentDefaultRetryFallbackChain(subagentSettings, modelRegistry)
? resolveSubagentInheritedRetryFallbackChain(subagentSettings, modelRegistry, modelPatterns)
: undefined;
const {
model,
@@ -2855,7 +2870,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
settings: subagentSettings,
id,
candidates: resolveSubagentRetryFallbackCandidates(modelPatterns, modelRegistry, subagentSettings),
defaultFallbackChain: defaultRetryFallbackChain,
inheritedFallbackChain: inheritedRetryFallbackChain,
model,
authFallbackUsed,
});
@@ -2998,7 +3013,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
modelPatternFallbackRole:
model || modelOverride === undefined ? undefined : `${SUBAGENT_RETRY_FALLBACK_ROLE_PREFIX}${id}`,
modelPatternDefaultFallbackChain:
model || modelOverride === undefined ? undefined : defaultRetryFallbackChain,
model || modelOverride === undefined ? undefined : inheritedRetryFallbackChain,
thinkingLevel: effectiveThinkingLevel,
thinkingLevelCeiling: spawnEffortCeiling,
toolNames,
@@ -166,6 +166,90 @@ describe("subagent runtime model resolution", () => {
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<string, string[]> | 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<string, string[]> | 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<string, 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<string, string[]> | 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");