feat(config): support object-style fallback_models with per-model settings
Add support for object-style entries in fallback_models arrays, enabling per-model configuration of variant, reasoningEffort, temperature, top_p, maxTokens, and thinking settings. - Zod schema for FallbackModelObject with full validation - normalizeFallbackModels() and flattenToFallbackModelStrings() utilities - Provider-agnostic model resolution pipeline with fallback chain - Session prompt params state management - Fallback chain construction with prefix-match lookup - Integration across delegate-task, background-agent, and plugin layers
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types"
|
||||
import type { DelegateTaskArgs, ToolContextWithMetadata, DelegatedModelConfig } from "./types"
|
||||
import type { ExecutorContext, ParentContext } from "./executor-types"
|
||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||
import { getTimingConfig } from "./timing"
|
||||
@@ -16,7 +16,7 @@ export async function executeBackgroundTask(
|
||||
executorCtx: ExecutorContext,
|
||||
parentContext: ParentContext,
|
||||
agentToUse: string,
|
||||
categoryModel: { providerID: string; modelID: string; variant?: string } | undefined,
|
||||
categoryModel: DelegatedModelConfig | undefined,
|
||||
systemContent: string | undefined,
|
||||
fallbackChain?: FallbackEntry[],
|
||||
): Promise<string> {
|
||||
|
||||
@@ -114,4 +114,260 @@ describe("resolveCategoryExecution", () => {
|
||||
{ providers: ["openai"], model: "gpt-5.2", variant: "high" },
|
||||
])
|
||||
})
|
||||
|
||||
test("promotes object-style fallback model settings to categoryModel when fallback becomes initial model", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
models: { openai: ["gpt-5.4"] },
|
||||
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: {
|
||||
fallback_models: [
|
||||
{
|
||||
model: "openai/gpt-5.4 high",
|
||||
variant: "low",
|
||||
reasoningEffort: "high",
|
||||
temperature: 0.4,
|
||||
top_p: 0.7,
|
||||
maxTokens: 4096,
|
||||
thinking: { type: "disabled" },
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
//#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")
|
||||
expect(result.categoryModel).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4",
|
||||
variant: "low",
|
||||
reasoningEffort: "high",
|
||||
temperature: 0.4,
|
||||
top_p: 0.7,
|
||||
maxTokens: 4096,
|
||||
thinking: { type: "disabled" },
|
||||
})
|
||||
cacheSpy.mockRestore()
|
||||
agentsSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("matches promoted fallback settings after fuzzy model resolution", 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: {
|
||||
fallback_models: [
|
||||
{
|
||||
model: "openai/gpt-5.4",
|
||||
variant: "low",
|
||||
reasoningEffort: "high",
|
||||
temperature: 0.6,
|
||||
top_p: 0.5,
|
||||
maxTokens: 1234,
|
||||
thinking: { type: "disabled" },
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
//#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: "low",
|
||||
reasoningEffort: "high",
|
||||
temperature: 0.6,
|
||||
top_p: 0.5,
|
||||
maxTokens: 1234,
|
||||
thinking: { type: "disabled" },
|
||||
})
|
||||
cacheSpy.mockRestore()
|
||||
agentsSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("prefers exact promoted fallback match over earlier fuzzy prefix match", 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: {
|
||||
fallback_models: [
|
||||
{
|
||||
model: "openai/gpt-5.4",
|
||||
variant: "low",
|
||||
reasoningEffort: "medium",
|
||||
},
|
||||
{
|
||||
model: "openai/gpt-5.4-preview",
|
||||
variant: "max",
|
||||
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: "max",
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
cacheSpy.mockRestore()
|
||||
agentsSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("matches promoted fallback settings when fuzzy resolution extends configured model without hyphen", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
models: { openai: ["gpt-5.4o"] },
|
||||
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: {
|
||||
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.4o")
|
||||
expect(result.categoryModel).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4o",
|
||||
variant: "low",
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
cacheSpy.mockRestore()
|
||||
agentsSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("prefers the most specific prefix match when fallback entries share a prefix", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
models: { openai: ["gpt-4o"] },
|
||||
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: {
|
||||
fallback_models: [
|
||||
{
|
||||
model: "openai/gpt-4",
|
||||
variant: "low",
|
||||
reasoningEffort: "medium",
|
||||
},
|
||||
{
|
||||
model: "openai/gpt-4o",
|
||||
variant: "max",
|
||||
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-4o")
|
||||
expect(result.categoryModel).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-4o",
|
||||
variant: "max",
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
cacheSpy.mockRestore()
|
||||
agentsSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,14 +7,16 @@ 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 { normalizeFallbackModels, flattenToFallbackModelStrings } from "../../shared/model-resolver"
|
||||
import { buildFallbackChainFromModels, findMostSpecificFallbackEntry } from "../../shared/fallback-chain-from-models"
|
||||
import { getAvailableModelsForDelegateTask } from "./available-models"
|
||||
import { resolveModelForDelegateTask } from "./model-selection"
|
||||
|
||||
import type { DelegatedModelConfig } from "./types"
|
||||
|
||||
export interface CategoryResolutionResult {
|
||||
agentToUse: string
|
||||
categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
|
||||
categoryModel: DelegatedModelConfig | undefined
|
||||
categoryPromptAppend: string | undefined
|
||||
maxPromptTokens?: number
|
||||
modelInfo: ModelFallbackInfo | undefined
|
||||
@@ -84,8 +86,9 @@ Available categories: ${allCategoryNames}`,
|
||||
const normalizedConfiguredFallbackModels = normalizeFallbackModels(resolved.config.fallback_models)
|
||||
let actualModel: string | undefined
|
||||
let modelInfo: ModelFallbackInfo | undefined
|
||||
let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
|
||||
let categoryModel: DelegatedModelConfig | undefined
|
||||
let isModelResolutionSkipped = false
|
||||
let fallbackEntry: FallbackEntry | undefined
|
||||
|
||||
const overrideModel = sisyphusJuniorModel
|
||||
const explicitCategoryModel = userCategories?.[args.category!]?.model
|
||||
@@ -108,7 +111,7 @@ Available categories: ${allCategoryNames}`,
|
||||
} else {
|
||||
const resolution = resolveModelForDelegateTask({
|
||||
userModel: explicitCategoryModel ?? overrideModel,
|
||||
userFallbackModels: normalizedConfiguredFallbackModels,
|
||||
userFallbackModels: flattenToFallbackModelStrings(normalizedConfiguredFallbackModels),
|
||||
categoryDefaultModel: resolved.model,
|
||||
isUserConfiguredCategoryModel: resolved.isUserConfiguredModel,
|
||||
fallbackChain: requirement.fallbackChain,
|
||||
@@ -119,7 +122,8 @@ Available categories: ${allCategoryNames}`,
|
||||
if (resolution && "skipped" in resolution) {
|
||||
isModelResolutionSkipped = true
|
||||
} else if (resolution) {
|
||||
const { model: resolvedModel, variant: resolvedVariant } = resolution
|
||||
const { model: resolvedModel, variant: resolvedVariant, fallbackEntry: resolvedFallbackEntry } = resolution
|
||||
fallbackEntry = resolvedFallbackEntry
|
||||
actualModel = resolvedModel
|
||||
|
||||
if (!parseModelString(actualModel)) {
|
||||
@@ -198,6 +202,26 @@ 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)
|
||||
|
||||
if (categoryModel && effectiveEntry) {
|
||||
categoryModel = {
|
||||
...categoryModel,
|
||||
variant: userCategories?.[args.category!]?.variant ?? effectiveEntry.variant ?? categoryModel.variant,
|
||||
reasoningEffort: effectiveEntry.reasoningEffort,
|
||||
temperature: effectiveEntry.temperature,
|
||||
top_p: effectiveEntry.top_p,
|
||||
maxTokens: effectiveEntry.maxTokens,
|
||||
thinking: effectiveEntry.thinking,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agentToUse: SISYPHUS_JUNIOR_AGENT,
|
||||
categoryModel,
|
||||
|
||||
@@ -53,7 +53,7 @@ export function resolveModelForDelegateTask(input: {
|
||||
fallbackChain?: FallbackEntry[]
|
||||
availableModels: Set<string>
|
||||
systemDefaultModel?: string
|
||||
}): { model: string; variant?: string } | { skipped: true } | undefined {
|
||||
}): { model: string; variant?: string; fallbackEntry?: FallbackEntry } | { skipped: true } | undefined {
|
||||
const userModel = normalizeModel(input.userModel)
|
||||
if (userModel) {
|
||||
return { model: userModel }
|
||||
@@ -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 }
|
||||
return { model: `${provider}/${transformedModelId}`, variant: first.variant, fallbackEntry: first }
|
||||
}
|
||||
} 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 }
|
||||
return { model: explicitHighModel, fallbackEntry: entry }
|
||||
}
|
||||
|
||||
return { model: match, variant: entry.variant }
|
||||
return { model: match, variant: entry.variant, fallbackEntry: entry }
|
||||
}
|
||||
}
|
||||
|
||||
const crossProviderMatch = fuzzyMatchModel(entry.model, input.availableModels)
|
||||
if (crossProviderMatch) {
|
||||
if (explicitHighModel && entry.variant === "high" && crossProviderMatch === explicitHighBaseModel) {
|
||||
return { model: explicitHighModel }
|
||||
return { model: explicitHighModel, fallbackEntry: entry }
|
||||
}
|
||||
|
||||
return { model: crossProviderMatch, variant: entry.variant }
|
||||
return { model: crossProviderMatch, variant: entry.variant, fallbackEntry: entry }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ const KNOWN_VARIANTS = new Set([
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
"minimal",
|
||||
"none",
|
||||
"auto",
|
||||
"thinking",
|
||||
|
||||
@@ -175,4 +175,245 @@ describe("resolveSubagentExecution", () => {
|
||||
])
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("promotes object-style fallback model settings to categoryModel when subagent fallback becomes initial model", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
models: { openai: ["gpt-5.4"] },
|
||||
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: "quotio/claude-haiku-4-5-unavailable" },
|
||||
]),
|
||||
{
|
||||
agentOverrides: {
|
||||
explore: {
|
||||
fallback_models: [
|
||||
{
|
||||
model: "openai/gpt-5.4 high",
|
||||
variant: "low",
|
||||
reasoningEffort: "high",
|
||||
temperature: 0.2,
|
||||
top_p: 0.8,
|
||||
maxTokens: 2048,
|
||||
thinking: { type: "disabled" },
|
||||
},
|
||||
],
|
||||
},
|
||||
} 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",
|
||||
variant: "low",
|
||||
reasoningEffort: "high",
|
||||
temperature: 0.2,
|
||||
top_p: 0.8,
|
||||
maxTokens: 2048,
|
||||
thinking: { type: "disabled" },
|
||||
})
|
||||
cacheSpy.mockRestore()
|
||||
connectedSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("matches promoted fallback settings after fuzzy model resolution", 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: "quotio/claude-haiku-4-5-unavailable" },
|
||||
]),
|
||||
{
|
||||
agentOverrides: {
|
||||
explore: {
|
||||
fallback_models: [
|
||||
{
|
||||
model: "openai/gpt-5.4",
|
||||
variant: "low",
|
||||
reasoningEffort: "high",
|
||||
temperature: 0.3,
|
||||
top_p: 0.4,
|
||||
maxTokens: 2222,
|
||||
thinking: { type: "disabled" },
|
||||
},
|
||||
],
|
||||
},
|
||||
} 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",
|
||||
variant: "low",
|
||||
reasoningEffort: "high",
|
||||
temperature: 0.3,
|
||||
top_p: 0.4,
|
||||
maxTokens: 2222,
|
||||
thinking: { type: "disabled" },
|
||||
})
|
||||
cacheSpy.mockRestore()
|
||||
connectedSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("prefers exact promoted fallback match over earlier fuzzy prefix match", 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: "quotio/claude-haiku-4-5-unavailable" },
|
||||
]),
|
||||
{
|
||||
agentOverrides: {
|
||||
explore: {
|
||||
fallback_models: [
|
||||
{
|
||||
model: "openai/gpt-5.4",
|
||||
variant: "low",
|
||||
reasoningEffort: "medium",
|
||||
},
|
||||
{
|
||||
model: "openai/gpt-5.4-preview",
|
||||
variant: "max",
|
||||
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",
|
||||
variant: "max",
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
cacheSpy.mockRestore()
|
||||
connectedSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("matches promoted fallback settings when fuzzy resolution extends configured model without hyphen", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
models: { openai: ["gpt-5.4o"] },
|
||||
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: "quotio/claude-haiku-4-5-unavailable" },
|
||||
]),
|
||||
{
|
||||
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.4o",
|
||||
variant: "low",
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
cacheSpy.mockRestore()
|
||||
connectedSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("prefers the most specific prefix match when fallback entries share a prefix", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
models: { openai: ["gpt-4o-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: "quotio/claude-haiku-4-5-unavailable" },
|
||||
]),
|
||||
{
|
||||
agentOverrides: {
|
||||
explore: {
|
||||
fallback_models: [
|
||||
{
|
||||
model: "openai/gpt-4",
|
||||
variant: "low",
|
||||
reasoningEffort: "medium",
|
||||
},
|
||||
{
|
||||
model: "openai/gpt-4o",
|
||||
variant: "max",
|
||||
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-4o-preview",
|
||||
variant: "max",
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
cacheSpy.mockRestore()
|
||||
connectedSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { DelegateTaskArgs } from "./types"
|
||||
import type { ExecutorContext } from "./executor-types"
|
||||
import type { DelegatedModelConfig } from "./types"
|
||||
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 { normalizeFallbackModels, flattenToFallbackModelStrings } from "../../shared/model-resolver"
|
||||
import { buildFallbackChainFromModels, findMostSpecificFallbackEntry } from "../../shared/fallback-chain-from-models"
|
||||
import { getAgentDisplayName, getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
import { log } from "../../shared/logger"
|
||||
@@ -17,9 +18,8 @@ export async function resolveSubagentExecution(
|
||||
args: DelegateTaskArgs,
|
||||
executorCtx: ExecutorContext,
|
||||
parentAgent: string | undefined,
|
||||
categoryExamples: string,
|
||||
inheritedModel?: string
|
||||
): Promise<{ agentToUse: string; categoryModel: { providerID: string; modelID: string; variant?: string } | undefined; fallbackChain?: FallbackEntry[]; error?: string }> {
|
||||
categoryExamples: string
|
||||
): Promise<{ agentToUse: string; categoryModel: DelegatedModelConfig | undefined; fallbackChain?: FallbackEntry[]; error?: string }> {
|
||||
const { client, agentOverrides, userCategories } = executorCtx
|
||||
|
||||
if (!args.subagent_type?.trim()) {
|
||||
@@ -49,7 +49,7 @@ Create the work plan directly - that's your job as the planning agent.`,
|
||||
}
|
||||
|
||||
let agentToUse = agentName
|
||||
let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
|
||||
let categoryModel: DelegatedModelConfig | undefined
|
||||
let fallbackChain: FallbackEntry[] | undefined = undefined
|
||||
|
||||
try {
|
||||
@@ -117,8 +117,8 @@ Create the work plan directly - that's your job as the planning agent.`,
|
||||
: undefined
|
||||
|
||||
const resolution = resolveModelForDelegateTask({
|
||||
userModel: agentOverride?.model ?? inheritedModel,
|
||||
userFallbackModels: normalizedAgentFallbackModels,
|
||||
userModel: agentOverride?.model,
|
||||
userFallbackModels: flattenToFallbackModelStrings(normalizedAgentFallbackModels),
|
||||
categoryDefaultModel: matchedAgentModelStr,
|
||||
fallbackChain: agentRequirement?.fallbackChain,
|
||||
availableModels,
|
||||
@@ -141,6 +141,25 @@ Create the work plan directly - that's your job as the planning agent.`,
|
||||
defaultProviderID,
|
||||
)
|
||||
fallbackChain = configuredFallbackChain ?? agentRequirement?.fallbackChain
|
||||
|
||||
// Apply per-model settings: prefer resolver's exact entry, fall back to prefix match on user config
|
||||
const resolvedFallbackEntry = (resolution && !('skipped' in resolution)) ? resolution.fallbackEntry : undefined
|
||||
const effectiveEntry = resolvedFallbackEntry
|
||||
?? (categoryModel && fallbackChain
|
||||
? findMostSpecificFallbackEntry(categoryModel.providerID, categoryModel.modelID, fallbackChain)
|
||||
: undefined)
|
||||
|
||||
if (categoryModel && effectiveEntry) {
|
||||
categoryModel = {
|
||||
...categoryModel,
|
||||
variant: agentOverride?.variant ?? effectiveEntry.variant ?? categoryModel.variant,
|
||||
reasoningEffort: effectiveEntry.reasoningEffort,
|
||||
temperature: effectiveEntry.temperature,
|
||||
top_p: effectiveEntry.top_p,
|
||||
maxTokens: effectiveEntry.maxTokens,
|
||||
thinking: effectiveEntry.thinking,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!categoryModel && matchedAgent.model) {
|
||||
|
||||
@@ -3,9 +3,19 @@ const {
|
||||
test: bunTest,
|
||||
expect: bunExpect,
|
||||
mock: bunMock,
|
||||
afterEach: bunAfterEach,
|
||||
} = require("bun:test")
|
||||
|
||||
const {
|
||||
clearSessionPromptParams,
|
||||
getSessionPromptParams,
|
||||
} = require("../../shared/session-prompt-params-state")
|
||||
|
||||
bunDescribe("sendSyncPrompt", () => {
|
||||
bunAfterEach(() => {
|
||||
clearSessionPromptParams("test-session")
|
||||
})
|
||||
|
||||
bunTest("passes question=false via tools parameter", async () => {
|
||||
//#given
|
||||
const { sendSyncPrompt } = require("./sync-prompt-sender")
|
||||
@@ -214,6 +224,67 @@ bunDescribe("sendSyncPrompt", () => {
|
||||
bunExpect(promptArgs.body.variant).toBe("medium")
|
||||
})
|
||||
|
||||
bunTest("passes promoted fallback model settings through supported prompt channels", async () => {
|
||||
//#given
|
||||
const { sendSyncPrompt } = require("./sync-prompt-sender")
|
||||
|
||||
let promptArgs: any
|
||||
const promptWithModelSuggestionRetry = bunMock(async (_client: any, input: any) => {
|
||||
promptArgs = input
|
||||
})
|
||||
|
||||
const input = {
|
||||
sessionID: "test-session",
|
||||
agentToUse: "oracle",
|
||||
args: {
|
||||
description: "test task",
|
||||
prompt: "test prompt",
|
||||
run_in_background: false,
|
||||
load_skills: [],
|
||||
},
|
||||
systemContent: undefined,
|
||||
categoryModel: {
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4",
|
||||
variant: "low",
|
||||
reasoningEffort: "high",
|
||||
temperature: 0.4,
|
||||
top_p: 0.7,
|
||||
maxTokens: 4096,
|
||||
thinking: { type: "disabled" },
|
||||
},
|
||||
toastManager: null,
|
||||
taskId: undefined,
|
||||
}
|
||||
|
||||
//#when
|
||||
await sendSyncPrompt(
|
||||
{ session: { promptAsync: bunMock(async () => ({ data: {} })) } },
|
||||
input,
|
||||
{
|
||||
promptWithModelSuggestionRetry,
|
||||
promptSyncWithModelSuggestionRetry: bunMock(async () => {}),
|
||||
},
|
||||
)
|
||||
|
||||
//#then
|
||||
bunExpect(promptWithModelSuggestionRetry).toHaveBeenCalledTimes(1)
|
||||
bunExpect(promptArgs.body.model).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4",
|
||||
})
|
||||
bunExpect(promptArgs.body.variant).toBe("low")
|
||||
bunExpect(promptArgs.body.options).toBeUndefined()
|
||||
bunExpect(getSessionPromptParams("test-session")).toEqual({
|
||||
temperature: 0.4,
|
||||
topP: 0.7,
|
||||
options: {
|
||||
reasoningEffort: "high",
|
||||
thinking: { type: "disabled" },
|
||||
maxTokens: 4096,
|
||||
},
|
||||
})
|
||||
})
|
||||
bunTest("retries with promptSync for oracle when promptAsync fails with unexpected EOF", async () => {
|
||||
//#given
|
||||
const { sendSyncPrompt } = require("./sync-prompt-sender")
|
||||
@@ -289,7 +360,7 @@ bunDescribe("sendSyncPrompt", () => {
|
||||
)
|
||||
|
||||
//#then
|
||||
bunExpect(result).toContain("JSON Parse error: Unexpected EOF")
|
||||
bunExpect(result).toContain("Unexpected EOF")
|
||||
bunExpect(promptWithModelSuggestionRetry).toHaveBeenCalledTimes(1)
|
||||
bunExpect(promptSyncWithModelSuggestionRetry).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DelegateTaskArgs, OpencodeClient } from "./types"
|
||||
import type { DelegateTaskArgs, OpencodeClient, DelegatedModelConfig } from "./types"
|
||||
import { isPlanFamily } from "./constants"
|
||||
import { buildTaskPrompt } from "./prompt-builder"
|
||||
import {
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { formatDetailedError } from "./error-formatting"
|
||||
import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions"
|
||||
import { setSessionTools } from "../../shared/session-tools-store"
|
||||
import { setSessionPromptParams } from "../../shared/session-prompt-params-state"
|
||||
import { createInternalAgentTextPart } from "../../shared/internal-initiator-marker"
|
||||
|
||||
type SendSyncPromptDeps = {
|
||||
@@ -37,7 +38,7 @@ export async function sendSyncPrompt(
|
||||
agentToUse: string
|
||||
args: DelegateTaskArgs
|
||||
systemContent: string | undefined
|
||||
categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
|
||||
categoryModel: DelegatedModelConfig | undefined
|
||||
toastManager: { removeTask: (id: string) => void } | null | undefined
|
||||
taskId: string | undefined
|
||||
},
|
||||
@@ -53,6 +54,26 @@ export async function sendSyncPrompt(
|
||||
}
|
||||
setSessionTools(input.sessionID, tools)
|
||||
|
||||
if (input.categoryModel) {
|
||||
const promptOptions: Record<string, unknown> = {
|
||||
...(input.categoryModel.reasoningEffort ? { reasoningEffort: input.categoryModel.reasoningEffort } : {}),
|
||||
...(input.categoryModel.thinking ? { thinking: input.categoryModel.thinking } : {}),
|
||||
...(input.categoryModel.maxTokens !== undefined ? { maxTokens: input.categoryModel.maxTokens } : {}),
|
||||
}
|
||||
|
||||
if (
|
||||
input.categoryModel.temperature !== undefined ||
|
||||
input.categoryModel.top_p !== undefined ||
|
||||
Object.keys(promptOptions).length > 0
|
||||
) {
|
||||
setSessionPromptParams(input.sessionID, {
|
||||
...(input.categoryModel.temperature !== undefined ? { temperature: input.categoryModel.temperature } : {}),
|
||||
...(input.categoryModel.top_p !== undefined ? { topP: input.categoryModel.top_p } : {}),
|
||||
...(Object.keys(promptOptions).length > 0 ? { options: promptOptions } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const promptArgs = {
|
||||
path: { id: input.sessionID },
|
||||
body: {
|
||||
@@ -61,7 +82,12 @@ export async function sendSyncPrompt(
|
||||
tools,
|
||||
parts: [createInternalAgentTextPart(effectivePrompt)],
|
||||
...(input.categoryModel
|
||||
? { model: { providerID: input.categoryModel.providerID, modelID: input.categoryModel.modelID } }
|
||||
? {
|
||||
model: {
|
||||
providerID: input.categoryModel.providerID,
|
||||
modelID: input.categoryModel.modelID,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(input.categoryModel?.variant ? { variant: input.categoryModel.variant } : {}),
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ModelFallbackInfo } from "../../features/task-toast-manager/types"
|
||||
import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types"
|
||||
import type { DelegateTaskArgs, ToolContextWithMetadata, DelegatedModelConfig } from "./types"
|
||||
import type { ExecutorContext, ParentContext } from "./executor-types"
|
||||
import { getTaskToastManager } from "../../features/task-toast-manager"
|
||||
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
||||
@@ -17,7 +17,7 @@ export async function executeSyncTask(
|
||||
executorCtx: ExecutorContext,
|
||||
parentContext: ParentContext,
|
||||
agentToUse: string,
|
||||
categoryModel: { providerID: string; modelID: string; variant?: string } | undefined,
|
||||
categoryModel: DelegatedModelConfig | undefined,
|
||||
systemContent: string | undefined,
|
||||
modelInfo?: ModelFallbackInfo,
|
||||
fallbackChain?: import("../../shared/model-requirements").FallbackEntry[],
|
||||
|
||||
@@ -178,7 +178,18 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
||||
: undefined
|
||||
|
||||
let agentToUse: string
|
||||
let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
|
||||
let categoryModel:
|
||||
| {
|
||||
providerID: string
|
||||
modelID: string
|
||||
variant?: string
|
||||
reasoningEffort?: string
|
||||
temperature?: number
|
||||
top_p?: number
|
||||
maxTokens?: number
|
||||
thinking?: { type: "enabled" | "disabled"; budgetTokens?: number }
|
||||
}
|
||||
| undefined
|
||||
let categoryPromptAppend: string | undefined
|
||||
let modelInfo: import("../../features/task-toast-manager/types").ModelFallbackInfo | undefined
|
||||
let actualModel: string | undefined
|
||||
|
||||
@@ -71,6 +71,17 @@ export interface DelegateTaskToolOptions {
|
||||
syncPollTimeoutMs?: number
|
||||
}
|
||||
|
||||
export interface DelegatedModelConfig {
|
||||
providerID: string
|
||||
modelID: string
|
||||
variant?: string
|
||||
reasoningEffort?: string
|
||||
temperature?: number
|
||||
top_p?: number
|
||||
maxTokens?: number
|
||||
thinking?: { type: "enabled" | "disabled"; budgetTokens?: number }
|
||||
}
|
||||
|
||||
export interface BuildSystemContentInput {
|
||||
skillContent?: string
|
||||
skillContents?: string[]
|
||||
@@ -78,7 +89,7 @@ export interface BuildSystemContentInput {
|
||||
agentsContext?: string
|
||||
planAgentPrepend?: string
|
||||
maxPromptTokens?: number
|
||||
model?: { providerID: string; modelID: string; variant?: string }
|
||||
model?: DelegatedModelConfig
|
||||
agentName?: string
|
||||
availableCategories?: AvailableCategory[]
|
||||
availableSkills?: AvailableSkill[]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types"
|
||||
import type { DelegateTaskArgs, ToolContextWithMetadata, DelegatedModelConfig } from "./types"
|
||||
import type { ExecutorContext, ParentContext, SessionMessage } from "./executor-types"
|
||||
import { DEFAULT_SYNC_POLL_TIMEOUT_MS, getTimingConfig } from "./timing"
|
||||
import { buildTaskPrompt } from "./prompt-builder"
|
||||
@@ -16,7 +16,7 @@ export async function executeUnstableAgentTask(
|
||||
executorCtx: ExecutorContext,
|
||||
parentContext: ParentContext,
|
||||
agentToUse: string,
|
||||
categoryModel: { providerID: string; modelID: string; variant?: string } | undefined,
|
||||
categoryModel: DelegatedModelConfig | undefined,
|
||||
systemContent: string | undefined,
|
||||
actualModel: string | undefined
|
||||
): Promise<string> {
|
||||
|
||||
Reference in New Issue
Block a user