fix(delegate-task): gate fallback settings to real fallback matches

This commit is contained in:
Ravi Tharuma
2026-03-25 10:49:10 +01:00
parent a77a16c494
commit e5c5438a44
8 changed files with 1070 additions and 39 deletions
File diff suppressed because it is too large Load Diff
@@ -169,6 +169,52 @@ describe("resolveCategoryExecution", () => {
agentsSpy.mockRestore()
})
test("does not apply object-style fallback settings when the configured primary model matches directly", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
models: { openai: ["gpt-5.4-preview"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const agentsSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
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: "openai/gpt-5.4-preview",
fallback_models: [
{
model: "openai/gpt-5.4",
variant: "low",
reasoningEffort: "high",
},
],
},
}
//#when
const result = await resolveCategoryExecution(args, executorCtx, undefined, "anthropic/claude-sonnet-4-6")
//#then
expect(result.error).toBeUndefined()
expect(result.actualModel).toBe("openai/gpt-5.4-preview")
expect(result.categoryModel).toEqual({
providerID: "openai",
modelID: "gpt-5.4-preview",
variant: "medium",
})
cacheSpy.mockRestore()
agentsSpy.mockRestore()
})
test("matches promoted fallback settings after fuzzy model resolution", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
+17 -8
View File
@@ -89,6 +89,7 @@ Available categories: ${allCategoryNames}`,
let categoryModel: DelegatedModelConfig | undefined
let isModelResolutionSkipped = false
let fallbackEntry: FallbackEntry | undefined
let matchedFallback = false
const overrideModel = sisyphusJuniorModel
const explicitCategoryModel = userCategories?.[args.category!]?.model
@@ -122,8 +123,14 @@ Available categories: ${allCategoryNames}`,
if (resolution && "skipped" in resolution) {
isModelResolutionSkipped = true
} else if (resolution) {
const { model: resolvedModel, variant: resolvedVariant, fallbackEntry: resolvedFallbackEntry } = resolution
const {
model: resolvedModel,
variant: resolvedVariant,
fallbackEntry: resolvedFallbackEntry,
matchedFallback: resolvedMatchedFallback,
} = resolution
fallbackEntry = resolvedFallbackEntry
matchedFallback = resolvedMatchedFallback === true
actualModel = resolvedModel
if (!parseModelString(actualModel)) {
@@ -202,13 +209,15 @@ Available categories: ${categoryNames.join(", ")}`,
defaultProviderID,
)
// Apply per-model settings from the source that provided the match:
// 1. fallbackEntry from resolver (built-in chain match) — exact, no lookup needed
// 2. configuredFallbackChain (user's fallback_models) — prefix match against user config
const effectiveEntry = fallbackEntry
?? (categoryModel && configuredFallbackChain
? findMostSpecificFallbackEntry(categoryModel.providerID, categoryModel.modelID, configuredFallbackChain)
: undefined)
// Only promote fallback-only settings when resolution actually selected a fallback model.
const effectiveEntry = matchedFallback && categoryModel
? (
fallbackEntry
?? (configuredFallbackChain
? findMostSpecificFallbackEntry(categoryModel.providerID, categoryModel.modelID, configuredFallbackChain)
: undefined)
)
: undefined
if (categoryModel && effectiveEntry) {
categoryModel = {
@@ -121,7 +121,7 @@ describe("resolveModelForDelegateTask", () => {
availableModels: new Set(["openai/gpt-5.2"]),
})
expect(result).toEqual({ model: "openai/gpt-5.2", variant: "high" })
expect(result).toEqual({ model: "openai/gpt-5.2", variant: "high", matchedFallback: true })
})
test("#then resolves a space-separated variant against the base available model", () => {
@@ -130,7 +130,7 @@ describe("resolveModelForDelegateTask", () => {
availableModels: new Set(["openai/gpt-5.2"]),
})
expect(result).toEqual({ model: "openai/gpt-5.2", variant: "medium" })
expect(result).toEqual({ model: "openai/gpt-5.2", variant: "medium", matchedFallback: true })
})
})
})
+8 -8
View File
@@ -53,7 +53,7 @@ export function resolveModelForDelegateTask(input: {
fallbackChain?: FallbackEntry[]
availableModels: Set<string>
systemDefaultModel?: string
}): { model: string; variant?: string; fallbackEntry?: FallbackEntry } | { skipped: true } | undefined {
}): { model: string; variant?: string; fallbackEntry?: FallbackEntry; matchedFallback?: boolean } | { skipped: true } | undefined {
const userModel = normalizeModel(input.userModel)
if (userModel) {
return { model: userModel }
@@ -97,7 +97,7 @@ export function resolveModelForDelegateTask(input: {
if (input.availableModels.size === 0) {
const first = userFallbackModels[0] ? parseUserFallbackModel(userFallbackModels[0]) : undefined
if (first) {
return { model: first.baseModel, variant: first.variant }
return { model: first.baseModel, variant: first.variant, matchedFallback: true }
}
} else {
for (const fallbackModel of userFallbackModels) {
@@ -106,7 +106,7 @@ export function resolveModelForDelegateTask(input: {
const match = fuzzyMatchModel(parsedFallback.baseModel, input.availableModels, parsedFallback.providerHint)
if (match) {
return { model: match, variant: parsedFallback.variant }
return { model: match, variant: parsedFallback.variant, matchedFallback: true }
}
}
}
@@ -119,7 +119,7 @@ export function resolveModelForDelegateTask(input: {
const provider = first?.providers?.[0]
if (provider) {
const transformedModelId = transformModelForProvider(provider, first.model)
return { model: `${provider}/${transformedModelId}`, variant: first.variant, fallbackEntry: first }
return { model: `${provider}/${transformedModelId}`, variant: first.variant, fallbackEntry: first, matchedFallback: true }
}
} else {
for (const entry of fallbackChain) {
@@ -128,20 +128,20 @@ export function resolveModelForDelegateTask(input: {
const match = fuzzyMatchModel(fullModel, input.availableModels, [provider])
if (match) {
if (explicitHighModel && entry.variant === "high" && match === explicitHighBaseModel) {
return { model: explicitHighModel, fallbackEntry: entry }
return { model: explicitHighModel, fallbackEntry: entry, matchedFallback: true }
}
return { model: match, variant: entry.variant, fallbackEntry: entry }
return { model: match, variant: entry.variant, fallbackEntry: entry, matchedFallback: true }
}
}
const crossProviderMatch = fuzzyMatchModel(entry.model, input.availableModels)
if (crossProviderMatch) {
if (explicitHighModel && entry.variant === "high" && crossProviderMatch === explicitHighBaseModel) {
return { model: explicitHighModel, fallbackEntry: entry }
return { model: explicitHighModel, fallbackEntry: entry, matchedFallback: true }
}
return { model: crossProviderMatch, variant: entry.variant, fallbackEntry: entry }
return { model: crossProviderMatch, variant: entry.variant, fallbackEntry: entry, matchedFallback: true }
}
}
}
@@ -227,6 +227,47 @@ describe("resolveSubagentExecution", () => {
connectedSpy.mockRestore()
})
test("does not apply object-style fallback settings when the subagent primary model matches directly", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
models: { openai: ["gpt-5.4-preview"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(
async () => ([
{ name: "explore", mode: "subagent", model: "openai/gpt-5.4-preview" },
]),
{
agentOverrides: {
explore: {
fallback_models: [
{
model: "openai/gpt-5.4",
variant: "low",
reasoningEffort: "high",
},
],
},
} as ExecutorContext["agentOverrides"],
}
)
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.categoryModel).toEqual({
providerID: "openai",
modelID: "gpt-5.4-preview",
})
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("matches promoted fallback settings after fuzzy model resolution", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
+10 -5
View File
@@ -142,12 +142,17 @@ Create the work plan directly - that's your job as the planning agent.`,
)
fallbackChain = configuredFallbackChain ?? agentRequirement?.fallbackChain
// Apply per-model settings: prefer resolver's exact entry, fall back to prefix match on user config
// Only promote fallback-only settings when resolution actually selected a fallback model.
const resolvedFallbackEntry = (resolution && !('skipped' in resolution)) ? resolution.fallbackEntry : undefined
const effectiveEntry = resolvedFallbackEntry
?? (categoryModel && fallbackChain
? findMostSpecificFallbackEntry(categoryModel.providerID, categoryModel.modelID, fallbackChain)
: undefined)
const matchedFallback = (resolution && !('skipped' in resolution)) ? resolution.matchedFallback === true : false
const effectiveEntry = matchedFallback && categoryModel
? (
resolvedFallbackEntry
?? (configuredFallbackChain
? findMostSpecificFallbackEntry(categoryModel.providerID, categoryModel.modelID, configuredFallbackChain)
: undefined)
)
: undefined
if (categoryModel && effectiveEntry) {
categoryModel = {
+1 -1
View File
@@ -237,7 +237,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
return executeUnstableAgentTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel)
}
} else {
const resolution = await resolveSubagentExecution(args, options, parentContext.agent, categoryExamples, inheritedModel)
const resolution = await resolveSubagentExecution(args, options, parentContext.agent, categoryExamples)
if (resolution.error) {
return resolution.error
}