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
@@ -1,5 +1,6 @@
declare const require: (name: string) => any
const { describe, test, expect, beforeEach, afterEach, spyOn } = require("bun:test")
import { getSessionPromptParams, clearSessionPromptParams } from "../../shared/session-prompt-params-state"
import { tmpdir } from "node:os"
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundTask, ResumeInput } from "./types"
@@ -1636,6 +1637,9 @@ describe("BackgroundManager.resume model persistence", () => {
})
afterEach(() => {
clearSessionPromptParams("session-1")
clearSessionPromptParams("session-advanced")
clearSessionPromptParams("session-2")
manager.shutdown()
})
@@ -1671,6 +1675,60 @@ describe("BackgroundManager.resume model persistence", () => {
expect(promptCalls[0].body.agent).toBe("explore")
})
test("should preserve promoted per-model settings when resuming a task", async () => {
// given - task resumed after fallback promotion
const taskWithAdvancedModel: BackgroundTask = {
id: "task-with-advanced-model",
sessionID: "session-advanced",
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "task with advanced model settings",
prompt: "original prompt",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
model: {
providerID: "openai",
modelID: "gpt-5.4-preview",
variant: "minimal",
reasoningEffort: "high",
temperature: 0.25,
top_p: 0.55,
maxTokens: 8192,
thinking: { type: "disabled" },
},
concurrencyGroup: "explore",
}
getTaskMap(manager).set(taskWithAdvancedModel.id, taskWithAdvancedModel)
// when
await manager.resume({
sessionId: "session-advanced",
prompt: "continue the work",
parentSessionID: "parent-session-2",
parentMessageID: "msg-2",
})
// then
expect(promptCalls).toHaveLength(1)
expect(promptCalls[0].body.model).toEqual({
providerID: "openai",
modelID: "gpt-5.4-preview",
})
expect(promptCalls[0].body.variant).toBe("minimal")
expect(promptCalls[0].body.options).toBeUndefined()
expect(getSessionPromptParams("session-advanced")).toEqual({
temperature: 0.25,
topP: 0.55,
options: {
reasoningEffort: "high",
thinking: { type: "disabled" },
maxTokens: 8192,
},
})
})
test("should NOT pass model when task has no model (backward compatibility)", async () => {
// given - task without model (default behavior)
const taskWithoutModel: BackgroundTask = {
+48 -7
View File
@@ -16,6 +16,35 @@ import {
createInternalAgentTextPart,
} from "../../shared"
import { setSessionTools } from "../../shared/session-tools-store"
import { setSessionPromptParams } from "../../shared/session-prompt-params-state"
type PromptParamsModel = {
reasoningEffort?: string
thinking?: { type: "enabled" | "disabled"; budgetTokens?: number }
maxTokens?: number
temperature?: number
top_p?: number
}
function applySessionPromptParams(sessionID: string, model: PromptParamsModel): void {
const promptOptions: Record<string, unknown> = {
...(model.reasoningEffort ? { reasoningEffort: model.reasoningEffort } : {}),
...(model.thinking ? { thinking: model.thinking } : {}),
...(model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}),
}
if (
model.temperature !== undefined ||
model.top_p !== undefined ||
Object.keys(promptOptions).length > 0
) {
setSessionPromptParams(sessionID, {
...(model.temperature !== undefined ? { temperature: model.temperature } : {}),
...(model.top_p !== undefined ? { topP: model.top_p } : {}),
...(Object.keys(promptOptions).length > 0 ? { options: promptOptions } : {}),
})
}
}
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { ConcurrencyManager } from "./concurrency"
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
@@ -504,14 +533,20 @@ export class BackgroundManager {
})
// Fire-and-forget prompt via promptAsync (no response body needed)
// Include model if caller provided one (e.g., from Sisyphus category configs)
// IMPORTANT: variant must be a top-level field in the body, NOT nested inside model
// OpenCode's PromptInput schema expects: { model: { providerID, modelID }, variant: "max" }
// OpenCode prompt payload accepts model provider/model IDs and top-level variant only.
// Temperature/topP and provider-specific options are applied through chat.params.
const launchModel = input.model
? { providerID: input.model.providerID, modelID: input.model.modelID }
? {
providerID: input.model.providerID,
modelID: input.model.modelID,
}
: undefined
const launchVariant = input.model?.variant
if (input.model) {
applySessionPromptParams(sessionID, input.model)
}
promptWithModelSuggestionRetry(this.client, {
path: { id: sessionID },
body: {
@@ -782,13 +817,19 @@ export class BackgroundManager {
})
// Fire-and-forget prompt via promptAsync (no response body needed)
// Include model if task has one (preserved from original launch with category config)
// variant must be top-level in body, not nested inside model (OpenCode PromptInput schema)
// Resume uses the same PromptInput contract as launch: model IDs plus top-level variant.
const resumeModel = existingTask.model
? { providerID: existingTask.model.providerID, modelID: existingTask.model.modelID }
? {
providerID: existingTask.model.providerID,
modelID: existingTask.model.modelID,
}
: undefined
const resumeVariant = existingTask.model?.variant
if (existingTask.model) {
applySessionPromptParams(existingTask.sessionID!, existingTask.model)
}
this.client.session.promptAsync({
path: { id: existingTask.sessionID },
body: {
+77 -49
View File
@@ -1,68 +1,96 @@
import { describe, test, expect } from "bun:test"
import { describe, test, expect, mock, afterEach } from "bun:test"
import { startTask } from "./spawner"
import type { BackgroundTask } from "./types"
import {
clearSessionPromptParams,
getSessionPromptParams,
} from "../../shared/session-prompt-params-state"
import { createTask, startTask } from "./spawner"
describe("background-agent spawner fallback model promotion", () => {
afterEach(() => {
clearSessionPromptParams("session-123")
})
describe("background-agent spawner.startTask", () => {
test("applies explicit child session permission rules when creating child session", async () => {
test("passes promoted fallback model settings through supported prompt channels", async () => {
//#given
const createCalls: any[] = []
const parentPermission = [
{ permission: "question", action: "allow" as const, pattern: "*" },
{ permission: "plan_enter", action: "deny" as const, pattern: "*" },
]
let promptArgs: any
const client = {
session: {
get: async () => ({ data: { directory: "/parent/dir", permission: parentPermission } }),
create: async (args?: any) => {
createCalls.push(args)
return { data: { id: "ses_child" } }
},
promptAsync: async () => ({}),
get: mock(async () => ({ data: { directory: "/tmp/test" } })),
create: mock(async () => ({ data: { id: "session-123" } })),
promptAsync: mock(async (input: any) => {
promptArgs = input
return { data: {} }
}),
},
}
} as any
const task = createTask({
const concurrencyManager = {
release: mock(() => {}),
} as any
const onTaskError = mock(() => {})
const task: BackgroundTask = {
id: "bg_test123",
status: "pending",
queuedAt: new Date(),
description: "Test task",
prompt: "Do work",
agent: "explore",
parentSessionID: "ses_parent",
parentMessageID: "msg_parent",
})
const item = {
task,
input: {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
parentModel: task.parentModel,
parentAgent: task.parentAgent,
model: task.model,
sessionPermission: [
{ permission: "question", action: "deny", pattern: "*" },
],
prompt: "Do the thing",
agent: "oracle",
parentSessionID: "parent-1",
parentMessageID: "message-1",
model: {
providerID: "openai",
modelID: "gpt-5.4",
variant: "low",
reasoningEffort: "high",
temperature: 0.4,
top_p: 0.7,
maxTokens: 4096,
thinking: { type: "disabled" },
},
}
const ctx = {
client,
directory: "/fallback",
concurrencyManager: { release: () => {} },
tmuxEnabled: false,
onTaskError: () => {},
const input = {
description: "Test task",
prompt: "Do the thing",
agent: "oracle",
parentSessionID: "parent-1",
parentMessageID: "message-1",
model: task.model,
}
//#when
await startTask(item as any, ctx as any)
await startTask(
{ task, input },
{
client,
directory: "/tmp/test",
concurrencyManager,
tmuxEnabled: false,
onTaskError,
},
)
await new Promise((resolve) => setTimeout(resolve, 0))
//#then
expect(createCalls).toHaveLength(1)
expect(createCalls[0]?.body?.permission).toEqual([
{ permission: "question", action: "deny", pattern: "*" },
])
expect(promptArgs.body.model).toEqual({
providerID: "openai",
modelID: "gpt-5.4",
})
expect(promptArgs.body.variant).toBe("low")
expect(promptArgs.body.options).toBeUndefined()
expect(getSessionPromptParams("session-123")).toEqual({
temperature: 0.4,
topP: 0.7,
options: {
reasoningEffort: "high",
thinking: { type: "disabled" },
maxTokens: 4096,
},
})
})
test("keeps agent when explicit model is configured", async () => {
+49 -2
View File
@@ -2,6 +2,7 @@ import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants"
import { TMUX_CALLBACK_DELAY_MS } from "./constants"
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry, createInternalAgentTextPart } from "../../shared"
import { setSessionPromptParams } from "../../shared/session-prompt-params-state"
import { subagentSessions } from "../claude-code-session-state"
import { getTaskToastManager } from "../task-toast-manager"
import { isInsideTmux } from "../../shared/tmux"
@@ -128,10 +129,33 @@ export async function startTask(
})
const launchModel = input.model
? { providerID: input.model.providerID, modelID: input.model.modelID }
? {
providerID: input.model.providerID,
modelID: input.model.modelID,
}
: undefined
const launchVariant = input.model?.variant
if (input.model) {
const promptOptions: Record<string, unknown> = {
...(input.model.reasoningEffort ? { reasoningEffort: input.model.reasoningEffort } : {}),
...(input.model.thinking ? { thinking: input.model.thinking } : {}),
...(input.model.maxTokens !== undefined ? { maxTokens: input.model.maxTokens } : {}),
}
if (
input.model.temperature !== undefined ||
input.model.top_p !== undefined ||
Object.keys(promptOptions).length > 0
) {
setSessionPromptParams(sessionID, {
...(input.model.temperature !== undefined ? { temperature: input.model.temperature } : {}),
...(input.model.top_p !== undefined ? { topP: input.model.top_p } : {}),
...(Object.keys(promptOptions).length > 0 ? { options: promptOptions } : {}),
})
}
}
promptWithModelSuggestionRetry(client, {
path: { id: sessionID },
body: {
@@ -213,10 +237,33 @@ export async function resumeTask(
})
const resumeModel = task.model
? { providerID: task.model.providerID, modelID: task.model.modelID }
? {
providerID: task.model.providerID,
modelID: task.model.modelID,
}
: undefined
const resumeVariant = task.model?.variant
if (task.model) {
const promptOptions: Record<string, unknown> = {
...(task.model.reasoningEffort ? { reasoningEffort: task.model.reasoningEffort } : {}),
...(task.model.thinking ? { thinking: task.model.thinking } : {}),
...(task.model.maxTokens !== undefined ? { maxTokens: task.model.maxTokens } : {}),
}
if (
task.model.temperature !== undefined ||
task.model.top_p !== undefined ||
Object.keys(promptOptions).length > 0
) {
setSessionPromptParams(task.sessionID, {
...(task.model.temperature !== undefined ? { temperature: task.model.temperature } : {}),
...(task.model.top_p !== undefined ? { topP: task.model.top_p } : {}),
...(Object.keys(promptOptions).length > 0 ? { options: promptOptions } : {}),
})
}
}
client.session.promptAsync({
path: { id: task.sessionID },
body: {
+13 -2
View File
@@ -25,6 +25,17 @@ export interface TaskProgress {
lastMessageAt?: Date
}
type DelegatedModelConfig = {
providerID: string
modelID: string
variant?: string
reasoningEffort?: string
temperature?: number
top_p?: number
maxTokens?: number
thinking?: { type: "enabled" | "disabled"; budgetTokens?: number }
}
export interface BackgroundTask {
id: string
sessionID?: string
@@ -43,7 +54,7 @@ export interface BackgroundTask {
error?: string
progress?: TaskProgress
parentModel?: { providerID: string; modelID: string }
model?: { providerID: string; modelID: string; variant?: string }
model?: DelegatedModelConfig
/** Fallback chain for runtime retry on model errors */
fallbackChain?: FallbackEntry[]
/** Number of fallback retry attempts made */
@@ -76,7 +87,7 @@ export interface LaunchInput {
parentModel?: { providerID: string; modelID: string }
parentAgent?: string
parentTools?: Record<string, boolean>
model?: { providerID: string; modelID: string; variant?: string }
model?: DelegatedModelConfig
/** Fallback chain for runtime retry on model errors */
fallbackChain?: FallbackEntry[]
isUnstableAgent?: boolean