Merge PR #7694: fix(task): inherit the aliased role's retry fallback chain for subagents (@enieuwy)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user