Merge pull request #2622 from RaviTharuma/feat/object-style-fallback-models
feat(config): object-style fallback_models with per-model settings
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -123,7 +123,7 @@ export type AgentName = BuiltinAgentName;
|
|||||||
export type AgentOverrideConfig = Partial<AgentConfig> & {
|
export type AgentOverrideConfig = Partial<AgentConfig> & {
|
||||||
prompt_append?: string;
|
prompt_append?: string;
|
||||||
variant?: string;
|
variant?: string;
|
||||||
fallback_models?: string | string[];
|
fallback_models?: string | (string | import("../config/schema/fallback-models").FallbackModelObject)[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AgentOverrides = Partial<
|
export type AgentOverrides = Partial<
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
export const FallbackModelsSchema = z.union([z.string(), z.array(z.string())])
|
export const FallbackModelObjectSchema = z.object({
|
||||||
|
model: z.string(),
|
||||||
|
variant: z.string().optional(),
|
||||||
|
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(),
|
||||||
|
temperature: z.number().min(0).max(2).optional(),
|
||||||
|
top_p: z.number().min(0).max(1).optional(),
|
||||||
|
maxTokens: z.number().optional(),
|
||||||
|
thinking: z
|
||||||
|
.object({
|
||||||
|
type: z.enum(["enabled", "disabled"]),
|
||||||
|
budgetTokens: z.number().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type FallbackModelObject = z.infer<typeof FallbackModelObjectSchema>
|
||||||
|
|
||||||
|
export const FallbackModelsSchema = z.union([
|
||||||
|
z.string(),
|
||||||
|
z.array(z.union([z.string(), FallbackModelObjectSchema])),
|
||||||
|
])
|
||||||
|
|
||||||
export type FallbackModels = z.infer<typeof FallbackModelsSchema>
|
export type FallbackModels = z.infer<typeof FallbackModelsSchema>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
declare const require: (name: string) => any
|
declare const require: (name: string) => any
|
||||||
const { describe, test, expect, beforeEach, afterEach, spyOn } = require("bun:test")
|
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 { tmpdir } from "node:os"
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import type { BackgroundTask, ResumeInput } from "./types"
|
import type { BackgroundTask, ResumeInput } from "./types"
|
||||||
@@ -1636,6 +1637,9 @@ describe("BackgroundManager.resume model persistence", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
clearSessionPromptParams("session-1")
|
||||||
|
clearSessionPromptParams("session-advanced")
|
||||||
|
clearSessionPromptParams("session-2")
|
||||||
manager.shutdown()
|
manager.shutdown()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1671,6 +1675,60 @@ describe("BackgroundManager.resume model persistence", () => {
|
|||||||
expect(promptCalls[0].body.agent).toBe("explore")
|
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 () => {
|
test("should NOT pass model when task has no model (backward compatibility)", async () => {
|
||||||
// given - task without model (default behavior)
|
// given - task without model (default behavior)
|
||||||
const taskWithoutModel: BackgroundTask = {
|
const taskWithoutModel: BackgroundTask = {
|
||||||
|
|||||||
@@ -16,6 +16,35 @@ import {
|
|||||||
createInternalAgentTextPart,
|
createInternalAgentTextPart,
|
||||||
} from "../../shared"
|
} from "../../shared"
|
||||||
import { setSessionTools } from "../../shared/session-tools-store"
|
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 { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||||
import { ConcurrencyManager } from "./concurrency"
|
import { ConcurrencyManager } from "./concurrency"
|
||||||
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
|
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
|
||||||
@@ -504,14 +533,20 @@ export class BackgroundManager {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Fire-and-forget prompt via promptAsync (no response body needed)
|
// Fire-and-forget prompt via promptAsync (no response body needed)
|
||||||
// Include model if caller provided one (e.g., from Sisyphus category configs)
|
// OpenCode prompt payload accepts model provider/model IDs and top-level variant only.
|
||||||
// IMPORTANT: variant must be a top-level field in the body, NOT nested inside model
|
// Temperature/topP and provider-specific options are applied through chat.params.
|
||||||
// OpenCode's PromptInput schema expects: { model: { providerID, modelID }, variant: "max" }
|
|
||||||
const launchModel = input.model
|
const launchModel = input.model
|
||||||
? { providerID: input.model.providerID, modelID: input.model.modelID }
|
? {
|
||||||
|
providerID: input.model.providerID,
|
||||||
|
modelID: input.model.modelID,
|
||||||
|
}
|
||||||
: undefined
|
: undefined
|
||||||
const launchVariant = input.model?.variant
|
const launchVariant = input.model?.variant
|
||||||
|
|
||||||
|
if (input.model) {
|
||||||
|
applySessionPromptParams(sessionID, input.model)
|
||||||
|
}
|
||||||
|
|
||||||
promptWithModelSuggestionRetry(this.client, {
|
promptWithModelSuggestionRetry(this.client, {
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
body: {
|
body: {
|
||||||
@@ -782,13 +817,19 @@ export class BackgroundManager {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Fire-and-forget prompt via promptAsync (no response body needed)
|
// Fire-and-forget prompt via promptAsync (no response body needed)
|
||||||
// Include model if task has one (preserved from original launch with category config)
|
// Resume uses the same PromptInput contract as launch: model IDs plus top-level variant.
|
||||||
// variant must be top-level in body, not nested inside model (OpenCode PromptInput schema)
|
|
||||||
const resumeModel = existingTask.model
|
const resumeModel = existingTask.model
|
||||||
? { providerID: existingTask.model.providerID, modelID: existingTask.model.modelID }
|
? {
|
||||||
|
providerID: existingTask.model.providerID,
|
||||||
|
modelID: existingTask.model.modelID,
|
||||||
|
}
|
||||||
: undefined
|
: undefined
|
||||||
const resumeVariant = existingTask.model?.variant
|
const resumeVariant = existingTask.model?.variant
|
||||||
|
|
||||||
|
if (existingTask.model) {
|
||||||
|
applySessionPromptParams(existingTask.sessionID!, existingTask.model)
|
||||||
|
}
|
||||||
|
|
||||||
this.client.session.promptAsync({
|
this.client.session.promptAsync({
|
||||||
path: { id: existingTask.sessionID },
|
path: { id: existingTask.sessionID },
|
||||||
body: {
|
body: {
|
||||||
|
|||||||
@@ -1,68 +1,96 @@
|
|||||||
import { describe, test, expect } from "bun:test"
|
import { describe, test, expect, mock, afterEach } from "bun:test"
|
||||||
|
|
||||||
import { createTask, startTask } from "./spawner"
|
import { createTask, startTask } from "./spawner"
|
||||||
|
import type { BackgroundTask } from "./types"
|
||||||
|
import {
|
||||||
|
clearSessionPromptParams,
|
||||||
|
getSessionPromptParams,
|
||||||
|
} from "../../shared/session-prompt-params-state"
|
||||||
|
|
||||||
describe("background-agent spawner.startTask", () => {
|
describe("background-agent spawner fallback model promotion", () => {
|
||||||
test("applies explicit child session permission rules when creating child session", async () => {
|
afterEach(() => {
|
||||||
|
clearSessionPromptParams("session-123")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("passes promoted fallback model settings through supported prompt channels", async () => {
|
||||||
//#given
|
//#given
|
||||||
const createCalls: any[] = []
|
let promptArgs: any
|
||||||
const parentPermission = [
|
|
||||||
{ permission: "question", action: "allow" as const, pattern: "*" },
|
|
||||||
{ permission: "plan_enter", action: "deny" as const, pattern: "*" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const client = {
|
const client = {
|
||||||
session: {
|
session: {
|
||||||
get: async () => ({ data: { directory: "/parent/dir", permission: parentPermission } }),
|
get: mock(async () => ({ data: { directory: "/tmp/test" } })),
|
||||||
create: async (args?: any) => {
|
create: mock(async () => ({ data: { id: "session-123" } })),
|
||||||
createCalls.push(args)
|
promptAsync: mock(async (input: any) => {
|
||||||
return { data: { id: "ses_child" } }
|
promptArgs = input
|
||||||
},
|
return { data: {} }
|
||||||
promptAsync: async () => ({}),
|
}),
|
||||||
},
|
},
|
||||||
}
|
} 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",
|
description: "Test task",
|
||||||
prompt: "Do work",
|
prompt: "Do the thing",
|
||||||
agent: "explore",
|
agent: "oracle",
|
||||||
parentSessionID: "ses_parent",
|
parentSessionID: "parent-1",
|
||||||
parentMessageID: "msg_parent",
|
parentMessageID: "message-1",
|
||||||
})
|
model: {
|
||||||
|
providerID: "openai",
|
||||||
const item = {
|
modelID: "gpt-5.4",
|
||||||
task,
|
variant: "low",
|
||||||
input: {
|
reasoningEffort: "high",
|
||||||
description: task.description,
|
temperature: 0.4,
|
||||||
prompt: task.prompt,
|
top_p: 0.7,
|
||||||
agent: task.agent,
|
maxTokens: 4096,
|
||||||
parentSessionID: task.parentSessionID,
|
thinking: { type: "disabled" },
|
||||||
parentMessageID: task.parentMessageID,
|
|
||||||
parentModel: task.parentModel,
|
|
||||||
parentAgent: task.parentAgent,
|
|
||||||
model: task.model,
|
|
||||||
sessionPermission: [
|
|
||||||
{ permission: "question", action: "deny", pattern: "*" },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx = {
|
const input = {
|
||||||
client,
|
description: "Test task",
|
||||||
directory: "/fallback",
|
prompt: "Do the thing",
|
||||||
concurrencyManager: { release: () => {} },
|
agent: "oracle",
|
||||||
tmuxEnabled: false,
|
parentSessionID: "parent-1",
|
||||||
onTaskError: () => {},
|
parentMessageID: "message-1",
|
||||||
|
model: task.model,
|
||||||
}
|
}
|
||||||
|
|
||||||
//#when
|
//#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
|
//#then
|
||||||
expect(createCalls).toHaveLength(1)
|
expect(promptArgs.body.model).toEqual({
|
||||||
expect(createCalls[0]?.body?.permission).toEqual([
|
providerID: "openai",
|
||||||
{ permission: "question", action: "deny", pattern: "*" },
|
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 () => {
|
test("keeps agent when explicit model is configured", async () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
|
|||||||
import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants"
|
import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants"
|
||||||
import { TMUX_CALLBACK_DELAY_MS } from "./constants"
|
import { TMUX_CALLBACK_DELAY_MS } from "./constants"
|
||||||
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry, createInternalAgentTextPart } from "../../shared"
|
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry, createInternalAgentTextPart } from "../../shared"
|
||||||
|
import { setSessionPromptParams } from "../../shared/session-prompt-params-state"
|
||||||
import { subagentSessions } from "../claude-code-session-state"
|
import { subagentSessions } from "../claude-code-session-state"
|
||||||
import { getTaskToastManager } from "../task-toast-manager"
|
import { getTaskToastManager } from "../task-toast-manager"
|
||||||
import { isInsideTmux } from "../../shared/tmux"
|
import { isInsideTmux } from "../../shared/tmux"
|
||||||
@@ -128,10 +129,33 @@ export async function startTask(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const launchModel = input.model
|
const launchModel = input.model
|
||||||
? { providerID: input.model.providerID, modelID: input.model.modelID }
|
? {
|
||||||
|
providerID: input.model.providerID,
|
||||||
|
modelID: input.model.modelID,
|
||||||
|
}
|
||||||
: undefined
|
: undefined
|
||||||
const launchVariant = input.model?.variant
|
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, {
|
promptWithModelSuggestionRetry(client, {
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
body: {
|
body: {
|
||||||
@@ -213,10 +237,33 @@ export async function resumeTask(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const resumeModel = task.model
|
const resumeModel = task.model
|
||||||
? { providerID: task.model.providerID, modelID: task.model.modelID }
|
? {
|
||||||
|
providerID: task.model.providerID,
|
||||||
|
modelID: task.model.modelID,
|
||||||
|
}
|
||||||
: undefined
|
: undefined
|
||||||
const resumeVariant = task.model?.variant
|
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({
|
client.session.promptAsync({
|
||||||
path: { id: task.sessionID },
|
path: { id: task.sessionID },
|
||||||
body: {
|
body: {
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ export interface TaskProgress {
|
|||||||
lastMessageAt?: Date
|
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 {
|
export interface BackgroundTask {
|
||||||
id: string
|
id: string
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
@@ -43,7 +54,7 @@ export interface BackgroundTask {
|
|||||||
error?: string
|
error?: string
|
||||||
progress?: TaskProgress
|
progress?: TaskProgress
|
||||||
parentModel?: { providerID: string; modelID: string }
|
parentModel?: { providerID: string; modelID: string }
|
||||||
model?: { providerID: string; modelID: string; variant?: string }
|
model?: DelegatedModelConfig
|
||||||
/** Fallback chain for runtime retry on model errors */
|
/** Fallback chain for runtime retry on model errors */
|
||||||
fallbackChain?: FallbackEntry[]
|
fallbackChain?: FallbackEntry[]
|
||||||
/** Number of fallback retry attempts made */
|
/** Number of fallback retry attempts made */
|
||||||
@@ -76,7 +87,7 @@ export interface LaunchInput {
|
|||||||
parentModel?: { providerID: string; modelID: string }
|
parentModel?: { providerID: string; modelID: string }
|
||||||
parentAgent?: string
|
parentAgent?: string
|
||||||
parentTools?: Record<string, boolean>
|
parentTools?: Record<string, boolean>
|
||||||
model?: { providerID: string; modelID: string; variant?: string }
|
model?: DelegatedModelConfig
|
||||||
/** Fallback chain for runtime retry on model errors */
|
/** Fallback chain for runtime retry on model errors */
|
||||||
fallbackChain?: FallbackEntry[]
|
fallbackChain?: FallbackEntry[]
|
||||||
isUnstableAgent?: boolean
|
isUnstableAgent?: boolean
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import type { OhMyOpenCodeConfig } from "../../config"
|
import type { OhMyOpenCodeConfig } from "../../config"
|
||||||
|
import type { FallbackModelObject } from "../../config/schema/fallback-models"
|
||||||
import { agentPattern } from "./agent-resolver"
|
import { agentPattern } from "./agent-resolver"
|
||||||
import { HOOK_NAME } from "./constants"
|
import { HOOK_NAME } from "./constants"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||||
import { normalizeFallbackModels } from "../../shared/model-resolver"
|
import { normalizeFallbackModels, flattenToFallbackModelStrings } from "../../shared/model-resolver"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns fallback model strings for the runtime-fallback system.
|
||||||
|
* Object entries are flattened to "provider/model(variant)" strings so the
|
||||||
|
* string-based fallback state machine can work with them unchanged.
|
||||||
|
*/
|
||||||
export function getFallbackModelsForSession(
|
export function getFallbackModelsForSession(
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
agent: string | undefined,
|
agent: string | undefined,
|
||||||
@@ -12,15 +18,38 @@ export function getFallbackModelsForSession(
|
|||||||
): string[] {
|
): string[] {
|
||||||
if (!pluginConfig) return []
|
if (!pluginConfig) return []
|
||||||
|
|
||||||
|
const raw = getRawFallbackModelsForSession(sessionID, agent, pluginConfig)
|
||||||
|
return flattenToFallbackModelStrings(raw) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the raw fallback model entries (strings and objects) for a session.
|
||||||
|
* Use this when per-model settings (temperature, reasoningEffort, etc.) must be
|
||||||
|
* preserved — e.g. before passing to buildFallbackChainFromModels.
|
||||||
|
*/
|
||||||
|
export function getRawFallbackModels(
|
||||||
|
sessionID: string,
|
||||||
|
agent: string | undefined,
|
||||||
|
pluginConfig: OhMyOpenCodeConfig | undefined,
|
||||||
|
): (string | FallbackModelObject)[] | undefined {
|
||||||
|
if (!pluginConfig) return undefined
|
||||||
|
return getRawFallbackModelsForSession(sessionID, agent, pluginConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRawFallbackModelsForSession(
|
||||||
|
sessionID: string,
|
||||||
|
agent: string | undefined,
|
||||||
|
pluginConfig: OhMyOpenCodeConfig,
|
||||||
|
): (string | FallbackModelObject)[] | undefined {
|
||||||
const sessionCategory = SessionCategoryRegistry.get(sessionID)
|
const sessionCategory = SessionCategoryRegistry.get(sessionID)
|
||||||
if (sessionCategory && pluginConfig.categories?.[sessionCategory]) {
|
if (sessionCategory && pluginConfig.categories?.[sessionCategory]) {
|
||||||
const categoryConfig = pluginConfig.categories[sessionCategory]
|
const categoryConfig = pluginConfig.categories[sessionCategory]
|
||||||
if (categoryConfig?.fallback_models) {
|
if (categoryConfig?.fallback_models) {
|
||||||
return normalizeFallbackModels(categoryConfig.fallback_models) ?? []
|
return normalizeFallbackModels(categoryConfig.fallback_models)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tryGetFallbackFromAgent = (agentName: string): string[] | undefined => {
|
const tryGetFallbackFromAgent = (agentName: string): (string | FallbackModelObject)[] | undefined => {
|
||||||
const agentConfig = pluginConfig.agents?.[agentName as keyof typeof pluginConfig.agents]
|
const agentConfig = pluginConfig.agents?.[agentName as keyof typeof pluginConfig.agents]
|
||||||
if (!agentConfig) return undefined
|
if (!agentConfig) return undefined
|
||||||
|
|
||||||
@@ -53,5 +82,5 @@ export function getFallbackModelsForSession(
|
|||||||
|
|
||||||
log(`[${HOOK_NAME}] No category/agent fallback models resolved for session`, { sessionID, agent })
|
log(`[${HOOK_NAME}] No category/agent fallback models resolved for session`, { sessionID, agent })
|
||||||
|
|
||||||
return []
|
return undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import { createChatParamsHandler } from "./chat-params"
|
import { createChatParamsHandler } from "./chat-params"
|
||||||
|
import {
|
||||||
|
clearSessionPromptParams,
|
||||||
|
getSessionPromptParams,
|
||||||
|
setSessionPromptParams,
|
||||||
|
} from "../shared/session-prompt-params-state"
|
||||||
|
|
||||||
describe("createChatParamsHandler", () => {
|
describe("createChatParamsHandler", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
clearSessionPromptParams("ses_chat_params")
|
||||||
|
})
|
||||||
|
|
||||||
test("normalizes object-style agent payload and runs chat.params hooks", async () => {
|
test("normalizes object-style agent payload and runs chat.params hooks", async () => {
|
||||||
//#given
|
//#given
|
||||||
let called = false
|
let called = false
|
||||||
@@ -35,7 +44,6 @@ describe("createChatParamsHandler", () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(called).toBe(true)
|
expect(called).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("passes the original mutable message object to chat.params hooks", async () => {
|
test("passes the original mutable message object to chat.params hooks", async () => {
|
||||||
//#given
|
//#given
|
||||||
const handler = createChatParamsHandler({
|
const handler = createChatParamsHandler({
|
||||||
@@ -68,4 +76,61 @@ describe("createChatParamsHandler", () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(message.variant).toBe("high")
|
expect(message.variant).toBe("high")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("applies stored prompt params for the session", async () => {
|
||||||
|
//#given
|
||||||
|
setSessionPromptParams("ses_chat_params", {
|
||||||
|
temperature: 0.4,
|
||||||
|
topP: 0.7,
|
||||||
|
options: {
|
||||||
|
reasoningEffort: "high",
|
||||||
|
thinking: { type: "disabled" },
|
||||||
|
maxTokens: 4096,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handler = createChatParamsHandler({
|
||||||
|
anthropicEffort: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
sessionID: "ses_chat_params",
|
||||||
|
agent: { name: "oracle" },
|
||||||
|
model: { providerID: "openai", modelID: "gpt-5.4" },
|
||||||
|
provider: { id: "openai" },
|
||||||
|
message: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
temperature: 0.1,
|
||||||
|
topP: 1,
|
||||||
|
topK: 1,
|
||||||
|
options: { existing: true },
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await handler(input, output)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(output).toEqual({
|
||||||
|
temperature: 0.4,
|
||||||
|
topP: 0.7,
|
||||||
|
topK: 1,
|
||||||
|
options: {
|
||||||
|
existing: true,
|
||||||
|
reasoningEffort: "high",
|
||||||
|
thinking: { type: "disabled" },
|
||||||
|
maxTokens: 4096,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(getSessionPromptParams("ses_chat_params")).toEqual({
|
||||||
|
temperature: 0.4,
|
||||||
|
topP: 0.7,
|
||||||
|
options: {
|
||||||
|
reasoningEffort: "high",
|
||||||
|
thinking: { type: "disabled" },
|
||||||
|
maxTokens: 4096,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getSessionPromptParams } from "../shared/session-prompt-params-state"
|
||||||
|
|
||||||
export type ChatParamsInput = {
|
export type ChatParamsInput = {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
agent: { name?: string }
|
agent: { name?: string }
|
||||||
@@ -82,6 +84,22 @@ export function createChatParamsHandler(args: {
|
|||||||
if (!normalizedInput) return
|
if (!normalizedInput) return
|
||||||
if (!isChatParamsOutput(output)) return
|
if (!isChatParamsOutput(output)) return
|
||||||
|
|
||||||
|
const storedPromptParams = getSessionPromptParams(normalizedInput.sessionID)
|
||||||
|
if (storedPromptParams) {
|
||||||
|
if (storedPromptParams.temperature !== undefined) {
|
||||||
|
output.temperature = storedPromptParams.temperature
|
||||||
|
}
|
||||||
|
if (storedPromptParams.topP !== undefined) {
|
||||||
|
output.topP = storedPromptParams.topP
|
||||||
|
}
|
||||||
|
if (storedPromptParams.options) {
|
||||||
|
output.options = {
|
||||||
|
...output.options,
|
||||||
|
...storedPromptParams.options,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await args.anthropicEffort?.["chat.params"]?.(normalizedInput, output)
|
await args.anthropicEffort?.["chat.params"]?.(normalizedInput, output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "bun:test"
|
|||||||
|
|
||||||
import { _resetForTesting, getSessionAgent, updateSessionAgent } from "../features/claude-code-session-state"
|
import { _resetForTesting, getSessionAgent, updateSessionAgent } from "../features/claude-code-session-state"
|
||||||
import { clearSessionModel, getSessionModel, setSessionModel } from "../shared/session-model-state"
|
import { clearSessionModel, getSessionModel, setSessionModel } from "../shared/session-model-state"
|
||||||
|
import { clearSessionPromptParams } from "../shared/session-prompt-params-state"
|
||||||
import { createEventHandler } from "./event"
|
import { createEventHandler } from "./event"
|
||||||
|
|
||||||
function createMinimalEventHandler() {
|
function createMinimalEventHandler() {
|
||||||
@@ -53,6 +54,8 @@ describe("createEventHandler compaction agent filtering", () => {
|
|||||||
_resetForTesting()
|
_resetForTesting()
|
||||||
clearSessionModel("ses_compaction_poisoning")
|
clearSessionModel("ses_compaction_poisoning")
|
||||||
clearSessionModel("ses_compaction_model_poisoning")
|
clearSessionModel("ses_compaction_model_poisoning")
|
||||||
|
clearSessionPromptParams("ses_compaction_poisoning")
|
||||||
|
clearSessionPromptParams("ses_compaction_model_poisoning")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("does not overwrite the stored session agent with compaction", async () => {
|
it("does not overwrite the stored session agent with compaction", async () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createEventHandler } from "./event"
|
|||||||
import { createChatMessageHandler } from "./chat-message"
|
import { createChatMessageHandler } from "./chat-message"
|
||||||
import { _resetForTesting, setMainSession } from "../features/claude-code-session-state"
|
import { _resetForTesting, setMainSession } from "../features/claude-code-session-state"
|
||||||
import { clearPendingModelFallback, createModelFallbackHook } from "../hooks/model-fallback/hook"
|
import { clearPendingModelFallback, createModelFallbackHook } from "../hooks/model-fallback/hook"
|
||||||
|
import { getSessionPromptParams, setSessionPromptParams } from "../shared/session-prompt-params-state"
|
||||||
|
|
||||||
type EventInput = { event: { type: string; properties?: unknown } }
|
type EventInput = { event: { type: string; properties?: unknown } }
|
||||||
|
|
||||||
@@ -441,6 +442,45 @@ describe("createEventHandler - event forwarding", () => {
|
|||||||
expect(disconnectedSessions).toEqual([sessionID])
|
expect(disconnectedSessions).toEqual([sessionID])
|
||||||
expect(deletedSessions).toEqual([sessionID])
|
expect(deletedSessions).toEqual([sessionID])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("clears stored prompt params on session.deleted", async () => {
|
||||||
|
//#given
|
||||||
|
const eventHandler = createEventHandler({
|
||||||
|
ctx: {} as never,
|
||||||
|
pluginConfig: {} as never,
|
||||||
|
firstMessageVariantGate: {
|
||||||
|
markSessionCreated: () => {},
|
||||||
|
clear: () => {},
|
||||||
|
},
|
||||||
|
managers: {
|
||||||
|
skillMcpManager: {
|
||||||
|
disconnectSession: async () => {},
|
||||||
|
},
|
||||||
|
tmuxSessionManager: {
|
||||||
|
onSessionCreated: async () => {},
|
||||||
|
onSessionDeleted: async () => {},
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
hooks: {} as never,
|
||||||
|
})
|
||||||
|
const sessionID = "ses_prompt_params_deleted"
|
||||||
|
setSessionPromptParams(sessionID, {
|
||||||
|
temperature: 0.4,
|
||||||
|
topP: 0.7,
|
||||||
|
options: { reasoningEffort: "high" },
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await eventHandler({
|
||||||
|
event: {
|
||||||
|
type: "session.deleted",
|
||||||
|
properties: { info: { id: sessionID } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(getSessionPromptParams(sessionID)).toBeUndefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("createEventHandler - retry dedupe lifecycle", () => {
|
describe("createEventHandler - retry dedupe lifecycle", () => {
|
||||||
|
|||||||
+6
-4
@@ -16,7 +16,7 @@ import {
|
|||||||
setSessionFallbackChain,
|
setSessionFallbackChain,
|
||||||
setPendingModelFallback,
|
setPendingModelFallback,
|
||||||
} from "../hooks/model-fallback/hook";
|
} from "../hooks/model-fallback/hook";
|
||||||
import { getFallbackModelsForSession } from "../hooks/runtime-fallback/fallback-models";
|
import { getRawFallbackModels } from "../hooks/runtime-fallback/fallback-models";
|
||||||
import { resetMessageCursor } from "../shared";
|
import { resetMessageCursor } from "../shared";
|
||||||
import { getAgentConfigKey } from "../shared/agent-display-names";
|
import { getAgentConfigKey } from "../shared/agent-display-names";
|
||||||
import { readConnectedProvidersCache } from "../shared/connected-providers-cache";
|
import { readConnectedProvidersCache } from "../shared/connected-providers-cache";
|
||||||
@@ -25,6 +25,7 @@ import { shouldRetryError } from "../shared/model-error-classifier";
|
|||||||
import { buildFallbackChainFromModels } from "../shared/fallback-chain-from-models";
|
import { buildFallbackChainFromModels } from "../shared/fallback-chain-from-models";
|
||||||
import { extractRetryAttempt, normalizeRetryStatusMessage } from "../shared/retry-status-utils";
|
import { extractRetryAttempt, normalizeRetryStatusMessage } from "../shared/retry-status-utils";
|
||||||
import { clearSessionModel, getSessionModel, setSessionModel } from "../shared/session-model-state";
|
import { clearSessionModel, getSessionModel, setSessionModel } from "../shared/session-model-state";
|
||||||
|
import { clearSessionPromptParams } from "../shared/session-prompt-params-state";
|
||||||
import { deleteSessionTools } from "../shared/session-tools-store";
|
import { deleteSessionTools } from "../shared/session-tools-store";
|
||||||
import { lspManager } from "../tools";
|
import { lspManager } from "../tools";
|
||||||
|
|
||||||
@@ -110,10 +111,10 @@ function applyUserConfiguredFallbackChain(
|
|||||||
pluginConfig: OhMyOpenCodeConfig,
|
pluginConfig: OhMyOpenCodeConfig,
|
||||||
): void {
|
): void {
|
||||||
const agentKey = getAgentConfigKey(agentName);
|
const agentKey = getAgentConfigKey(agentName);
|
||||||
const configuredFallbackModels = getFallbackModelsForSession(sessionID, agentKey, pluginConfig);
|
const rawFallbackModels = getRawFallbackModels(sessionID, agentKey, pluginConfig);
|
||||||
if (configuredFallbackModels.length === 0) return;
|
if (!rawFallbackModels || rawFallbackModels.length === 0) return;
|
||||||
|
|
||||||
const fallbackChain = buildFallbackChainFromModels(configuredFallbackModels, currentProviderID);
|
const fallbackChain = buildFallbackChainFromModels(rawFallbackModels, currentProviderID);
|
||||||
|
|
||||||
if (fallbackChain && fallbackChain.length > 0) {
|
if (fallbackChain && fallbackChain.length > 0) {
|
||||||
setSessionFallbackChain(sessionID, fallbackChain);
|
setSessionFallbackChain(sessionID, fallbackChain);
|
||||||
@@ -330,6 +331,7 @@ export function createEventHandler(args: {
|
|||||||
resetMessageCursor(sessionInfo.id);
|
resetMessageCursor(sessionInfo.id);
|
||||||
firstMessageVariantGate.clear(sessionInfo.id);
|
firstMessageVariantGate.clear(sessionInfo.id);
|
||||||
clearSessionModel(sessionInfo.id);
|
clearSessionModel(sessionInfo.id);
|
||||||
|
clearSessionPromptParams(sessionInfo.id);
|
||||||
syncSubagentSessions.delete(sessionInfo.id);
|
syncSubagentSessions.delete(sessionInfo.id);
|
||||||
if (wasSyncSubagentSession) {
|
if (wasSyncSubagentSession) {
|
||||||
subagentSessions.delete(sessionInfo.id);
|
subagentSessions.delete(sessionInfo.id);
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { describe, test, expect } from "bun:test"
|
import { describe, test, it, expect } from "bun:test"
|
||||||
import { buildFallbackChainFromModels, parseFallbackModelEntry } from "./fallback-chain-from-models"
|
import {
|
||||||
|
parseFallbackModelEntry,
|
||||||
|
parseFallbackModelObjectEntry,
|
||||||
|
buildFallbackChainFromModels,
|
||||||
|
findMostSpecificFallbackEntry,
|
||||||
|
} from "./fallback-chain-from-models"
|
||||||
|
import { flattenToFallbackModelStrings } from "./model-resolver"
|
||||||
|
|
||||||
|
// Upstream tests
|
||||||
describe("fallback-chain-from-models", () => {
|
describe("fallback-chain-from-models", () => {
|
||||||
test("parses provider/model entry with parenthesized variant", () => {
|
test("parses provider/model entry with parenthesized variant", () => {
|
||||||
//#given
|
//#given
|
||||||
@@ -61,3 +68,330 @@ describe("fallback-chain-from-models", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Object-style entry tests
|
||||||
|
describe("parseFallbackModelEntry (extended)", () => {
|
||||||
|
it("parses provider/model string", () => {
|
||||||
|
const result = parseFallbackModelEntry("anthropic/claude-sonnet-4-6", undefined)
|
||||||
|
expect(result).toEqual({
|
||||||
|
providers: ["anthropic"],
|
||||||
|
model: "claude-sonnet-4-6",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("parses model with parenthesized variant", () => {
|
||||||
|
const result = parseFallbackModelEntry("anthropic/claude-sonnet-4-6(high)", undefined)
|
||||||
|
expect(result).toEqual({
|
||||||
|
providers: ["anthropic"],
|
||||||
|
model: "claude-sonnet-4-6",
|
||||||
|
variant: "high",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("parses model with space variant", () => {
|
||||||
|
const result = parseFallbackModelEntry("openai/gpt-5.4 xhigh", undefined)
|
||||||
|
expect(result).toEqual({
|
||||||
|
providers: ["openai"],
|
||||||
|
model: "gpt-5.4",
|
||||||
|
variant: "xhigh",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("parses model with minimal space variant", () => {
|
||||||
|
const result = parseFallbackModelEntry("openai/gpt-5.4 minimal", undefined)
|
||||||
|
expect(result).toEqual({
|
||||||
|
providers: ["openai"],
|
||||||
|
model: "gpt-5.4",
|
||||||
|
variant: "minimal",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses context provider when no provider prefix", () => {
|
||||||
|
const result = parseFallbackModelEntry("claude-sonnet-4-6", "anthropic")
|
||||||
|
expect(result).toEqual({
|
||||||
|
providers: ["anthropic"],
|
||||||
|
model: "claude-sonnet-4-6",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns undefined for empty string", () => {
|
||||||
|
expect(parseFallbackModelEntry("", undefined)).toBeUndefined()
|
||||||
|
expect(parseFallbackModelEntry(" ", undefined)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("parseFallbackModelObjectEntry", () => {
|
||||||
|
it("parses object with model only", () => {
|
||||||
|
const result = parseFallbackModelObjectEntry(
|
||||||
|
{ model: "anthropic/claude-sonnet-4-6" },
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
expect(result).toEqual({
|
||||||
|
providers: ["anthropic"],
|
||||||
|
model: "claude-sonnet-4-6",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("parses object with variant override", () => {
|
||||||
|
const result = parseFallbackModelObjectEntry(
|
||||||
|
{ model: "anthropic/claude-sonnet-4-6", variant: "high" },
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
expect(result).toEqual({
|
||||||
|
providers: ["anthropic"],
|
||||||
|
model: "claude-sonnet-4-6",
|
||||||
|
variant: "high",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("object variant overrides inline variant", () => {
|
||||||
|
const result = parseFallbackModelObjectEntry(
|
||||||
|
{ model: "anthropic/claude-sonnet-4-6(low)", variant: "high" },
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
expect(result).toEqual({
|
||||||
|
providers: ["anthropic"],
|
||||||
|
model: "claude-sonnet-4-6",
|
||||||
|
variant: "high",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("carries reasoningEffort and temperature", () => {
|
||||||
|
const result = parseFallbackModelObjectEntry(
|
||||||
|
{
|
||||||
|
model: "openai/gpt-5.4",
|
||||||
|
variant: "high",
|
||||||
|
reasoningEffort: "high",
|
||||||
|
temperature: 0.5,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
expect(result).toEqual({
|
||||||
|
providers: ["openai"],
|
||||||
|
model: "gpt-5.4",
|
||||||
|
variant: "high",
|
||||||
|
reasoningEffort: "high",
|
||||||
|
temperature: 0.5,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("carries thinking config", () => {
|
||||||
|
const result = parseFallbackModelObjectEntry(
|
||||||
|
{
|
||||||
|
model: "anthropic/claude-sonnet-4-6",
|
||||||
|
thinking: { type: "enabled", budgetTokens: 10000 },
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
expect(result).toEqual({
|
||||||
|
providers: ["anthropic"],
|
||||||
|
model: "claude-sonnet-4-6",
|
||||||
|
thinking: { type: "enabled", budgetTokens: 10000 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("carries all optional fields", () => {
|
||||||
|
const result = parseFallbackModelObjectEntry(
|
||||||
|
{
|
||||||
|
model: "openai/gpt-5.4",
|
||||||
|
variant: "xhigh",
|
||||||
|
reasoningEffort: "xhigh",
|
||||||
|
temperature: 0.3,
|
||||||
|
top_p: 0.9,
|
||||||
|
maxTokens: 8192,
|
||||||
|
thinking: { type: "disabled" },
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
expect(result).toEqual({
|
||||||
|
providers: ["openai"],
|
||||||
|
model: "gpt-5.4",
|
||||||
|
variant: "xhigh",
|
||||||
|
reasoningEffort: "xhigh",
|
||||||
|
temperature: 0.3,
|
||||||
|
top_p: 0.9,
|
||||||
|
maxTokens: 8192,
|
||||||
|
thinking: { type: "disabled" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("buildFallbackChainFromModels (mixed)", () => {
|
||||||
|
it("handles string input", () => {
|
||||||
|
const result = buildFallbackChainFromModels("anthropic/claude-sonnet-4-6", undefined)
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles string array", () => {
|
||||||
|
const result = buildFallbackChainFromModels(
|
||||||
|
["anthropic/claude-sonnet-4-6", "openai/gpt-5.4"],
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
||||||
|
{ providers: ["openai"], model: "gpt-5.4" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles mixed array of strings and objects", () => {
|
||||||
|
const result = buildFallbackChainFromModels(
|
||||||
|
[
|
||||||
|
{ model: "anthropic/claude-sonnet-4-6", variant: "high", reasoningEffort: "high" },
|
||||||
|
{ model: "openai/gpt-5.4", reasoningEffort: "xhigh" },
|
||||||
|
"chutes/kimi-k2.5",
|
||||||
|
{ model: "chutes/glm-5", temperature: 0.7 },
|
||||||
|
"google/gemini-3-flash",
|
||||||
|
],
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ providers: ["anthropic"], model: "claude-sonnet-4-6", variant: "high", reasoningEffort: "high" },
|
||||||
|
{ providers: ["openai"], model: "gpt-5.4", reasoningEffort: "xhigh" },
|
||||||
|
{ providers: ["chutes"], model: "kimi-k2.5" },
|
||||||
|
{ providers: ["chutes"], model: "glm-5", temperature: 0.7 },
|
||||||
|
{ providers: ["google"], model: "gemini-3-flash" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns undefined for empty/undefined input", () => {
|
||||||
|
expect(buildFallbackChainFromModels(undefined, undefined)).toBeUndefined()
|
||||||
|
expect(buildFallbackChainFromModels([], undefined)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("filters out invalid entries", () => {
|
||||||
|
const result = buildFallbackChainFromModels(
|
||||||
|
["", "anthropic/claude-sonnet-4-6", " "],
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("flattenToFallbackModelStrings", () => {
|
||||||
|
it("returns undefined for undefined input", () => {
|
||||||
|
expect(flattenToFallbackModelStrings(undefined)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("passes through plain strings", () => {
|
||||||
|
expect(flattenToFallbackModelStrings(["anthropic/claude-sonnet-4-6"])).toEqual([
|
||||||
|
"anthropic/claude-sonnet-4-6",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("flattens object with explicit variant", () => {
|
||||||
|
expect(flattenToFallbackModelStrings([
|
||||||
|
{ model: "anthropic/claude-sonnet-4-6", variant: "high" },
|
||||||
|
])).toEqual(["anthropic/claude-sonnet-4-6(high)"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves inline variant when no explicit variant", () => {
|
||||||
|
expect(flattenToFallbackModelStrings([
|
||||||
|
{ model: "anthropic/claude-sonnet-4-6(high)" },
|
||||||
|
])).toEqual(["anthropic/claude-sonnet-4-6(high)"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("explicit variant overrides inline variant (no double-suffix)", () => {
|
||||||
|
expect(flattenToFallbackModelStrings([
|
||||||
|
{ model: "anthropic/claude-sonnet-4-6(low)", variant: "high" },
|
||||||
|
])).toEqual(["anthropic/claude-sonnet-4-6(high)"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("explicit variant overrides space-suffix variant", () => {
|
||||||
|
expect(flattenToFallbackModelStrings([
|
||||||
|
{ model: "openai/gpt-5.4 high", variant: "low" },
|
||||||
|
])).toEqual(["openai/gpt-5.4(low)"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("explicit variant overrides minimal space-suffix variant", () => {
|
||||||
|
expect(flattenToFallbackModelStrings([
|
||||||
|
{ model: "openai/gpt-5.4 minimal", variant: "low" },
|
||||||
|
])).toEqual(["openai/gpt-5.4(low)"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves trailing non-variant suffixes when adding explicit variant", () => {
|
||||||
|
expect(flattenToFallbackModelStrings([
|
||||||
|
{ model: "openai/gpt-5.4 preview", variant: "low" },
|
||||||
|
])).toEqual(["openai/gpt-5.4 preview(low)"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("flattens object without variant", () => {
|
||||||
|
expect(flattenToFallbackModelStrings([
|
||||||
|
{ model: "openai/gpt-5.4" },
|
||||||
|
])).toEqual(["openai/gpt-5.4"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles mixed array", () => {
|
||||||
|
expect(flattenToFallbackModelStrings([
|
||||||
|
"anthropic/claude-sonnet-4-6",
|
||||||
|
{ model: "openai/gpt-5.4", variant: "high" },
|
||||||
|
{ model: "google/gemini-3-flash(low)" },
|
||||||
|
])).toEqual([
|
||||||
|
"anthropic/claude-sonnet-4-6",
|
||||||
|
"openai/gpt-5.4(high)",
|
||||||
|
"google/gemini-3-flash(low)",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("findMostSpecificFallbackEntry", () => {
|
||||||
|
it("picks exact match over prefix match", () => {
|
||||||
|
const chain = [
|
||||||
|
{ providers: ["openai"], model: "gpt-5.4" },
|
||||||
|
{ providers: ["openai"], model: "gpt-5.4-preview" },
|
||||||
|
]
|
||||||
|
const result = findMostSpecificFallbackEntry("openai", "gpt-5.4-preview", chain)
|
||||||
|
expect(result?.model).toBe("gpt-5.4-preview")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns prefix match when no exact match exists", () => {
|
||||||
|
const chain = [
|
||||||
|
{ providers: ["openai"], model: "gpt-5.4" },
|
||||||
|
]
|
||||||
|
const result = findMostSpecificFallbackEntry("openai", "gpt-5.4-preview", chain)
|
||||||
|
expect(result?.model).toBe("gpt-5.4")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns undefined when no entry matches", () => {
|
||||||
|
const chain = [
|
||||||
|
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
||||||
|
]
|
||||||
|
expect(findMostSpecificFallbackEntry("openai", "gpt-5.4", chain)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sorts by matched prefix length, not insertion order", () => {
|
||||||
|
// Both entries share the same provider so both match as prefixes;
|
||||||
|
// the longer (more-specific) prefix must win regardless of array order.
|
||||||
|
const chain = [
|
||||||
|
{ providers: ["openai"], model: "gpt-5" },
|
||||||
|
{ providers: ["openai"], model: "gpt-5.4-preview" },
|
||||||
|
]
|
||||||
|
const result = findMostSpecificFallbackEntry("openai", "gpt-5.4-preview-2026", chain)
|
||||||
|
expect(result?.model).toBe("gpt-5.4-preview")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("is case-insensitive", () => {
|
||||||
|
const chain = [
|
||||||
|
{ providers: ["OpenAI"], model: "GPT-5.4" },
|
||||||
|
]
|
||||||
|
const result = findMostSpecificFallbackEntry("openai", "gpt-5.4-preview", chain)
|
||||||
|
expect(result?.model).toBe("GPT-5.4")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves variant and settings from matched entry", () => {
|
||||||
|
const chain = [
|
||||||
|
{ providers: ["openai"], model: "gpt-5.4", variant: "high", temperature: 0.7 },
|
||||||
|
{ providers: ["openai"], model: "gpt-5.4-preview", variant: "low", reasoningEffort: "medium" },
|
||||||
|
]
|
||||||
|
const result = findMostSpecificFallbackEntry("openai", "gpt-5.4-preview", chain)
|
||||||
|
expect(result).toEqual({
|
||||||
|
providers: ["openai"],
|
||||||
|
model: "gpt-5.4-preview",
|
||||||
|
variant: "low",
|
||||||
|
reasoningEffort: "medium",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
import type { FallbackEntry } from "./model-requirements"
|
import type { FallbackEntry } from "./model-requirements"
|
||||||
|
import type { FallbackModelObject } from "../config/schema/fallback-models"
|
||||||
import { normalizeFallbackModels } from "./model-resolver"
|
import { normalizeFallbackModels } from "./model-resolver"
|
||||||
|
import { KNOWN_VARIANTS } from "./known-variants"
|
||||||
const KNOWN_VARIANTS = new Set([
|
|
||||||
"low",
|
|
||||||
"medium",
|
|
||||||
"high",
|
|
||||||
"xhigh",
|
|
||||||
"max",
|
|
||||||
"none",
|
|
||||||
"auto",
|
|
||||||
"thinking",
|
|
||||||
])
|
|
||||||
|
|
||||||
function parseVariantFromModel(rawModel: string): { modelID: string; variant?: string } {
|
function parseVariantFromModel(rawModel: string): { modelID: string; variant?: string } {
|
||||||
const trimmedModel = rawModel.trim()
|
const trimmedModel = rawModel.trim()
|
||||||
@@ -61,8 +52,60 @@ export function parseFallbackModelEntry(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseFallbackModelObjectEntry(
|
||||||
|
obj: FallbackModelObject,
|
||||||
|
contextProviderID: string | undefined,
|
||||||
|
defaultProviderID = "opencode",
|
||||||
|
): FallbackEntry | undefined {
|
||||||
|
// Reuse the string-based parser for provider/model/variant extraction.
|
||||||
|
const base = parseFallbackModelEntry(obj.model, contextProviderID, defaultProviderID)
|
||||||
|
if (!base) return undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
// Explicit object variant overrides any inline variant in the model string.
|
||||||
|
variant: obj.variant ?? base.variant,
|
||||||
|
reasoningEffort: obj.reasoningEffort,
|
||||||
|
temperature: obj.temperature,
|
||||||
|
top_p: obj.top_p,
|
||||||
|
maxTokens: obj.maxTokens,
|
||||||
|
thinking: obj.thinking,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the most specific FallbackEntry whose `provider/model` is a prefix of
|
||||||
|
* the resolved `provider/modelID`. Longest match wins so that e.g.
|
||||||
|
* `openai/gpt-5.4-preview` picks the entry for `openai/gpt-5.4-preview` over
|
||||||
|
* the shorter `openai/gpt-5.4`.
|
||||||
|
*/
|
||||||
|
export function findMostSpecificFallbackEntry(
|
||||||
|
providerID: string,
|
||||||
|
modelID: string,
|
||||||
|
chain: FallbackEntry[],
|
||||||
|
): FallbackEntry | undefined {
|
||||||
|
const resolved = `${providerID}/${modelID}`.toLowerCase()
|
||||||
|
|
||||||
|
// Collect entries whose provider/model is a prefix of the resolved model,
|
||||||
|
// together with the length of the matching prefix (longest match wins).
|
||||||
|
const matches: { entry: FallbackEntry; matchLen: number }[] = []
|
||||||
|
for (const entry of chain) {
|
||||||
|
for (const p of entry.providers) {
|
||||||
|
const candidate = `${p}/${entry.model}`.toLowerCase()
|
||||||
|
if (resolved.startsWith(candidate)) {
|
||||||
|
matches.push({ entry, matchLen: candidate.length })
|
||||||
|
break // one match per entry is enough
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length === 0) return undefined
|
||||||
|
matches.sort((a, b) => b.matchLen - a.matchLen)
|
||||||
|
return matches[0].entry
|
||||||
|
}
|
||||||
|
|
||||||
export function buildFallbackChainFromModels(
|
export function buildFallbackChainFromModels(
|
||||||
fallbackModels: string | string[] | undefined,
|
fallbackModels: string | (string | FallbackModelObject)[] | undefined,
|
||||||
contextProviderID: string | undefined,
|
contextProviderID: string | undefined,
|
||||||
defaultProviderID = "opencode",
|
defaultProviderID = "opencode",
|
||||||
): FallbackEntry[] | undefined {
|
): FallbackEntry[] | undefined {
|
||||||
@@ -70,7 +113,12 @@ export function buildFallbackChainFromModels(
|
|||||||
if (!normalized || normalized.length === 0) return undefined
|
if (!normalized || normalized.length === 0) return undefined
|
||||||
|
|
||||||
const parsed = normalized
|
const parsed = normalized
|
||||||
.map((model) => parseFallbackModelEntry(model, contextProviderID, defaultProviderID))
|
.map((entry) => {
|
||||||
|
if (typeof entry === "string") {
|
||||||
|
return parseFallbackModelEntry(entry, contextProviderID, defaultProviderID)
|
||||||
|
}
|
||||||
|
return parseFallbackModelObjectEntry(entry, contextProviderID, defaultProviderID)
|
||||||
|
})
|
||||||
.filter((entry): entry is FallbackEntry => entry !== undefined)
|
.filter((entry): entry is FallbackEntry => entry !== undefined)
|
||||||
|
|
||||||
if (parsed.length === 0) return undefined
|
if (parsed.length === 0) return undefined
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@ export * from "./agent-tool-restrictions"
|
|||||||
export * from "./model-requirements"
|
export * from "./model-requirements"
|
||||||
export * from "./model-resolver"
|
export * from "./model-resolver"
|
||||||
export { normalizeModel, normalizeModelID } from "./model-normalization"
|
export { normalizeModel, normalizeModelID } from "./model-normalization"
|
||||||
export { normalizeFallbackModels } from "./model-resolver"
|
export { normalizeFallbackModels, flattenToFallbackModelStrings } from "./model-resolver"
|
||||||
export { resolveModelPipeline } from "./model-resolution-pipeline"
|
export { resolveModelPipeline } from "./model-resolution-pipeline"
|
||||||
export type {
|
export type {
|
||||||
ModelResolutionRequest,
|
ModelResolutionRequest,
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Canonical set of recognised variant / effort tokens.
|
||||||
|
* Used by parseFallbackModelEntry (space-suffix detection) and
|
||||||
|
* flattenToFallbackModelStrings (inline-variant stripping).
|
||||||
|
*/
|
||||||
|
export const KNOWN_VARIANTS = new Set([
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high",
|
||||||
|
"xhigh",
|
||||||
|
"max",
|
||||||
|
"minimal",
|
||||||
|
"none",
|
||||||
|
"auto",
|
||||||
|
"thinking",
|
||||||
|
])
|
||||||
@@ -2,6 +2,11 @@ export type FallbackEntry = {
|
|||||||
providers: string[];
|
providers: string[];
|
||||||
model: string;
|
model: string;
|
||||||
variant?: string; // Entry-specific variant (e.g., GPT→high, Opus→max)
|
variant?: string; // Entry-specific variant (e.g., GPT→high, Opus→max)
|
||||||
|
reasoningEffort?: string;
|
||||||
|
temperature?: number;
|
||||||
|
top_p?: number;
|
||||||
|
maxTokens?: number;
|
||||||
|
thinking?: { type: "enabled" | "disabled"; budgetTokens?: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ModelRequirement = {
|
export type ModelRequirement = {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { FallbackEntry } from "./model-requirements"
|
import type { FallbackEntry } from "./model-requirements"
|
||||||
|
import type { FallbackModelObject } from "../config/schema/fallback-models"
|
||||||
import { normalizeModel } from "./model-normalization"
|
import { normalizeModel } from "./model-normalization"
|
||||||
import { resolveModelPipeline } from "./model-resolution-pipeline"
|
import { resolveModelPipeline } from "./model-resolution-pipeline"
|
||||||
|
import { KNOWN_VARIANTS } from "./known-variants"
|
||||||
|
|
||||||
export type ModelResolutionInput = {
|
export type ModelResolutionInput = {
|
||||||
userModel?: string
|
userModel?: string
|
||||||
@@ -61,11 +63,45 @@ export function resolveModelWithFallback(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes fallback_models config (which can be string or string[]) to string[]
|
* Normalizes fallback_models config to a mixed array.
|
||||||
* Centralized helper to avoid duplicated normalization logic
|
* Accepts string, string[], or mixed arrays of strings and FallbackModelObject entries.
|
||||||
*/
|
*/
|
||||||
export function normalizeFallbackModels(models: string | string[] | undefined): string[] | undefined {
|
export function normalizeFallbackModels(
|
||||||
|
models: string | (string | FallbackModelObject)[] | undefined,
|
||||||
|
): (string | FallbackModelObject)[] | undefined {
|
||||||
if (!models) return undefined
|
if (!models) return undefined
|
||||||
if (typeof models === "string") return [models]
|
if (typeof models === "string") return [models]
|
||||||
return models
|
return models
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts plain model strings from a mixed fallback models array.
|
||||||
|
* Object entries are flattened to "model" or "model(variant)" strings.
|
||||||
|
* Use this when consumers need string[] (e.g., resolveModelForDelegateTask).
|
||||||
|
*/
|
||||||
|
export function flattenToFallbackModelStrings(
|
||||||
|
models: (string | FallbackModelObject)[] | undefined,
|
||||||
|
): string[] | undefined {
|
||||||
|
if (!models) return undefined
|
||||||
|
return models.map((entry) => {
|
||||||
|
if (typeof entry === "string") return entry
|
||||||
|
const variant = entry.variant
|
||||||
|
if (variant) {
|
||||||
|
// Strip any supported inline variant syntax before appending explicit override.
|
||||||
|
// Supports both parenthesized and space-suffix forms so we don't emit
|
||||||
|
// invalid strings like "provider/model high(low)".
|
||||||
|
const model = entry.model
|
||||||
|
.replace(/\([^()]+\)\s*$/, "")
|
||||||
|
.replace(/\s+([a-z][a-z0-9_-]*)\s*$/i, (match, suffix) => {
|
||||||
|
const normalized = String(suffix).toLowerCase()
|
||||||
|
return KNOWN_VARIANTS.has(normalized)
|
||||||
|
? ""
|
||||||
|
: match
|
||||||
|
})
|
||||||
|
.trim()
|
||||||
|
return `${model}(${variant})`
|
||||||
|
}
|
||||||
|
// No explicit variant — preserve model string as-is (including any inline variant)
|
||||||
|
return entry.model
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearAllSessionPromptParams,
|
||||||
|
clearSessionPromptParams,
|
||||||
|
getSessionPromptParams,
|
||||||
|
setSessionPromptParams,
|
||||||
|
} from "./session-prompt-params-state"
|
||||||
|
|
||||||
|
describe("session-prompt-params-state", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
clearAllSessionPromptParams()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("stores and returns prompt params by session", () => {
|
||||||
|
//#given
|
||||||
|
const sessionID = "ses_prompt_params"
|
||||||
|
const params = {
|
||||||
|
temperature: 0.4,
|
||||||
|
topP: 0.7,
|
||||||
|
options: {
|
||||||
|
reasoningEffort: "high",
|
||||||
|
maxTokens: 4096,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
setSessionPromptParams(sessionID, params)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(getSessionPromptParams(sessionID)).toEqual(params)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns copies so callers cannot mutate stored state", () => {
|
||||||
|
//#given
|
||||||
|
const sessionID = "ses_prompt_params_copy"
|
||||||
|
setSessionPromptParams(sessionID, {
|
||||||
|
temperature: 0.2,
|
||||||
|
options: { reasoningEffort: "medium" },
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = getSessionPromptParams(sessionID)!
|
||||||
|
result.temperature = 0.9
|
||||||
|
result.options!.reasoningEffort = "max"
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(getSessionPromptParams(sessionID)).toEqual({
|
||||||
|
temperature: 0.2,
|
||||||
|
options: { reasoningEffort: "medium" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clears a single session", () => {
|
||||||
|
//#given
|
||||||
|
const sessionID = "ses_prompt_params_clear"
|
||||||
|
setSessionPromptParams(sessionID, { topP: 0.5 })
|
||||||
|
|
||||||
|
//#when
|
||||||
|
clearSessionPromptParams(sessionID)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(getSessionPromptParams(sessionID)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export type SessionPromptParams = {
|
||||||
|
temperature?: number
|
||||||
|
topP?: number
|
||||||
|
options?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionPromptParams = new Map<string, SessionPromptParams>()
|
||||||
|
|
||||||
|
export function setSessionPromptParams(sessionID: string, params: SessionPromptParams): void {
|
||||||
|
sessionPromptParams.set(sessionID, {
|
||||||
|
...(params.temperature !== undefined ? { temperature: params.temperature } : {}),
|
||||||
|
...(params.topP !== undefined ? { topP: params.topP } : {}),
|
||||||
|
...(params.options !== undefined ? { options: { ...params.options } } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionPromptParams(sessionID: string): SessionPromptParams | undefined {
|
||||||
|
const params = sessionPromptParams.get(sessionID)
|
||||||
|
if (!params) return undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(params.temperature !== undefined ? { temperature: params.temperature } : {}),
|
||||||
|
...(params.topP !== undefined ? { topP: params.topP } : {}),
|
||||||
|
...(params.options !== undefined ? { options: { ...params.options } } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSessionPromptParams(sessionID: string): void {
|
||||||
|
sessionPromptParams.delete(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAllSessionPromptParams(): void {
|
||||||
|
sessionPromptParams.clear()
|
||||||
|
}
|
||||||
@@ -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 { ExecutorContext, ParentContext } from "./executor-types"
|
||||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||||
import { getTimingConfig } from "./timing"
|
import { getTimingConfig } from "./timing"
|
||||||
@@ -16,7 +16,7 @@ export async function executeBackgroundTask(
|
|||||||
executorCtx: ExecutorContext,
|
executorCtx: ExecutorContext,
|
||||||
parentContext: ParentContext,
|
parentContext: ParentContext,
|
||||||
agentToUse: string,
|
agentToUse: string,
|
||||||
categoryModel: { providerID: string; modelID: string; variant?: string } | undefined,
|
categoryModel: DelegatedModelConfig | undefined,
|
||||||
systemContent: string | undefined,
|
systemContent: string | undefined,
|
||||||
fallbackChain?: FallbackEntry[],
|
fallbackChain?: FallbackEntry[],
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
|||||||
@@ -114,4 +114,306 @@ describe("resolveCategoryExecution", () => {
|
|||||||
{ providers: ["openai"], model: "gpt-5.2", variant: "high" },
|
{ 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("does not apply object-style fallback settings when the configured primary model matches directly", async () => {
|
||||||
|
//#given
|
||||||
|
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||||
|
models: { openai: ["gpt-5.4-preview"] },
|
||||||
|
connected: ["openai"],
|
||||||
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||||
|
})
|
||||||
|
const agentsSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||||
|
const args = {
|
||||||
|
category: "deep",
|
||||||
|
prompt: "test prompt",
|
||||||
|
description: "Test task",
|
||||||
|
run_in_background: false,
|
||||||
|
load_skills: [],
|
||||||
|
blockedBy: undefined,
|
||||||
|
enableSkillTools: false,
|
||||||
|
}
|
||||||
|
const executorCtx = createMockExecutorContext()
|
||||||
|
executorCtx.userCategories = {
|
||||||
|
deep: {
|
||||||
|
model: "openai/gpt-5.4-preview",
|
||||||
|
fallback_models: [
|
||||||
|
{
|
||||||
|
model: "openai/gpt-5.4",
|
||||||
|
variant: "low",
|
||||||
|
reasoningEffort: "high",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await resolveCategoryExecution(args, executorCtx, undefined, "anthropic/claude-sonnet-4-6")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.error).toBeUndefined()
|
||||||
|
expect(result.actualModel).toBe("openai/gpt-5.4-preview")
|
||||||
|
expect(result.categoryModel).toEqual({
|
||||||
|
providerID: "openai",
|
||||||
|
modelID: "gpt-5.4-preview",
|
||||||
|
variant: "medium",
|
||||||
|
})
|
||||||
|
cacheSpy.mockRestore()
|
||||||
|
agentsSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("matches promoted fallback settings after fuzzy model resolution", async () => {
|
||||||
|
//#given
|
||||||
|
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||||
|
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 { resolveCategoryConfig } from "./categories"
|
||||||
import { parseModelString } from "./model-string-parser"
|
import { parseModelString } from "./model-string-parser"
|
||||||
import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
|
import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
|
||||||
import { normalizeFallbackModels } from "../../shared/model-resolver"
|
import { normalizeFallbackModels, flattenToFallbackModelStrings } from "../../shared/model-resolver"
|
||||||
import { buildFallbackChainFromModels } from "../../shared/fallback-chain-from-models"
|
import { buildFallbackChainFromModels, findMostSpecificFallbackEntry } from "../../shared/fallback-chain-from-models"
|
||||||
import { getAvailableModelsForDelegateTask } from "./available-models"
|
import { getAvailableModelsForDelegateTask } from "./available-models"
|
||||||
import { resolveModelForDelegateTask } from "./model-selection"
|
import { resolveModelForDelegateTask } from "./model-selection"
|
||||||
|
|
||||||
|
import type { DelegatedModelConfig } from "./types"
|
||||||
|
|
||||||
export interface CategoryResolutionResult {
|
export interface CategoryResolutionResult {
|
||||||
agentToUse: string
|
agentToUse: string
|
||||||
categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
|
categoryModel: DelegatedModelConfig | undefined
|
||||||
categoryPromptAppend: string | undefined
|
categoryPromptAppend: string | undefined
|
||||||
maxPromptTokens?: number
|
maxPromptTokens?: number
|
||||||
modelInfo: ModelFallbackInfo | undefined
|
modelInfo: ModelFallbackInfo | undefined
|
||||||
@@ -84,8 +86,10 @@ Available categories: ${allCategoryNames}`,
|
|||||||
const normalizedConfiguredFallbackModels = normalizeFallbackModels(resolved.config.fallback_models)
|
const normalizedConfiguredFallbackModels = normalizeFallbackModels(resolved.config.fallback_models)
|
||||||
let actualModel: string | undefined
|
let actualModel: string | undefined
|
||||||
let modelInfo: ModelFallbackInfo | undefined
|
let modelInfo: ModelFallbackInfo | undefined
|
||||||
let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
|
let categoryModel: DelegatedModelConfig | undefined
|
||||||
let isModelResolutionSkipped = false
|
let isModelResolutionSkipped = false
|
||||||
|
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
|
||||||
@@ -108,7 +112,7 @@ Available categories: ${allCategoryNames}`,
|
|||||||
} else {
|
} else {
|
||||||
const resolution = resolveModelForDelegateTask({
|
const resolution = resolveModelForDelegateTask({
|
||||||
userModel: explicitCategoryModel ?? overrideModel,
|
userModel: explicitCategoryModel ?? overrideModel,
|
||||||
userFallbackModels: normalizedConfiguredFallbackModels,
|
userFallbackModels: flattenToFallbackModelStrings(normalizedConfiguredFallbackModels),
|
||||||
categoryDefaultModel: resolved.model,
|
categoryDefaultModel: resolved.model,
|
||||||
isUserConfiguredCategoryModel: resolved.isUserConfiguredModel,
|
isUserConfiguredCategoryModel: resolved.isUserConfiguredModel,
|
||||||
fallbackChain: requirement.fallbackChain,
|
fallbackChain: requirement.fallbackChain,
|
||||||
@@ -119,7 +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 } = resolution
|
const {
|
||||||
|
model: resolvedModel,
|
||||||
|
variant: resolvedVariant,
|
||||||
|
fallbackEntry: resolvedFallbackEntry,
|
||||||
|
matchedFallback: resolvedMatchedFallback,
|
||||||
|
} = resolution
|
||||||
|
fallbackEntry = resolvedFallbackEntry
|
||||||
|
matchedFallback = resolvedMatchedFallback === true
|
||||||
actualModel = resolvedModel
|
actualModel = resolvedModel
|
||||||
|
|
||||||
if (!parseModelString(actualModel)) {
|
if (!parseModelString(actualModel)) {
|
||||||
@@ -198,6 +209,28 @@ Available categories: ${categoryNames.join(", ")}`,
|
|||||||
defaultProviderID,
|
defaultProviderID,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Only promote fallback-only settings when resolution actually selected a fallback model.
|
||||||
|
const effectiveEntry = matchedFallback && categoryModel
|
||||||
|
? (
|
||||||
|
fallbackEntry
|
||||||
|
?? (configuredFallbackChain
|
||||||
|
? findMostSpecificFallbackEntry(categoryModel.providerID, categoryModel.modelID, configuredFallbackChain)
|
||||||
|
: undefined)
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (categoryModel && effectiveEntry) {
|
||||||
|
categoryModel = {
|
||||||
|
...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 {
|
return {
|
||||||
agentToUse: SISYPHUS_JUNIOR_AGENT,
|
agentToUse: SISYPHUS_JUNIOR_AGENT,
|
||||||
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 } | { 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 }
|
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 }
|
return { model: explicitHighModel, fallbackEntry: entry, matchedFallback: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { model: match, variant: entry.variant }
|
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 }
|
return { model: explicitHighModel, fallbackEntry: entry, matchedFallback: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { model: crossProviderMatch, variant: entry.variant }
|
return { model: crossProviderMatch, variant: entry.variant, fallbackEntry: entry, matchedFallback: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const KNOWN_VARIANTS = new Set([
|
|||||||
"high",
|
"high",
|
||||||
"xhigh",
|
"xhigh",
|
||||||
"max",
|
"max",
|
||||||
|
"minimal",
|
||||||
"none",
|
"none",
|
||||||
"auto",
|
"auto",
|
||||||
"thinking",
|
"thinking",
|
||||||
|
|||||||
@@ -175,4 +175,286 @@ describe("resolveSubagentExecution", () => {
|
|||||||
])
|
])
|
||||||
cacheSpy.mockRestore()
|
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("does not apply object-style fallback settings when the subagent primary model matches directly", async () => {
|
||||||
|
//#given
|
||||||
|
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||||
|
models: { openai: ["gpt-5.4-preview"] },
|
||||||
|
connected: ["openai"],
|
||||||
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||||
|
})
|
||||||
|
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||||
|
const args = createBaseArgs({ subagent_type: "explore" })
|
||||||
|
const executorCtx = createExecutorContext(
|
||||||
|
async () => ([
|
||||||
|
{ name: "explore", mode: "subagent", model: "openai/gpt-5.4-preview" },
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
agentOverrides: {
|
||||||
|
explore: {
|
||||||
|
fallback_models: [
|
||||||
|
{
|
||||||
|
model: "openai/gpt-5.4",
|
||||||
|
variant: "low",
|
||||||
|
reasoningEffort: "high",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as ExecutorContext["agentOverrides"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.error).toBeUndefined()
|
||||||
|
expect(result.categoryModel).toEqual({
|
||||||
|
providerID: "openai",
|
||||||
|
modelID: "gpt-5.4-preview",
|
||||||
|
})
|
||||||
|
cacheSpy.mockRestore()
|
||||||
|
connectedSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("matches promoted fallback settings after fuzzy model resolution", async () => {
|
||||||
|
//#given
|
||||||
|
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||||
|
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 { DelegateTaskArgs } from "./types"
|
||||||
import type { ExecutorContext } from "./executor-types"
|
import type { ExecutorContext } from "./executor-types"
|
||||||
|
import type { DelegatedModelConfig } from "./types"
|
||||||
import { isPlanFamily } from "./constants"
|
import { isPlanFamily } from "./constants"
|
||||||
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
|
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
|
||||||
import { normalizeModelFormat } from "../../shared/model-format-normalizer"
|
import { normalizeModelFormat } from "../../shared/model-format-normalizer"
|
||||||
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
|
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
|
||||||
import { normalizeFallbackModels } from "../../shared/model-resolver"
|
import { normalizeFallbackModels, flattenToFallbackModelStrings } from "../../shared/model-resolver"
|
||||||
import { buildFallbackChainFromModels } from "../../shared/fallback-chain-from-models"
|
import { buildFallbackChainFromModels, findMostSpecificFallbackEntry } from "../../shared/fallback-chain-from-models"
|
||||||
import { getAgentDisplayName, getAgentConfigKey } from "../../shared/agent-display-names"
|
import { getAgentDisplayName, getAgentConfigKey } from "../../shared/agent-display-names"
|
||||||
import { normalizeSDKResponse } from "../../shared"
|
import { normalizeSDKResponse } from "../../shared"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
@@ -17,9 +18,8 @@ export async function resolveSubagentExecution(
|
|||||||
args: DelegateTaskArgs,
|
args: DelegateTaskArgs,
|
||||||
executorCtx: ExecutorContext,
|
executorCtx: ExecutorContext,
|
||||||
parentAgent: string | undefined,
|
parentAgent: string | undefined,
|
||||||
categoryExamples: string,
|
categoryExamples: string
|
||||||
inheritedModel?: string
|
): Promise<{ agentToUse: string; categoryModel: DelegatedModelConfig | undefined; fallbackChain?: FallbackEntry[]; error?: string }> {
|
||||||
): Promise<{ agentToUse: string; categoryModel: { providerID: string; modelID: string; variant?: string } | undefined; fallbackChain?: FallbackEntry[]; error?: string }> {
|
|
||||||
const { client, agentOverrides, userCategories } = executorCtx
|
const { client, agentOverrides, userCategories } = executorCtx
|
||||||
|
|
||||||
if (!args.subagent_type?.trim()) {
|
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 agentToUse = agentName
|
||||||
let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
|
let categoryModel: DelegatedModelConfig | undefined
|
||||||
let fallbackChain: FallbackEntry[] | undefined = undefined
|
let fallbackChain: FallbackEntry[] | undefined = undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -117,8 +117,8 @@ Create the work plan directly - that's your job as the planning agent.`,
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const resolution = resolveModelForDelegateTask({
|
const resolution = resolveModelForDelegateTask({
|
||||||
userModel: agentOverride?.model ?? inheritedModel,
|
userModel: agentOverride?.model,
|
||||||
userFallbackModels: normalizedAgentFallbackModels,
|
userFallbackModels: flattenToFallbackModelStrings(normalizedAgentFallbackModels),
|
||||||
categoryDefaultModel: matchedAgentModelStr,
|
categoryDefaultModel: matchedAgentModelStr,
|
||||||
fallbackChain: agentRequirement?.fallbackChain,
|
fallbackChain: agentRequirement?.fallbackChain,
|
||||||
availableModels,
|
availableModels,
|
||||||
@@ -141,6 +141,30 @@ Create the work plan directly - that's your job as the planning agent.`,
|
|||||||
defaultProviderID,
|
defaultProviderID,
|
||||||
)
|
)
|
||||||
fallbackChain = configuredFallbackChain ?? agentRequirement?.fallbackChain
|
fallbackChain = configuredFallbackChain ?? agentRequirement?.fallbackChain
|
||||||
|
|
||||||
|
// Only promote fallback-only settings when resolution actually selected a fallback model.
|
||||||
|
const resolvedFallbackEntry = (resolution && !('skipped' in resolution)) ? resolution.fallbackEntry : undefined
|
||||||
|
const matchedFallback = (resolution && !('skipped' in resolution)) ? resolution.matchedFallback === true : false
|
||||||
|
const effectiveEntry = matchedFallback && categoryModel
|
||||||
|
? (
|
||||||
|
resolvedFallbackEntry
|
||||||
|
?? (configuredFallbackChain
|
||||||
|
? findMostSpecificFallbackEntry(categoryModel.providerID, categoryModel.modelID, configuredFallbackChain)
|
||||||
|
: undefined)
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (categoryModel && effectiveEntry) {
|
||||||
|
categoryModel = {
|
||||||
|
...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) {
|
if (!categoryModel && matchedAgent.model) {
|
||||||
|
|||||||
@@ -3,9 +3,19 @@ const {
|
|||||||
test: bunTest,
|
test: bunTest,
|
||||||
expect: bunExpect,
|
expect: bunExpect,
|
||||||
mock: bunMock,
|
mock: bunMock,
|
||||||
|
afterEach: bunAfterEach,
|
||||||
} = require("bun:test")
|
} = require("bun:test")
|
||||||
|
|
||||||
|
const {
|
||||||
|
clearSessionPromptParams,
|
||||||
|
getSessionPromptParams,
|
||||||
|
} = require("../../shared/session-prompt-params-state")
|
||||||
|
|
||||||
bunDescribe("sendSyncPrompt", () => {
|
bunDescribe("sendSyncPrompt", () => {
|
||||||
|
bunAfterEach(() => {
|
||||||
|
clearSessionPromptParams("test-session")
|
||||||
|
})
|
||||||
|
|
||||||
bunTest("passes question=false via tools parameter", async () => {
|
bunTest("passes question=false via tools parameter", async () => {
|
||||||
//#given
|
//#given
|
||||||
const { sendSyncPrompt } = require("./sync-prompt-sender")
|
const { sendSyncPrompt } = require("./sync-prompt-sender")
|
||||||
@@ -214,6 +224,67 @@ bunDescribe("sendSyncPrompt", () => {
|
|||||||
bunExpect(promptArgs.body.variant).toBe("medium")
|
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 () => {
|
bunTest("retries with promptSync for oracle when promptAsync fails with unexpected EOF", async () => {
|
||||||
//#given
|
//#given
|
||||||
const { sendSyncPrompt } = require("./sync-prompt-sender")
|
const { sendSyncPrompt } = require("./sync-prompt-sender")
|
||||||
@@ -289,7 +360,7 @@ bunDescribe("sendSyncPrompt", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
//#then
|
//#then
|
||||||
bunExpect(result).toContain("JSON Parse error: Unexpected EOF")
|
bunExpect(result).toContain("Unexpected EOF")
|
||||||
bunExpect(promptWithModelSuggestionRetry).toHaveBeenCalledTimes(1)
|
bunExpect(promptWithModelSuggestionRetry).toHaveBeenCalledTimes(1)
|
||||||
bunExpect(promptSyncWithModelSuggestionRetry).toHaveBeenCalledTimes(0)
|
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 { isPlanFamily } from "./constants"
|
||||||
import { buildTaskPrompt } from "./prompt-builder"
|
import { buildTaskPrompt } from "./prompt-builder"
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { formatDetailedError } from "./error-formatting"
|
import { formatDetailedError } from "./error-formatting"
|
||||||
import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions"
|
import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions"
|
||||||
import { setSessionTools } from "../../shared/session-tools-store"
|
import { setSessionTools } from "../../shared/session-tools-store"
|
||||||
|
import { setSessionPromptParams } from "../../shared/session-prompt-params-state"
|
||||||
import { createInternalAgentTextPart } from "../../shared/internal-initiator-marker"
|
import { createInternalAgentTextPart } from "../../shared/internal-initiator-marker"
|
||||||
|
|
||||||
type SendSyncPromptDeps = {
|
type SendSyncPromptDeps = {
|
||||||
@@ -37,7 +38,7 @@ export async function sendSyncPrompt(
|
|||||||
agentToUse: string
|
agentToUse: string
|
||||||
args: DelegateTaskArgs
|
args: DelegateTaskArgs
|
||||||
systemContent: string | undefined
|
systemContent: string | undefined
|
||||||
categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
|
categoryModel: DelegatedModelConfig | undefined
|
||||||
toastManager: { removeTask: (id: string) => void } | null | undefined
|
toastManager: { removeTask: (id: string) => void } | null | undefined
|
||||||
taskId: string | undefined
|
taskId: string | undefined
|
||||||
},
|
},
|
||||||
@@ -53,6 +54,26 @@ export async function sendSyncPrompt(
|
|||||||
}
|
}
|
||||||
setSessionTools(input.sessionID, tools)
|
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 = {
|
const promptArgs = {
|
||||||
path: { id: input.sessionID },
|
path: { id: input.sessionID },
|
||||||
body: {
|
body: {
|
||||||
@@ -61,7 +82,12 @@ export async function sendSyncPrompt(
|
|||||||
tools,
|
tools,
|
||||||
parts: [createInternalAgentTextPart(effectivePrompt)],
|
parts: [createInternalAgentTextPart(effectivePrompt)],
|
||||||
...(input.categoryModel
|
...(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 } : {}),
|
...(input.categoryModel?.variant ? { variant: input.categoryModel.variant } : {}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ModelFallbackInfo } from "../../features/task-toast-manager/types"
|
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 type { ExecutorContext, ParentContext } from "./executor-types"
|
||||||
import { getTaskToastManager } from "../../features/task-toast-manager"
|
import { getTaskToastManager } from "../../features/task-toast-manager"
|
||||||
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
||||||
@@ -17,7 +17,7 @@ export async function executeSyncTask(
|
|||||||
executorCtx: ExecutorContext,
|
executorCtx: ExecutorContext,
|
||||||
parentContext: ParentContext,
|
parentContext: ParentContext,
|
||||||
agentToUse: string,
|
agentToUse: string,
|
||||||
categoryModel: { providerID: string; modelID: string; variant?: string } | undefined,
|
categoryModel: DelegatedModelConfig | undefined,
|
||||||
systemContent: string | undefined,
|
systemContent: string | undefined,
|
||||||
modelInfo?: ModelFallbackInfo,
|
modelInfo?: ModelFallbackInfo,
|
||||||
fallbackChain?: import("../../shared/model-requirements").FallbackEntry[],
|
fallbackChain?: import("../../shared/model-requirements").FallbackEntry[],
|
||||||
|
|||||||
@@ -178,7 +178,18 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
let agentToUse: string
|
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 categoryPromptAppend: string | undefined
|
||||||
let modelInfo: import("../../features/task-toast-manager/types").ModelFallbackInfo | undefined
|
let modelInfo: import("../../features/task-toast-manager/types").ModelFallbackInfo | undefined
|
||||||
let actualModel: string | undefined
|
let actualModel: string | undefined
|
||||||
@@ -226,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,17 @@ export interface DelegateTaskToolOptions {
|
|||||||
syncPollTimeoutMs?: number
|
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 {
|
export interface BuildSystemContentInput {
|
||||||
skillContent?: string
|
skillContent?: string
|
||||||
skillContents?: string[]
|
skillContents?: string[]
|
||||||
@@ -78,7 +89,7 @@ export interface BuildSystemContentInput {
|
|||||||
agentsContext?: string
|
agentsContext?: string
|
||||||
planAgentPrepend?: string
|
planAgentPrepend?: string
|
||||||
maxPromptTokens?: number
|
maxPromptTokens?: number
|
||||||
model?: { providerID: string; modelID: string; variant?: string }
|
model?: DelegatedModelConfig
|
||||||
agentName?: string
|
agentName?: string
|
||||||
availableCategories?: AvailableCategory[]
|
availableCategories?: AvailableCategory[]
|
||||||
availableSkills?: AvailableSkill[]
|
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 type { ExecutorContext, ParentContext, SessionMessage } from "./executor-types"
|
||||||
import { DEFAULT_SYNC_POLL_TIMEOUT_MS, getTimingConfig } from "./timing"
|
import { DEFAULT_SYNC_POLL_TIMEOUT_MS, getTimingConfig } from "./timing"
|
||||||
import { buildTaskPrompt } from "./prompt-builder"
|
import { buildTaskPrompt } from "./prompt-builder"
|
||||||
@@ -16,7 +16,7 @@ export async function executeUnstableAgentTask(
|
|||||||
executorCtx: ExecutorContext,
|
executorCtx: ExecutorContext,
|
||||||
parentContext: ParentContext,
|
parentContext: ParentContext,
|
||||||
agentToUse: string,
|
agentToUse: string,
|
||||||
categoryModel: { providerID: string; modelID: string; variant?: string } | undefined,
|
categoryModel: DelegatedModelConfig | undefined,
|
||||||
systemContent: string | undefined,
|
systemContent: string | undefined,
|
||||||
actualModel: string | undefined
|
actualModel: string | undefined
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
|||||||
Reference in New Issue
Block a user