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:
Ravi Tharuma
2026-03-18 14:21:27 +01:00
parent 7761e48dca
commit a77a16c494
34 changed files with 1686 additions and 126 deletions
+2 -2
View File
@@ -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()
})
})
+30 -6
View File
@@ -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,
+6 -6
View File
@@ -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()
})
})
+27 -8
View File
@@ -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)
})
+29 -3
View File
@@ -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 } : {}),
},
+2 -2
View File
@@ -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[],
+12 -1
View File
@@ -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
+12 -1
View File
@@ -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> {