fix(delegate-task): gate fallback settings to real fallback matches
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -169,6 +169,52 @@ describe("resolveCategoryExecution", () => {
|
|||||||
agentsSpy.mockRestore()
|
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 () => {
|
test("matches promoted fallback settings after fuzzy model resolution", async () => {
|
||||||
//#given
|
//#given
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ Available categories: ${allCategoryNames}`,
|
|||||||
let categoryModel: DelegatedModelConfig | undefined
|
let categoryModel: DelegatedModelConfig | undefined
|
||||||
let isModelResolutionSkipped = false
|
let isModelResolutionSkipped = false
|
||||||
let fallbackEntry: FallbackEntry | undefined
|
let fallbackEntry: FallbackEntry | undefined
|
||||||
|
let matchedFallback = false
|
||||||
|
|
||||||
const overrideModel = sisyphusJuniorModel
|
const overrideModel = sisyphusJuniorModel
|
||||||
const explicitCategoryModel = userCategories?.[args.category!]?.model
|
const explicitCategoryModel = userCategories?.[args.category!]?.model
|
||||||
@@ -122,8 +123,14 @@ Available categories: ${allCategoryNames}`,
|
|||||||
if (resolution && "skipped" in resolution) {
|
if (resolution && "skipped" in resolution) {
|
||||||
isModelResolutionSkipped = true
|
isModelResolutionSkipped = true
|
||||||
} else if (resolution) {
|
} else if (resolution) {
|
||||||
const { model: resolvedModel, variant: resolvedVariant, fallbackEntry: resolvedFallbackEntry } = resolution
|
const {
|
||||||
|
model: resolvedModel,
|
||||||
|
variant: resolvedVariant,
|
||||||
|
fallbackEntry: resolvedFallbackEntry,
|
||||||
|
matchedFallback: resolvedMatchedFallback,
|
||||||
|
} = resolution
|
||||||
fallbackEntry = resolvedFallbackEntry
|
fallbackEntry = resolvedFallbackEntry
|
||||||
|
matchedFallback = resolvedMatchedFallback === true
|
||||||
actualModel = resolvedModel
|
actualModel = resolvedModel
|
||||||
|
|
||||||
if (!parseModelString(actualModel)) {
|
if (!parseModelString(actualModel)) {
|
||||||
@@ -202,13 +209,15 @@ Available categories: ${categoryNames.join(", ")}`,
|
|||||||
defaultProviderID,
|
defaultProviderID,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Apply per-model settings from the source that provided the match:
|
// Only promote fallback-only settings when resolution actually selected a fallback model.
|
||||||
// 1. fallbackEntry from resolver (built-in chain match) — exact, no lookup needed
|
const effectiveEntry = matchedFallback && categoryModel
|
||||||
// 2. configuredFallbackChain (user's fallback_models) — prefix match against user config
|
? (
|
||||||
const effectiveEntry = fallbackEntry
|
fallbackEntry
|
||||||
?? (categoryModel && configuredFallbackChain
|
?? (configuredFallbackChain
|
||||||
? findMostSpecificFallbackEntry(categoryModel.providerID, categoryModel.modelID, configuredFallbackChain)
|
? findMostSpecificFallbackEntry(categoryModel.providerID, categoryModel.modelID, configuredFallbackChain)
|
||||||
: undefined)
|
: undefined)
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
|
||||||
if (categoryModel && effectiveEntry) {
|
if (categoryModel && effectiveEntry) {
|
||||||
categoryModel = {
|
categoryModel = {
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ describe("resolveModelForDelegateTask", () => {
|
|||||||
availableModels: new Set(["openai/gpt-5.2"]),
|
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", () => {
|
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"]),
|
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 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function resolveModelForDelegateTask(input: {
|
|||||||
fallbackChain?: FallbackEntry[]
|
fallbackChain?: FallbackEntry[]
|
||||||
availableModels: Set<string>
|
availableModels: Set<string>
|
||||||
systemDefaultModel?: 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)
|
const userModel = normalizeModel(input.userModel)
|
||||||
if (userModel) {
|
if (userModel) {
|
||||||
return { model: userModel }
|
return { model: userModel }
|
||||||
@@ -97,7 +97,7 @@ export function resolveModelForDelegateTask(input: {
|
|||||||
if (input.availableModels.size === 0) {
|
if (input.availableModels.size === 0) {
|
||||||
const first = userFallbackModels[0] ? parseUserFallbackModel(userFallbackModels[0]) : undefined
|
const first = userFallbackModels[0] ? parseUserFallbackModel(userFallbackModels[0]) : undefined
|
||||||
if (first) {
|
if (first) {
|
||||||
return { model: first.baseModel, variant: first.variant }
|
return { model: first.baseModel, variant: first.variant, matchedFallback: true }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const fallbackModel of userFallbackModels) {
|
for (const fallbackModel of userFallbackModels) {
|
||||||
@@ -106,7 +106,7 @@ export function resolveModelForDelegateTask(input: {
|
|||||||
|
|
||||||
const match = fuzzyMatchModel(parsedFallback.baseModel, input.availableModels, parsedFallback.providerHint)
|
const match = fuzzyMatchModel(parsedFallback.baseModel, input.availableModels, parsedFallback.providerHint)
|
||||||
if (match) {
|
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]
|
const provider = first?.providers?.[0]
|
||||||
if (provider) {
|
if (provider) {
|
||||||
const transformedModelId = transformModelForProvider(provider, first.model)
|
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 {
|
} else {
|
||||||
for (const entry of fallbackChain) {
|
for (const entry of fallbackChain) {
|
||||||
@@ -128,20 +128,20 @@ export function resolveModelForDelegateTask(input: {
|
|||||||
const match = fuzzyMatchModel(fullModel, input.availableModels, [provider])
|
const match = fuzzyMatchModel(fullModel, input.availableModels, [provider])
|
||||||
if (match) {
|
if (match) {
|
||||||
if (explicitHighModel && entry.variant === "high" && match === explicitHighBaseModel) {
|
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)
|
const crossProviderMatch = fuzzyMatchModel(entry.model, input.availableModels)
|
||||||
if (crossProviderMatch) {
|
if (crossProviderMatch) {
|
||||||
if (explicitHighModel && entry.variant === "high" && crossProviderMatch === explicitHighBaseModel) {
|
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()
|
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 () => {
|
test("matches promoted fallback settings after fuzzy model resolution", async () => {
|
||||||
//#given
|
//#given
|
||||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||||
|
|||||||
@@ -142,12 +142,17 @@ Create the work plan directly - that's your job as the planning agent.`,
|
|||||||
)
|
)
|
||||||
fallbackChain = configuredFallbackChain ?? agentRequirement?.fallbackChain
|
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 resolvedFallbackEntry = (resolution && !('skipped' in resolution)) ? resolution.fallbackEntry : undefined
|
||||||
const effectiveEntry = resolvedFallbackEntry
|
const matchedFallback = (resolution && !('skipped' in resolution)) ? resolution.matchedFallback === true : false
|
||||||
?? (categoryModel && fallbackChain
|
const effectiveEntry = matchedFallback && categoryModel
|
||||||
? findMostSpecificFallbackEntry(categoryModel.providerID, categoryModel.modelID, fallbackChain)
|
? (
|
||||||
: undefined)
|
resolvedFallbackEntry
|
||||||
|
?? (configuredFallbackChain
|
||||||
|
? findMostSpecificFallbackEntry(categoryModel.providerID, categoryModel.modelID, configuredFallbackChain)
|
||||||
|
: undefined)
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
|
||||||
if (categoryModel && effectiveEntry) {
|
if (categoryModel && effectiveEntry) {
|
||||||
categoryModel = {
|
categoryModel = {
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
|||||||
return executeUnstableAgentTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel)
|
return executeUnstableAgentTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const resolution = await resolveSubagentExecution(args, options, parentContext.agent, categoryExamples, inheritedModel)
|
const resolution = await resolveSubagentExecution(args, options, parentContext.agent, categoryExamples)
|
||||||
if (resolution.error) {
|
if (resolution.error) {
|
||||||
return resolution.error
|
return resolution.error
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user