Unify dynamic fallback chains for background subagents

This commit is contained in:
Ravi Tharuma
2026-03-04 20:43:55 +01:00
committed by YeonGyu-Kim
parent 38c925697b
commit 86c6bc7716
13 changed files with 388 additions and 37 deletions
@@ -75,4 +75,34 @@ describe("resolveCategoryExecution", () => {
expect(result.error).toContain("Unknown category")
expect(result.error).toContain("definitely-not-a-real-category-xyz123")
})
test("uses category fallback_models for background/runtime fallback chain", async () => {
//#given
const args = {
category: "deep",
prompt: "test prompt",
description: "Test task",
run_in_background: false,
load_skills: [],
blockedBy: undefined,
enableSkillTools: false,
}
const executorCtx = createMockExecutorContext()
executorCtx.userCategories = {
deep: {
model: "quotio/claude-opus-4-6",
fallback_models: ["quotio/kimi-k2.5", "openai/gpt-5.2(high)"],
},
}
//#when
const result = await resolveCategoryExecution(args, executorCtx, undefined, "anthropic/claude-sonnet-4-6")
//#then
expect(result.error).toBeUndefined()
expect(result.fallbackChain).toEqual([
{ providers: ["quotio"], model: "kimi-k2.5", variant: undefined },
{ providers: ["openai"], model: "gpt-5.2", variant: "high" },
])
})
})
+13 -1
View File
@@ -7,6 +7,8 @@ import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
import { resolveCategoryConfig } from "./categories"
import { parseModelString } from "./model-string-parser"
import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { normalizeFallbackModels } from "../../shared/model-resolver"
import { buildFallbackChainFromModels } from "../../shared/fallback-chain-from-models"
import { getAvailableModelsForDelegateTask } from "./available-models"
import { resolveModelForDelegateTask } from "./model-selection"
@@ -79,6 +81,7 @@ Available categories: ${allCategoryNames}`,
}
const requirement = CATEGORY_MODEL_REQUIREMENTS[args.category!]
const normalizedConfiguredFallbackModels = normalizeFallbackModels(resolved.config.fallback_models)
let actualModel: string | undefined
let modelInfo: ModelFallbackInfo | undefined
let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
@@ -99,6 +102,7 @@ Available categories: ${allCategoryNames}`,
} else {
const resolution = resolveModelForDelegateTask({
userModel: explicitCategoryModel ?? overrideModel,
userFallbackModels: normalizedConfiguredFallbackModels,
categoryDefaultModel: resolved.model,
fallbackChain: requirement.fallbackChain,
availableModels,
@@ -178,6 +182,14 @@ Available categories: ${categoryNames.join(", ")}`,
const categoryConfigModel = resolved.config.model?.toLowerCase()
const isUnstableAgent = resolved.config.is_unstable_agent === true || [unstableModel, categoryConfigModel].some(m => m ? m.includes("gemini") || m.includes("minimax") || m.includes("kimi") : false)
const defaultProviderID = categoryModel?.providerID
?? parseModelString(actualModel ?? "")?.providerID
?? "opencode"
const configuredFallbackChain = buildFallbackChainFromModels(
normalizedConfiguredFallbackModels,
defaultProviderID,
)
return {
agentToUse: SISYPHUS_JUNIOR_AGENT,
categoryModel,
@@ -186,6 +198,6 @@ Available categories: ${categoryNames.join(", ")}`,
modelInfo,
actualModel,
isUnstableAgent,
fallbackChain: requirement?.fallbackChain,
fallbackChain: configuredFallbackChain ?? requirement?.fallbackChain,
}
}
@@ -14,6 +14,7 @@ function getExplicitHighBaseModel(model: string): string | null {
export function resolveModelForDelegateTask(input: {
userModel?: string
userFallbackModels?: string[]
categoryDefaultModel?: string
fallbackChain?: FallbackEntry[]
availableModels: Set<string>
@@ -44,6 +45,28 @@ export function resolveModelForDelegateTask(input: {
}
}
const userFallbackModels = input.userFallbackModels
if (userFallbackModels && userFallbackModels.length > 0) {
if (input.availableModels.size === 0) {
const first = normalizeModel(userFallbackModels[0])
if (first) {
return { model: first }
}
} else {
for (const fallbackModel of userFallbackModels) {
const normalizedFallback = normalizeModel(fallbackModel)
if (!normalizedFallback) continue
const parts = normalizedFallback.split("/")
const providerHint = parts.length >= 2 ? [parts[0]] : undefined
const match = fuzzyMatchModel(normalizedFallback, input.availableModels, providerHint)
if (match) {
return { model: match }
}
}
}
}
const fallbackChain = input.fallbackChain
if (fallbackChain && fallbackChain.length > 0) {
if (input.availableModels.size === 0) {
@@ -17,7 +17,10 @@ function createBaseArgs(overrides?: Partial<DelegateTaskArgs>): DelegateTaskArgs
}
}
function createExecutorContext(agentsFn: () => Promise<unknown>): ExecutorContext {
function createExecutorContext(
agentsFn: () => Promise<unknown>,
overrides?: Partial<ExecutorContext>,
): ExecutorContext {
const client = {
app: {
agents: agentsFn,
@@ -28,6 +31,7 @@ function createExecutorContext(agentsFn: () => Promise<unknown>): ExecutorContex
client,
manager: {} as ExecutorContext["manager"],
directory: "/tmp/test",
...overrides,
}
}
@@ -101,4 +105,62 @@ describe("resolveSubagentExecution", () => {
expect(result.categoryModel).toEqual({ providerID: "openai", modelID: "gpt-5.3-codex" })
cacheSpy.mockRestore()
})
test("uses agent override fallback_models for subagent runtime fallback chain", async () => {
//#given
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(
async () => ([
{ name: "explore", mode: "subagent", model: "quotio/claude-haiku-4-5" },
]),
{
agentOverrides: {
explore: {
fallback_models: ["quotio/gpt-5.2", "glm-5(max)"],
},
} as ExecutorContext["agentOverrides"],
}
)
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.fallbackChain).toEqual([
{ providers: ["quotio"], model: "gpt-5.2", variant: undefined },
{ providers: ["quotio"], model: "glm-5", variant: "max" },
])
})
test("uses category fallback_models when agent override points at category", async () => {
//#given
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(
async () => ([
{ name: "explore", mode: "subagent", model: "quotio/claude-haiku-4-5" },
]),
{
agentOverrides: {
explore: {
category: "research",
},
} as ExecutorContext["agentOverrides"],
userCategories: {
research: {
fallback_models: ["anthropic/claude-haiku-4-5"],
},
} as ExecutorContext["userCategories"],
}
)
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.fallbackChain).toEqual([
{ providers: ["anthropic"], model: "claude-haiku-4-5", variant: undefined },
])
})
})
+17 -2
View File
@@ -4,6 +4,8 @@ import { isPlanFamily } from "./constants"
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
import { normalizeModelFormat } from "../../shared/model-format-normalizer"
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { normalizeFallbackModels } from "../../shared/model-resolver"
import { buildFallbackChainFromModels } from "../../shared/fallback-chain-from-models"
import { getAgentDisplayName, getAgentConfigKey } from "../../shared/agent-display-names"
import { normalizeSDKResponse } from "../../shared"
import { log } from "../../shared/logger"
@@ -17,7 +19,7 @@ export async function resolveSubagentExecution(
parentAgent: string | undefined,
categoryExamples: string
): Promise<{ agentToUse: string; categoryModel: { providerID: string; modelID: string; variant?: string } | undefined; fallbackChain?: FallbackEntry[]; error?: string }> {
const { client, agentOverrides } = executorCtx
const { client, agentOverrides, userCategories } = executorCtx
if (!args.subagent_type?.trim()) {
return { agentToUse: "", categoryModel: undefined, error: `Agent name cannot be empty.` }
@@ -98,7 +100,10 @@ Create the work plan directly - that's your job as the planning agent.`,
const agentOverride = agentOverrides?.[agentConfigKey as keyof typeof agentOverrides]
?? (agentOverrides ? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentConfigKey)?.[1] : undefined)
const agentRequirement = AGENT_MODEL_REQUIREMENTS[agentConfigKey]
fallbackChain = agentRequirement?.fallbackChain
const normalizedAgentFallbackModels = normalizeFallbackModels(
agentOverride?.fallback_models
?? (agentOverride?.category ? userCategories?.[agentOverride.category]?.fallback_models : undefined)
)
if (agentOverride?.model || agentRequirement || matchedAgent.model) {
const availableModels = await getAvailableModelsForDelegateTask(client)
@@ -112,6 +117,7 @@ Create the work plan directly - that's your job as the planning agent.`,
const resolution = resolveModelForDelegateTask({
userModel: agentOverride?.model,
userFallbackModels: normalizedAgentFallbackModels,
categoryDefaultModel: matchedAgentModelStr,
fallbackChain: agentRequirement?.fallbackChain,
availableModels,
@@ -125,6 +131,15 @@ Create the work plan directly - that's your job as the planning agent.`,
categoryModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized
}
}
const defaultProviderID = categoryModel?.providerID
?? normalizedMatchedModel?.providerID
?? "opencode"
const configuredFallbackChain = buildFallbackChainFromModels(
normalizedAgentFallbackModels,
defaultProviderID,
)
fallbackChain = configuredFallbackChain ?? agentRequirement?.fallbackChain
}
if (!categoryModel && matchedAgent.model) {