Unify dynamic fallback chains for background subagents
This commit is contained in:
committed by
YeonGyu-Kim
parent
38c925697b
commit
86c6bc7716
@@ -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" },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user