import type { ModelFallbackInfo } from "../../features/task-toast-manager/types" import type { DelegateTaskArgs } from "./types" import type { ExecutorContext } from "./executor-types" import type { FallbackEntry } from "../../shared/model-requirements" import { mergeCategories } from "../../shared/merge-categories" import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent" import { resolveCategoryConfig } from "./categories" import { CATEGORY_PROMPT_APPEND_RESOLVERS } from "./constants" import { parseModelString } from "../../shared/model-string-parser" import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements" import { normalizeFallbackModels, flattenToFallbackModelStrings } from "../../shared/model-resolver" import { buildFallbackChainFromModels, findMostSpecificFallbackEntry } from "../../shared/fallback-chain-from-models" import { CONFIG_BASENAME } from "../../shared/plugin-identity" import { getAvailableModelsForDelegateTask } from "./available-models" import { resolveModelForDelegateTask } from "./model-selection" import type { CategoryConfig } from "../../config/schema" import type { DelegatedModelConfig } from "./types" function applyCategoryParams(base: DelegatedModelConfig, config: CategoryConfig): DelegatedModelConfig { const result = { ...base } if (config.temperature !== undefined) result.temperature = config.temperature if (config.top_p !== undefined) result.top_p = config.top_p if (config.maxTokens !== undefined) result.maxTokens = config.maxTokens if (config.reasoningEffort !== undefined) result.reasoningEffort = config.reasoningEffort if (config.thinking !== undefined) result.thinking = config.thinking return result } function resolveCategoryPromptAppendForModel( categoryName: string, actualModel: string | undefined, staticPromptAppend: string, userPromptAppend: string | undefined, ): string | undefined { const dynamicResolver = CATEGORY_PROMPT_APPEND_RESOLVERS[categoryName] if (!dynamicResolver) { return staticPromptAppend || undefined } const dynamicBase = dynamicResolver(actualModel) if (!userPromptAppend) { return dynamicBase || undefined } return dynamicBase ? `${dynamicBase}\n\n${userPromptAppend}` : userPromptAppend } export interface CategoryResolutionResult { agentToUse: string categoryModel: DelegatedModelConfig | undefined categoryPromptAppend: string | undefined maxPromptTokens?: number modelInfo: ModelFallbackInfo | undefined actualModel: string | undefined isUnstableAgent: boolean fallbackChain?: FallbackEntry[] // For runtime retry on model errors error?: string } export async function resolveCategoryExecution( args: DelegateTaskArgs, executorCtx: ExecutorContext, inheritedModel: string | undefined, systemDefaultModel: string | undefined ): Promise { const { client, userCategories, sisyphusJuniorModel } = executorCtx const categoryName = args.category! const enabledCategories = mergeCategories(userCategories) const categoryExists = enabledCategories[categoryName] !== undefined if (!categoryExists) { const allCategoryNames = Object.keys(enabledCategories).join(", ") return { agentToUse: "", categoryModel: undefined, categoryPromptAppend: undefined, maxPromptTokens: undefined, modelInfo: undefined, actualModel: undefined, isUnstableAgent: false, error: `Unknown category: "${categoryName}". Available: ${allCategoryNames}`, } } const availableModels = await getAvailableModelsForDelegateTask(client) const resolved = resolveCategoryConfig(categoryName, { userCategories, inheritedModel, systemDefaultModel, availableModels, }) if (!resolved) { const requirement = CATEGORY_MODEL_REQUIREMENTS[categoryName] const allCategoryNames = Object.keys(enabledCategories).join(", ") if (categoryExists && requirement?.requiresModel) { return { agentToUse: "", categoryModel: undefined, categoryPromptAppend: undefined, maxPromptTokens: undefined, modelInfo: undefined, actualModel: undefined, isUnstableAgent: false, error: `Category "${categoryName}" requires model "${requirement.requiresModel}" which is not available. To use this category: 1. Connect a provider with this model: ${requirement.requiresModel} 2. Or configure an alternative model in your ${CONFIG_BASENAME}.json for this category Available categories: ${allCategoryNames}`, } } return { agentToUse: "", categoryModel: undefined, categoryPromptAppend: undefined, maxPromptTokens: undefined, modelInfo: undefined, actualModel: undefined, isUnstableAgent: false, error: `Unknown category: "${categoryName}". Available: ${allCategoryNames}`, } } const requirement = CATEGORY_MODEL_REQUIREMENTS[args.category!] const normalizedConfiguredFallbackModels = normalizeFallbackModels(resolved.config.fallback_models) let actualModel: string | undefined let modelInfo: ModelFallbackInfo | undefined let categoryModel: DelegatedModelConfig | undefined let isModelResolutionSkipped = false let fallbackEntry: FallbackEntry | undefined let matchedFallback = false const overrideModel = sisyphusJuniorModel const explicitCategoryModel = userCategories?.[args.category!]?.model if (!requirement) { // Precedence: explicit category model > sisyphus-junior default > category resolved model // This keeps `sisyphus-junior.model` useful as a global default while allowing // per-category overrides via `categories[category].model`. actualModel = explicitCategoryModel ?? overrideModel ?? resolved.model if (actualModel) { modelInfo = explicitCategoryModel || overrideModel ? { model: actualModel, type: "user-defined", source: "override" } : { model: actualModel, type: "system-default", source: "system-default" } const parsedModel = parseModelString(actualModel) const variantToUse = userCategories?.[args.category!]?.variant ?? resolved.config.variant categoryModel = parsedModel ? applyCategoryParams({ ...parsedModel, variant: variantToUse ?? parsedModel.variant }, resolved.config) : undefined } } else { const resolution = resolveModelForDelegateTask({ userModel: explicitCategoryModel ?? overrideModel, userFallbackModels: flattenToFallbackModelStrings(normalizedConfiguredFallbackModels), categoryDefaultModel: resolved.model, isUserConfiguredCategoryModel: resolved.isUserConfiguredModel, fallbackChain: requirement.fallbackChain, availableModels, systemDefaultModel, }) if (resolution && "skipped" in resolution) { isModelResolutionSkipped = true const userModelOverride = explicitCategoryModel ?? overrideModel if (userModelOverride) { actualModel = userModelOverride const parsedModel = parseModelString(userModelOverride) const variantToUse = userCategories?.[args.category!]?.variant ?? resolved.config.variant categoryModel = parsedModel ? applyCategoryParams({ ...parsedModel, variant: variantToUse ?? parsedModel.variant }, resolved.config) : undefined modelInfo = { model: userModelOverride, type: "user-defined", source: "override" } } } else if (resolution) { const { model: resolvedModel, variant: resolvedVariant, fallbackEntry: resolvedFallbackEntry, matchedFallback: resolvedMatchedFallback, } = resolution fallbackEntry = resolvedFallbackEntry matchedFallback = resolvedMatchedFallback === true actualModel = resolvedModel if (!parseModelString(actualModel)) { return { agentToUse: "", categoryModel: undefined, categoryPromptAppend: undefined, maxPromptTokens: undefined, modelInfo: undefined, actualModel: undefined, isUnstableAgent: false, error: `Invalid model format "${actualModel}". Expected "provider/model" format (e.g., "anthropic/claude-sonnet-4-6").`, } } const type: "user-defined" | "inherited" | "category-default" | "system-default" = (explicitCategoryModel || overrideModel) ? "user-defined" : (systemDefaultModel && actualModel === systemDefaultModel) ? "system-default" : "category-default" const source: "override" | "category-default" | "system-default" = type === "user-defined" ? "override" : type === "system-default" ? "system-default" : "category-default" modelInfo = { model: actualModel, type, source } const parsedModel = parseModelString(actualModel) const variantToUse = userCategories?.[args.category!]?.variant ?? resolvedVariant ?? resolved.config.variant categoryModel = parsedModel ? applyCategoryParams({ ...parsedModel, variant: variantToUse ?? parsedModel.variant }, resolved.config) : undefined } } if (!categoryModel && actualModel) { const parsedModel = parseModelString(actualModel) categoryModel = parsedModel ?? undefined } const categoryPromptAppend = resolveCategoryPromptAppendForModel( args.category!, actualModel, resolved.promptAppend, userCategories?.[args.category!]?.prompt_append, ) if (!categoryModel && !actualModel && !isModelResolutionSkipped) { const categoryNames = Object.keys(enabledCategories) return { agentToUse: "", categoryModel: undefined, categoryPromptAppend: undefined, maxPromptTokens: undefined, modelInfo: undefined, actualModel: undefined, isUnstableAgent: false, error: `Model not configured for category "${args.category}". Configure in one of: 1. OpenCode: Set "model" in opencode.json 2. Oh-My-OpenCode: Set category model in ${CONFIG_BASENAME}.json 3. Provider: Connect a provider with available models Current category: ${args.category} Available categories: ${categoryNames.join(", ")}`, } } const resolvedModel = actualModel?.toLowerCase() const isUnstableAgent = resolved.config.is_unstable_agent ?? (resolvedModel ? resolvedModel.includes("gemini") || resolvedModel.includes("minimax") : false) const defaultProviderID = categoryModel?.providerID ?? parseModelString(actualModel ?? "")?.providerID ?? "opencode" const configuredFallbackChain = buildFallbackChainFromModels( normalizedConfiguredFallbackModels, 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 ?? categoryModel.reasoningEffort, temperature: effectiveEntry.temperature ?? categoryModel.temperature, top_p: effectiveEntry.top_p ?? categoryModel.top_p, maxTokens: effectiveEntry.maxTokens ?? categoryModel.maxTokens, thinking: effectiveEntry.thinking ?? categoryModel.thinking, } } return { agentToUse: SISYPHUS_JUNIOR_AGENT, categoryModel, categoryPromptAppend, maxPromptTokens: resolved.config.max_prompt_tokens, modelInfo, actualModel, isUnstableAgent, // Don't use hardcoded fallback chain when resolution was skipped (cold cache) fallbackChain: configuredFallbackChain ?? ((isModelResolutionSkipped || explicitCategoryModel || overrideModel) ? undefined : requirement?.fallbackChain), } }