diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index a8710453c5..cbac7480ac 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -3287,6 +3287,14 @@ }, "additionalProperties": false }, + "model_profile": { + "type": "string", + "enum": [ + "premium", + "balanced", + "economy" + ] + }, "sisyphus_agent": { "type": "object", "properties": { diff --git a/src/agents/builtin-agents.ts b/src/agents/builtin-agents.ts index 57e859c759..0c288f3a77 100644 --- a/src/agents/builtin-agents.ts +++ b/src/agents/builtin-agents.ts @@ -1,197 +1,237 @@ -import type { AgentConfig } from "@opencode-ai/sdk" -import type { BuiltinAgentName, AgentOverrides, AgentFactory, AgentPromptMetadata } from "./types" -import type { CategoriesConfig, GitMasterConfig } from "../config/schema" -import type { LoadedSkill } from "../features/opencode-skill-loader/types" -import type { BrowserAutomationProvider } from "../config/schema" -import { createSisyphusAgent } from "./sisyphus" -import { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle" -import { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./librarian" -import { createExploreAgent, EXPLORE_PROMPT_METADATA } from "./explore" -import { createMultimodalLookerAgent, MULTIMODAL_LOOKER_PROMPT_METADATA } from "./multimodal-looker" -import { createMetisAgent, metisPromptMetadata } from "./metis" -import { createAtlasAgent, atlasPromptMetadata } from "./atlas" -import { createMomusAgent, momusPromptMetadata } from "./momus" -import { createHephaestusAgent } from "./hephaestus" -import type { AvailableCategory } from "./dynamic-agent-prompt-builder" +import type { AgentConfig } from "@opencode-ai/sdk"; +import type { + BrowserAutomationProvider, + CategoriesConfig, + GitMasterConfig, +} from "../config/schema"; +import type { LoadedSkill } from "../features/opencode-skill-loader/types"; import { - fetchAvailableModels, - readConnectedProvidersCache, - readProviderModelsCache, -} from "../shared" -import { CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants" -import { mergeCategories } from "../shared/merge-categories" -import { buildAvailableSkills } from "./builtin-agents/available-skills" -import { collectPendingBuiltinAgents } from "./builtin-agents/general-agents" -import { maybeCreateSisyphusConfig } from "./builtin-agents/sisyphus-agent" -import { maybeCreateHephaestusConfig } from "./builtin-agents/hephaestus-agent" -import { maybeCreateAtlasConfig } from "./builtin-agents/atlas-agent" -import { buildCustomAgentMetadata, parseRegisteredAgentSummaries } from "./custom-agent-summaries" - -type AgentSource = AgentFactory | AgentConfig + fetchAvailableModels, + readConnectedProvidersCache, + readProviderModelsCache, +} from "../shared"; +import { mergeCategories } from "../shared/merge-categories"; +import type { ProfileName } from "../shared/model-registry"; +import { CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"; +import { atlasPromptMetadata, createAtlasAgent } from "./atlas"; +import { maybeCreateAtlasConfig } from "./builtin-agents/atlas-agent"; +import { buildAvailableSkills } from "./builtin-agents/available-skills"; +import { collectPendingBuiltinAgents } from "./builtin-agents/general-agents"; +import { maybeCreateHephaestusConfig } from "./builtin-agents/hephaestus-agent"; +import { maybeCreateSisyphusConfig } from "./builtin-agents/sisyphus-agent"; +import { + buildCustomAgentMetadata, + parseRegisteredAgentSummaries, +} from "./custom-agent-summaries"; +import type { AvailableCategory } from "./dynamic-agent-prompt-builder"; +import { createExploreAgent, EXPLORE_PROMPT_METADATA } from "./explore"; +import { createHephaestusAgent } from "./hephaestus"; +import { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./librarian"; +import { createMetisAgent, metisPromptMetadata } from "./metis"; +import { createMomusAgent, momusPromptMetadata } from "./momus"; +import { + createMultimodalLookerAgent, + MULTIMODAL_LOOKER_PROMPT_METADATA, +} from "./multimodal-looker"; +import { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle"; +import { createSisyphusAgent } from "./sisyphus"; +import type { + AgentFactory, + AgentOverrides, + AgentPromptMetadata, + BuiltinAgentName, +} from "./types"; + +type AgentSource = AgentFactory | AgentConfig; const agentSources: Record = { - sisyphus: createSisyphusAgent, - hephaestus: createHephaestusAgent, - oracle: createOracleAgent, - librarian: createLibrarianAgent, - explore: createExploreAgent, - "multimodal-looker": createMultimodalLookerAgent, - metis: createMetisAgent, - momus: createMomusAgent, - // Note: Atlas is handled specially in createBuiltinAgents() - // because it needs OrchestratorContext, not just a model string - atlas: createAtlasAgent as AgentFactory, -} + sisyphus: createSisyphusAgent, + hephaestus: createHephaestusAgent, + oracle: createOracleAgent, + librarian: createLibrarianAgent, + explore: createExploreAgent, + "multimodal-looker": createMultimodalLookerAgent, + metis: createMetisAgent, + momus: createMomusAgent, + // Note: Atlas is handled specially in createBuiltinAgents() + // because it needs OrchestratorContext, not just a model string + atlas: createAtlasAgent as AgentFactory, +}; /** * Metadata for each agent, used to build Sisyphus's dynamic prompt sections * (Delegation Table, Tool Selection, Key Triggers, etc.) */ const agentMetadata: Partial> = { - oracle: ORACLE_PROMPT_METADATA, - librarian: LIBRARIAN_PROMPT_METADATA, - explore: EXPLORE_PROMPT_METADATA, - "multimodal-looker": MULTIMODAL_LOOKER_PROMPT_METADATA, - metis: metisPromptMetadata, - momus: momusPromptMetadata, - atlas: atlasPromptMetadata, -} + oracle: ORACLE_PROMPT_METADATA, + librarian: LIBRARIAN_PROMPT_METADATA, + explore: EXPLORE_PROMPT_METADATA, + "multimodal-looker": MULTIMODAL_LOOKER_PROMPT_METADATA, + metis: metisPromptMetadata, + momus: momusPromptMetadata, + atlas: atlasPromptMetadata, +}; export async function createBuiltinAgents( - disabledAgents: string[] = [], - agentOverrides: AgentOverrides = {}, - directory?: string, - systemDefaultModel?: string, - categories?: CategoriesConfig, - gitMasterConfig?: GitMasterConfig, - discoveredSkills: LoadedSkill[] = [], - customAgentSummaries?: unknown, - browserProvider?: BrowserAutomationProvider, - uiSelectedModel?: string, - disabledSkills?: Set, - useTaskSystem = false, - disableOmoEnv = false + disabledAgents: string[] = [], + agentOverrides: AgentOverrides = {}, + directory?: string, + systemDefaultModel?: string, + categories?: CategoriesConfig, + gitMasterConfig?: GitMasterConfig, + discoveredSkills: LoadedSkill[] = [], + customAgentSummaries?: unknown, + browserProvider?: BrowserAutomationProvider, + uiSelectedModel?: string, + disabledSkills?: Set, + useTaskSystem = false, + disableOmoEnv = false, + modelProfile?: ProfileName, ): Promise> { - - const connectedProviders = readConnectedProvidersCache() - const providerModelsConnected = connectedProviders - ? (readProviderModelsCache()?.connected ?? []) - : [] - const mergedConnectedProviders = Array.from( - new Set([...(connectedProviders ?? []), ...providerModelsConnected]) - ) - // IMPORTANT: Do NOT call OpenCode client APIs during plugin initialization. - // This function is called from config handler, and calling client API causes deadlock. - // See: https://github.com/code-yeongyu/oh-my-opencode/issues/1301 - const availableModels = await fetchAvailableModels(undefined, { - connectedProviders: mergedConnectedProviders.length > 0 ? mergedConnectedProviders : undefined, - }) - const isFirstRunNoCache = - availableModels.size === 0 && mergedConnectedProviders.length === 0 - - const result: Record = {} - - const mergedCategories = mergeCategories(categories) - - const availableCategories: AvailableCategory[] = Object.entries(mergedCategories).map(([name]) => ({ - name, - description: categories?.[name]?.description ?? CATEGORY_DESCRIPTIONS[name] ?? "General tasks", - })) - - const availableSkills = buildAvailableSkills(discoveredSkills, browserProvider, disabledSkills) - - // Collect general agents first (for availableAgents), but don't add to result yet - const { pendingAgentConfigs, availableAgents } = collectPendingBuiltinAgents({ - agentSources, - agentMetadata, - disabledAgents, - agentOverrides, - directory, - systemDefaultModel, - mergedCategories, - gitMasterConfig, - browserProvider, - uiSelectedModel, - availableModels, - disabledSkills, - disableOmoEnv, - }) - - const registeredAgents = parseRegisteredAgentSummaries(customAgentSummaries) - const builtinAgentNames = new Set(Object.keys(agentSources).map((name) => name.toLowerCase())) - const disabledAgentNames = new Set(disabledAgents.map((name) => name.toLowerCase())) - - for (const agent of registeredAgents) { - const lowerName = agent.name.toLowerCase() - if (builtinAgentNames.has(lowerName)) continue - if (disabledAgentNames.has(lowerName)) continue - if (availableAgents.some((availableAgent) => availableAgent.name.toLowerCase() === lowerName)) continue - - availableAgents.push({ - name: agent.name, - description: agent.description, - metadata: buildCustomAgentMetadata(agent.name, agent.description), - }) - } - - const sisyphusConfig = maybeCreateSisyphusConfig({ - disabledAgents, - agentOverrides, - uiSelectedModel, - availableModels, - systemDefaultModel, - isFirstRunNoCache, - availableAgents, - availableSkills, - availableCategories, - mergedCategories, - directory, - userCategories: categories, - useTaskSystem, - disableOmoEnv, - }) - if (sisyphusConfig) { - result["sisyphus"] = sisyphusConfig - } - - const hephaestusConfig = maybeCreateHephaestusConfig({ - disabledAgents, - agentOverrides, - availableModels, - systemDefaultModel, - isFirstRunNoCache, - availableAgents, - availableSkills, - availableCategories, - mergedCategories, - directory, - useTaskSystem, - disableOmoEnv, - }) - if (hephaestusConfig) { - result["hephaestus"] = hephaestusConfig - } - - // Add pending agents after sisyphus and hephaestus to maintain order - for (const [name, config] of pendingAgentConfigs) { - result[name] = config - } - - const atlasConfig = maybeCreateAtlasConfig({ - disabledAgents, - agentOverrides, - uiSelectedModel, - availableModels, - systemDefaultModel, - availableAgents, - availableSkills, - mergedCategories, - directory, - userCategories: categories, - }) - if (atlasConfig) { - result["atlas"] = atlasConfig - } - - return result + const connectedProviders = readConnectedProvidersCache(); + const providerModelsConnected = connectedProviders + ? (readProviderModelsCache()?.connected ?? []) + : []; + const mergedConnectedProviders = Array.from( + new Set([...(connectedProviders ?? []), ...providerModelsConnected]), + ); + // IMPORTANT: Do NOT call OpenCode client APIs during plugin initialization. + // This function is called from config handler, and calling client API causes deadlock. + // See: https://github.com/code-yeongyu/oh-my-opencode/issues/1301 + const availableModels = await fetchAvailableModels(undefined, { + connectedProviders: + mergedConnectedProviders.length > 0 + ? mergedConnectedProviders + : undefined, + }); + const isFirstRunNoCache = + availableModels.size === 0 && mergedConnectedProviders.length === 0; + + const result: Record = {}; + + const mergedCategories = mergeCategories(categories); + + const availableCategories: AvailableCategory[] = Object.entries( + mergedCategories, + ).map(([name]) => ({ + name, + description: + categories?.[name]?.description ?? + CATEGORY_DESCRIPTIONS[name] ?? + "General tasks", + })); + + const availableSkills = buildAvailableSkills( + discoveredSkills, + browserProvider, + disabledSkills, + ); + + // Collect general agents first (for availableAgents), but don't add to result yet + const { pendingAgentConfigs, availableAgents } = collectPendingBuiltinAgents({ + agentSources, + agentMetadata, + disabledAgents, + agentOverrides, + directory, + systemDefaultModel, + mergedCategories, + gitMasterConfig, + browserProvider, + uiSelectedModel, + availableModels, + disabledSkills, + modelProfile, + disableOmoEnv, + }); + + const registeredAgents = parseRegisteredAgentSummaries(customAgentSummaries); + const builtinAgentNames = new Set( + Object.keys(agentSources).map((name) => name.toLowerCase()), + ); + const disabledAgentNames = new Set( + disabledAgents.map((name) => name.toLowerCase()), + ); + + for (const agent of registeredAgents) { + const lowerName = agent.name.toLowerCase(); + if (builtinAgentNames.has(lowerName)) continue; + if (disabledAgentNames.has(lowerName)) continue; + if ( + availableAgents.some( + (availableAgent) => availableAgent.name.toLowerCase() === lowerName, + ) + ) + continue; + + availableAgents.push({ + name: agent.name, + description: agent.description, + metadata: buildCustomAgentMetadata(agent.name, agent.description), + }); + } + + const sisyphusConfig = maybeCreateSisyphusConfig({ + disabledAgents, + agentOverrides, + uiSelectedModel, + availableModels, + systemDefaultModel, + isFirstRunNoCache, + availableAgents, + availableSkills, + availableCategories, + mergedCategories, + directory, + userCategories: categories, + modelProfile, + useTaskSystem, + disableOmoEnv, + }); + if (sisyphusConfig) { + result["sisyphus"] = sisyphusConfig; + } + + const hephaestusConfig = maybeCreateHephaestusConfig({ + disabledAgents, + agentOverrides, + availableModels, + systemDefaultModel, + isFirstRunNoCache, + availableAgents, + availableSkills, + availableCategories, + mergedCategories, + directory, + modelProfile, + useTaskSystem, + disableOmoEnv, + }); + if (hephaestusConfig) { + result["hephaestus"] = hephaestusConfig; + } + + // Add pending agents after sisyphus and hephaestus to maintain order + for (const [name, config] of pendingAgentConfigs) { + result[name] = config; + } + + const atlasConfig = maybeCreateAtlasConfig({ + disabledAgents, + agentOverrides, + uiSelectedModel, + availableModels, + systemDefaultModel, + availableAgents, + availableSkills, + mergedCategories, + directory, + userCategories: categories, + modelProfile, + }); + if (atlasConfig) { + result["atlas"] = atlasConfig; + } + + return result; } diff --git a/src/agents/builtin-agents/atlas-agent.ts b/src/agents/builtin-agents/atlas-agent.ts index f1658ebc9c..cc0c95e063 100644 --- a/src/agents/builtin-agents/atlas-agent.ts +++ b/src/agents/builtin-agents/atlas-agent.ts @@ -1,66 +1,82 @@ -import type { AgentConfig } from "@opencode-ai/sdk" -import type { AgentOverrides } from "../types" -import type { CategoriesConfig, CategoryConfig } from "../../config/schema" -import type { AvailableAgent, AvailableSkill } from "../dynamic-agent-prompt-builder" -import { AGENT_MODEL_REQUIREMENTS } from "../../shared" -import { applyOverrides } from "./agent-overrides" -import { applyModelResolution } from "./model-resolution" -import { createAtlasAgent } from "../atlas" +import type { AgentConfig } from "@opencode-ai/sdk"; +import type { CategoriesConfig, CategoryConfig } from "../../config/schema"; +import { AGENT_MODEL_REQUIREMENTS } from "../../shared"; +import type { ProfileName } from "../../shared/model-registry"; +import { createAtlasAgent } from "../atlas"; +import type { + AvailableAgent, + AvailableSkill, +} from "../dynamic-agent-prompt-builder"; +import type { AgentOverrides } from "../types"; +import { applyOverrides } from "./agent-overrides"; +import { applyModelResolution } from "./model-resolution"; export function maybeCreateAtlasConfig(input: { - disabledAgents: string[] - agentOverrides: AgentOverrides - uiSelectedModel?: string - availableModels: Set - systemDefaultModel?: string - availableAgents: AvailableAgent[] - availableSkills: AvailableSkill[] - mergedCategories: Record - directory?: string - userCategories?: CategoriesConfig - useTaskSystem?: boolean + disabledAgents: string[]; + agentOverrides: AgentOverrides; + uiSelectedModel?: string; + availableModels: Set; + systemDefaultModel?: string; + availableAgents: AvailableAgent[]; + availableSkills: AvailableSkill[]; + mergedCategories: Record; + directory?: string; + userCategories?: CategoriesConfig; + modelProfile?: ProfileName; + useTaskSystem?: boolean; }): AgentConfig | undefined { - const { - disabledAgents, - agentOverrides, - uiSelectedModel, - availableModels, - systemDefaultModel, - availableAgents, - availableSkills, - mergedCategories, - directory, - userCategories, - } = input + const { + disabledAgents, + agentOverrides, + uiSelectedModel, + availableModels, + systemDefaultModel, + availableAgents, + availableSkills, + mergedCategories, + directory, + userCategories, + modelProfile, + } = input; - if (disabledAgents.includes("atlas")) return undefined + if (disabledAgents.includes("atlas")) return undefined; - const orchestratorOverride = agentOverrides["atlas"] - const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"] + const orchestratorOverride = agentOverrides["atlas"]; + const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"]; - const atlasResolution = applyModelResolution({ - uiSelectedModel: orchestratorOverride?.model ? undefined : uiSelectedModel, - userModel: orchestratorOverride?.model, - requirement: atlasRequirement, - availableModels, - systemDefaultModel, - }) + const atlasResolution = applyModelResolution({ + uiSelectedModel: orchestratorOverride?.model ? undefined : uiSelectedModel, + userModel: orchestratorOverride?.model, + profileName: modelProfile, + agentName: "atlas", + requirement: atlasRequirement, + availableModels, + systemDefaultModel, + }); - if (!atlasResolution) return undefined - const { model: atlasModel, variant: atlasResolvedVariant } = atlasResolution + if (!atlasResolution) return undefined; + const { model: atlasModel, variant: atlasResolvedVariant } = atlasResolution; - let orchestratorConfig = createAtlasAgent({ - model: atlasModel, - availableAgents, - availableSkills, - userCategories, - }) + let orchestratorConfig = createAtlasAgent({ + model: atlasModel, + availableAgents, + availableSkills, + userCategories, + }); - if (atlasResolvedVariant) { - orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant } - } + if (atlasResolvedVariant) { + orchestratorConfig = { + ...orchestratorConfig, + variant: atlasResolvedVariant, + }; + } - orchestratorConfig = applyOverrides(orchestratorConfig, orchestratorOverride, mergedCategories, directory) + orchestratorConfig = applyOverrides( + orchestratorConfig, + orchestratorOverride, + mergedCategories, + directory, + ); - return orchestratorConfig + return orchestratorConfig; } diff --git a/src/agents/builtin-agents/general-agents.ts b/src/agents/builtin-agents/general-agents.ts index 54f2ae3fb3..3bdb004738 100644 --- a/src/agents/builtin-agents/general-agents.ts +++ b/src/agents/builtin-agents/general-agents.ts @@ -1,105 +1,139 @@ -import type { AgentConfig } from "@opencode-ai/sdk" -import type { BuiltinAgentName, AgentOverrides, AgentPromptMetadata } from "../types" -import type { CategoryConfig, GitMasterConfig } from "../../config/schema" -import type { BrowserAutomationProvider } from "../../config/schema" -import type { AvailableAgent } from "../dynamic-agent-prompt-builder" -import { AGENT_MODEL_REQUIREMENTS, isModelAvailable } from "../../shared" -import { buildAgent, isFactory } from "../agent-builder" -import { applyOverrides } from "./agent-overrides" -import { applyEnvironmentContext } from "./environment-context" -import { applyModelResolution } from "./model-resolution" +import type { AgentConfig } from "@opencode-ai/sdk"; +import type { + BrowserAutomationProvider, + CategoryConfig, + GitMasterConfig, +} from "../../config/schema"; +import { AGENT_MODEL_REQUIREMENTS, isModelAvailable } from "../../shared"; +import type { ProfileName } from "../../shared/model-registry"; +import { buildAgent, isFactory } from "../agent-builder"; +import type { AvailableAgent } from "../dynamic-agent-prompt-builder"; +import type { + AgentOverrides, + AgentPromptMetadata, + BuiltinAgentName, +} from "../types"; +import { applyOverrides } from "./agent-overrides"; +import { applyEnvironmentContext } from "./environment-context"; +import { applyModelResolution } from "./model-resolution"; export function collectPendingBuiltinAgents(input: { - agentSources: Record - agentMetadata: Partial> - disabledAgents: string[] - agentOverrides: AgentOverrides - directory?: string - systemDefaultModel?: string - mergedCategories: Record - gitMasterConfig?: GitMasterConfig - browserProvider?: BrowserAutomationProvider - uiSelectedModel?: string - availableModels: Set - disabledSkills?: Set - useTaskSystem?: boolean - disableOmoEnv?: boolean -}): { pendingAgentConfigs: Map; availableAgents: AvailableAgent[] } { - const { - agentSources, - agentMetadata, - disabledAgents, - agentOverrides, - directory, - systemDefaultModel, - mergedCategories, - gitMasterConfig, - browserProvider, - uiSelectedModel, - availableModels, - disabledSkills, - disableOmoEnv = false, - } = input + agentSources: Record< + BuiltinAgentName, + import("../agent-builder").AgentSource + >; + agentMetadata: Partial>; + disabledAgents: string[]; + agentOverrides: AgentOverrides; + directory?: string; + systemDefaultModel?: string; + mergedCategories: Record; + gitMasterConfig?: GitMasterConfig; + browserProvider?: BrowserAutomationProvider; + uiSelectedModel?: string; + availableModels: Set; + disabledSkills?: Set; + modelProfile?: ProfileName; + useTaskSystem?: boolean; + disableOmoEnv?: boolean; +}): { + pendingAgentConfigs: Map; + availableAgents: AvailableAgent[]; +} { + const { + agentSources, + agentMetadata, + disabledAgents, + agentOverrides, + directory, + systemDefaultModel, + mergedCategories, + gitMasterConfig, + browserProvider, + uiSelectedModel, + availableModels, + disabledSkills, + modelProfile, + disableOmoEnv = false, + } = input; - const availableAgents: AvailableAgent[] = [] - const pendingAgentConfigs: Map = new Map() + const availableAgents: AvailableAgent[] = []; + const pendingAgentConfigs: Map = new Map(); - for (const [name, source] of Object.entries(agentSources)) { - const agentName = name as BuiltinAgentName + for (const [name, source] of Object.entries(agentSources)) { + const agentName = name as BuiltinAgentName; - if (agentName === "sisyphus") continue - if (agentName === "hephaestus") continue - if (agentName === "atlas") continue - if (disabledAgents.some((name) => name.toLowerCase() === agentName.toLowerCase())) continue + if (agentName === "sisyphus") continue; + if (agentName === "hephaestus") continue; + if (agentName === "atlas") continue; + if ( + disabledAgents.some( + (name) => name.toLowerCase() === agentName.toLowerCase(), + ) + ) + continue; - const override = agentOverrides[agentName] - ?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1] - const requirement = AGENT_MODEL_REQUIREMENTS[agentName] + const override = + agentOverrides[agentName] ?? + Object.entries(agentOverrides).find( + ([key]) => key.toLowerCase() === agentName.toLowerCase(), + )?.[1]; + const requirement = AGENT_MODEL_REQUIREMENTS[agentName]; - // Check if agent requires a specific model - if (requirement?.requiresModel && availableModels) { - if (!isModelAvailable(requirement.requiresModel, availableModels)) { - continue - } - } + // Check if agent requires a specific model + if (requirement?.requiresModel && availableModels) { + if (!isModelAvailable(requirement.requiresModel, availableModels)) { + continue; + } + } - const isPrimaryAgent = isFactory(source) && source.mode === "primary" + const isPrimaryAgent = isFactory(source) && source.mode === "primary"; - const resolution = applyModelResolution({ - uiSelectedModel: (isPrimaryAgent && !override?.model) ? uiSelectedModel : undefined, - userModel: override?.model, - requirement, - availableModels, - systemDefaultModel, - }) - if (!resolution) continue - const { model, variant: resolvedVariant } = resolution + const resolution = applyModelResolution({ + uiSelectedModel: + isPrimaryAgent && !override?.model ? uiSelectedModel : undefined, + userModel: override?.model, + profileName: modelProfile, + agentName, + requirement, + availableModels, + systemDefaultModel, + }); + if (!resolution) continue; + const { model, variant: resolvedVariant } = resolution; - let config = buildAgent(source, model, mergedCategories, gitMasterConfig, browserProvider, disabledSkills) + let config = buildAgent( + source, + model, + mergedCategories, + gitMasterConfig, + browserProvider, + disabledSkills, + ); - // Apply resolved variant from model fallback chain - if (resolvedVariant) { - config = { ...config, variant: resolvedVariant } - } + // Apply resolved variant from model fallback chain + if (resolvedVariant) { + config = { ...config, variant: resolvedVariant }; + } - if (agentName === "librarian") { - config = applyEnvironmentContext(config, directory, { disableOmoEnv }) - } + if (agentName === "librarian") { + config = applyEnvironmentContext(config, directory, { disableOmoEnv }); + } - config = applyOverrides(config, override, mergedCategories, directory) + config = applyOverrides(config, override, mergedCategories, directory); - // Store for later - will be added after sisyphus and hephaestus - pendingAgentConfigs.set(name, config) + // Store for later - will be added after sisyphus and hephaestus + pendingAgentConfigs.set(name, config); - const metadata = agentMetadata[agentName] - if (metadata) { - availableAgents.push({ - name: agentName, - description: config.description ?? "", - metadata, - }) - } - } + const metadata = agentMetadata[agentName]; + if (metadata) { + availableAgents.push({ + name: agentName, + description: config.description ?? "", + metadata, + }); + } + } - return { pendingAgentConfigs, availableAgents } + return { pendingAgentConfigs, availableAgents }; } diff --git a/src/agents/builtin-agents/hephaestus-agent.ts b/src/agents/builtin-agents/hephaestus-agent.ts index a4f0a801d7..06fcfb2679 100644 --- a/src/agents/builtin-agents/hephaestus-agent.ts +++ b/src/agents/builtin-agents/hephaestus-agent.ts @@ -1,90 +1,121 @@ -import type { AgentConfig } from "@opencode-ai/sdk" -import type { AgentOverrides } from "../types" -import type { CategoryConfig } from "../../config/schema" -import type { AvailableAgent, AvailableCategory, AvailableSkill } from "../dynamic-agent-prompt-builder" -import { AGENT_MODEL_REQUIREMENTS, isAnyProviderConnected } from "../../shared" -import { createHephaestusAgent } from "../hephaestus" -import { applyEnvironmentContext } from "./environment-context" -import { applyCategoryOverride, mergeAgentConfig } from "./agent-overrides" -import { applyModelResolution, getFirstFallbackModel } from "./model-resolution" +import type { AgentConfig } from "@opencode-ai/sdk"; +import type { CategoryConfig } from "../../config/schema"; +import { AGENT_MODEL_REQUIREMENTS, isAnyProviderConnected } from "../../shared"; +import type { ProfileName } from "../../shared/model-registry"; +import type { + AvailableAgent, + AvailableCategory, + AvailableSkill, +} from "../dynamic-agent-prompt-builder"; +import { createHephaestusAgent } from "../hephaestus"; +import type { AgentOverrides } from "../types"; +import { applyCategoryOverride, mergeAgentConfig } from "./agent-overrides"; +import { applyEnvironmentContext } from "./environment-context"; +import { + applyModelResolution, + getFirstFallbackModel, +} from "./model-resolution"; export function maybeCreateHephaestusConfig(input: { - disabledAgents: string[] - agentOverrides: AgentOverrides - availableModels: Set - systemDefaultModel?: string - isFirstRunNoCache: boolean - availableAgents: AvailableAgent[] - availableSkills: AvailableSkill[] - availableCategories: AvailableCategory[] - mergedCategories: Record - directory?: string - useTaskSystem: boolean - disableOmoEnv?: boolean + disabledAgents: string[]; + agentOverrides: AgentOverrides; + availableModels: Set; + systemDefaultModel?: string; + isFirstRunNoCache: boolean; + availableAgents: AvailableAgent[]; + availableSkills: AvailableSkill[]; + availableCategories: AvailableCategory[]; + mergedCategories: Record; + directory?: string; + modelProfile?: ProfileName; + useTaskSystem: boolean; + disableOmoEnv?: boolean; }): AgentConfig | undefined { - const { - disabledAgents, - agentOverrides, - availableModels, - systemDefaultModel, - isFirstRunNoCache, - availableAgents, - availableSkills, - availableCategories, - mergedCategories, - directory, - useTaskSystem, - disableOmoEnv = false, - } = input + const { + disabledAgents, + agentOverrides, + availableModels, + systemDefaultModel, + isFirstRunNoCache, + availableAgents, + availableSkills, + availableCategories, + mergedCategories, + directory, + modelProfile, + useTaskSystem, + disableOmoEnv = false, + } = input; - if (disabledAgents.includes("hephaestus")) return undefined + if (disabledAgents.includes("hephaestus")) return undefined; - const hephaestusOverride = agentOverrides["hephaestus"] - const hephaestusRequirement = AGENT_MODEL_REQUIREMENTS["hephaestus"] - const hasHephaestusExplicitConfig = hephaestusOverride !== undefined + const hephaestusOverride = agentOverrides["hephaestus"]; + const hephaestusRequirement = AGENT_MODEL_REQUIREMENTS["hephaestus"]; + const hasHephaestusExplicitConfig = hephaestusOverride !== undefined; - const hasRequiredProvider = - !hephaestusRequirement?.requiresProvider || - hasHephaestusExplicitConfig || - isFirstRunNoCache || - isAnyProviderConnected(hephaestusRequirement.requiresProvider, availableModels) + const hasRequiredProvider = + !hephaestusRequirement?.requiresProvider || + hasHephaestusExplicitConfig || + isFirstRunNoCache || + isAnyProviderConnected( + hephaestusRequirement.requiresProvider, + availableModels, + ); - if (!hasRequiredProvider) return undefined + if (!hasRequiredProvider) return undefined; - let hephaestusResolution = applyModelResolution({ - userModel: hephaestusOverride?.model, - requirement: hephaestusRequirement, - availableModels, - systemDefaultModel, - }) + let hephaestusResolution = applyModelResolution({ + userModel: hephaestusOverride?.model, + profileName: modelProfile, + agentName: "hephaestus", + requirement: hephaestusRequirement, + availableModels, + systemDefaultModel, + }); - if (isFirstRunNoCache && !hephaestusOverride?.model) { - hephaestusResolution = getFirstFallbackModel(hephaestusRequirement) - } + if (isFirstRunNoCache && !hephaestusOverride?.model) { + hephaestusResolution = getFirstFallbackModel(hephaestusRequirement); + } - if (!hephaestusResolution) return undefined - const { model: hephaestusModel, variant: hephaestusResolvedVariant } = hephaestusResolution + if (!hephaestusResolution) return undefined; + const { model: hephaestusModel, variant: hephaestusResolvedVariant } = + hephaestusResolution; - let hephaestusConfig = createHephaestusAgent( - hephaestusModel, - availableAgents, - undefined, - availableSkills, - availableCategories, - useTaskSystem - ) + let hephaestusConfig = createHephaestusAgent( + hephaestusModel, + availableAgents, + undefined, + availableSkills, + availableCategories, + useTaskSystem, + ); - hephaestusConfig = { ...hephaestusConfig, variant: hephaestusResolvedVariant ?? "medium" } + hephaestusConfig = { + ...hephaestusConfig, + variant: hephaestusResolvedVariant ?? "medium", + }; - const hepOverrideCategory = (hephaestusOverride as Record | undefined)?.category as string | undefined - if (hepOverrideCategory) { - hephaestusConfig = applyCategoryOverride(hephaestusConfig, hepOverrideCategory, mergedCategories) - } + const hepOverrideCategory = ( + hephaestusOverride as Record | undefined + )?.category as string | undefined; + if (hepOverrideCategory) { + hephaestusConfig = applyCategoryOverride( + hephaestusConfig, + hepOverrideCategory, + mergedCategories, + ); + } - hephaestusConfig = applyEnvironmentContext(hephaestusConfig, directory, { disableOmoEnv }) + hephaestusConfig = applyEnvironmentContext(hephaestusConfig, directory, { + disableOmoEnv, + }); - if (hephaestusOverride) { - hephaestusConfig = mergeAgentConfig(hephaestusConfig, hephaestusOverride, directory) - } - return hephaestusConfig + if (hephaestusOverride) { + hephaestusConfig = mergeAgentConfig( + hephaestusConfig, + hephaestusOverride, + directory, + ); + } + return hephaestusConfig; } diff --git a/src/agents/builtin-agents/model-resolution.ts b/src/agents/builtin-agents/model-resolution.ts index f692c93523..93698443a0 100644 --- a/src/agents/builtin-agents/model-resolution.ts +++ b/src/agents/builtin-agents/model-resolution.ts @@ -1,31 +1,57 @@ -import { resolveModelPipeline } from "../../shared" -import { transformModelForProvider } from "../../shared/provider-model-id-transform" +import { resolveModelPipeline } from "../../shared"; +import type { ProfileName } from "../../shared/model-registry"; +import { getProfileOverride } from "../../shared/model-registry"; +import { transformModelForProvider } from "../../shared/provider-model-id-transform"; export function applyModelResolution(input: { - uiSelectedModel?: string - userModel?: string - requirement?: { fallbackChain?: { providers: string[]; model: string; variant?: string }[] } - availableModels: Set - systemDefaultModel?: string + uiSelectedModel?: string; + userModel?: string; + profileName?: ProfileName; + agentName?: string; + requirement?: { + fallbackChain?: { providers: string[]; model: string; variant?: string }[]; + }; + availableModels: Set; + systemDefaultModel?: string; }) { - const { uiSelectedModel, userModel, requirement, availableModels, systemDefaultModel } = input - return resolveModelPipeline({ - intent: { uiSelectedModel, userModel }, - constraints: { availableModels }, - policy: { fallbackChain: requirement?.fallbackChain, systemDefaultModel }, - }) + const { + uiSelectedModel, + userModel, + profileName, + agentName, + requirement, + availableModels, + systemDefaultModel, + } = input; + let resolvedUserModel = userModel; + + let resolvedVariant: string | undefined; + + if (!resolvedUserModel && profileName && agentName) { + const profileOverride = getProfileOverride(profileName, agentName); + if (profileOverride) { + resolvedUserModel = profileOverride.model; + resolvedVariant = profileOverride.variant; + } + } + + return resolveModelPipeline({ + intent: { uiSelectedModel, userModel: resolvedUserModel, userVariant: resolvedVariant }, + constraints: { availableModels }, + policy: { fallbackChain: requirement?.fallbackChain, systemDefaultModel, profileName, agentName }, + }); } export function getFirstFallbackModel(requirement?: { - fallbackChain?: { providers: string[]; model: string; variant?: string }[] + fallbackChain?: { providers: string[]; model: string; variant?: string }[]; }) { - const entry = requirement?.fallbackChain?.[0] - if (!entry || entry.providers.length === 0) return undefined - const provider = entry.providers[0] - const transformedModel = transformModelForProvider(provider, entry.model) - return { - model: `${provider}/${transformedModel}`, - provenance: "provider-fallback" as const, - variant: entry.variant, - } + const entry = requirement?.fallbackChain?.[0]; + if (!entry || entry.providers.length === 0) return undefined; + const provider = entry.providers[0]; + const transformedModel = transformModelForProvider(provider, entry.model); + return { + model: `${provider}/${transformedModel}`, + provenance: "provider-fallback" as const, + variant: entry.variant, + }; } diff --git a/src/agents/builtin-agents/sisyphus-agent.ts b/src/agents/builtin-agents/sisyphus-agent.ts index a28879b7a0..96b078500e 100644 --- a/src/agents/builtin-agents/sisyphus-agent.ts +++ b/src/agents/builtin-agents/sisyphus-agent.ts @@ -1,88 +1,113 @@ -import type { AgentConfig } from "@opencode-ai/sdk" -import type { AgentOverrides } from "../types" -import type { CategoriesConfig, CategoryConfig } from "../../config/schema" -import type { AvailableAgent, AvailableCategory, AvailableSkill } from "../dynamic-agent-prompt-builder" -import { AGENT_MODEL_REQUIREMENTS, isAnyFallbackModelAvailable } from "../../shared" -import { applyEnvironmentContext } from "./environment-context" -import { applyOverrides } from "./agent-overrides" -import { applyModelResolution, getFirstFallbackModel } from "./model-resolution" -import { createSisyphusAgent } from "../sisyphus" +import type { AgentConfig } from "@opencode-ai/sdk"; +import type { CategoriesConfig, CategoryConfig } from "../../config/schema"; +import { + AGENT_MODEL_REQUIREMENTS, + isAnyFallbackModelAvailable, +} from "../../shared"; +import type { ProfileName } from "../../shared/model-registry"; +import type { + AvailableAgent, + AvailableCategory, + AvailableSkill, +} from "../dynamic-agent-prompt-builder"; +import { createSisyphusAgent } from "../sisyphus"; +import type { AgentOverrides } from "../types"; +import { applyOverrides } from "./agent-overrides"; +import { applyEnvironmentContext } from "./environment-context"; +import { + applyModelResolution, + getFirstFallbackModel, +} from "./model-resolution"; export function maybeCreateSisyphusConfig(input: { - disabledAgents: string[] - agentOverrides: AgentOverrides - uiSelectedModel?: string - availableModels: Set - systemDefaultModel?: string - isFirstRunNoCache: boolean - availableAgents: AvailableAgent[] - availableSkills: AvailableSkill[] - availableCategories: AvailableCategory[] - mergedCategories: Record - directory?: string - userCategories?: CategoriesConfig - useTaskSystem: boolean - disableOmoEnv?: boolean + disabledAgents: string[]; + agentOverrides: AgentOverrides; + uiSelectedModel?: string; + availableModels: Set; + systemDefaultModel?: string; + isFirstRunNoCache: boolean; + availableAgents: AvailableAgent[]; + availableSkills: AvailableSkill[]; + availableCategories: AvailableCategory[]; + mergedCategories: Record; + directory?: string; + userCategories?: CategoriesConfig; + modelProfile?: ProfileName; + useTaskSystem: boolean; + disableOmoEnv?: boolean; }): AgentConfig | undefined { - const { - disabledAgents, - agentOverrides, - uiSelectedModel, - availableModels, - systemDefaultModel, - isFirstRunNoCache, - availableAgents, - availableSkills, - availableCategories, - mergedCategories, - directory, - useTaskSystem, - disableOmoEnv = false, - } = input + const { + disabledAgents, + agentOverrides, + uiSelectedModel, + availableModels, + systemDefaultModel, + isFirstRunNoCache, + availableAgents, + availableSkills, + availableCategories, + mergedCategories, + directory, + modelProfile, + useTaskSystem, + disableOmoEnv = false, + } = input; - const sisyphusOverride = agentOverrides["sisyphus"] - const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["sisyphus"] - const hasSisyphusExplicitConfig = sisyphusOverride !== undefined - const meetsSisyphusAnyModelRequirement = - !sisyphusRequirement?.requiresAnyModel || - hasSisyphusExplicitConfig || - isFirstRunNoCache || - isAnyFallbackModelAvailable(sisyphusRequirement.fallbackChain, availableModels) + const sisyphusOverride = agentOverrides["sisyphus"]; + const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["sisyphus"]; + const hasSisyphusExplicitConfig = sisyphusOverride !== undefined; + const meetsSisyphusAnyModelRequirement = + !sisyphusRequirement?.requiresAnyModel || + hasSisyphusExplicitConfig || + isFirstRunNoCache || + isAnyFallbackModelAvailable( + sisyphusRequirement.fallbackChain, + availableModels, + ); - if (disabledAgents.includes("sisyphus") || !meetsSisyphusAnyModelRequirement) return undefined + if (disabledAgents.includes("sisyphus") || !meetsSisyphusAnyModelRequirement) + return undefined; - let sisyphusResolution = applyModelResolution({ - uiSelectedModel: sisyphusOverride?.model ? undefined : uiSelectedModel, - userModel: sisyphusOverride?.model, - requirement: sisyphusRequirement, - availableModels, - systemDefaultModel, - }) + let sisyphusResolution = applyModelResolution({ + uiSelectedModel: sisyphusOverride?.model ? undefined : uiSelectedModel, + userModel: sisyphusOverride?.model, + profileName: modelProfile, + agentName: "sisyphus", + requirement: sisyphusRequirement, + availableModels, + systemDefaultModel, + }); - if (isFirstRunNoCache && !sisyphusOverride?.model && !uiSelectedModel) { - sisyphusResolution = getFirstFallbackModel(sisyphusRequirement) - } + if (isFirstRunNoCache && !sisyphusOverride?.model && !uiSelectedModel) { + sisyphusResolution = getFirstFallbackModel(sisyphusRequirement); + } - if (!sisyphusResolution) return undefined - const { model: sisyphusModel, variant: sisyphusResolvedVariant } = sisyphusResolution + if (!sisyphusResolution) return undefined; + const { model: sisyphusModel, variant: sisyphusResolvedVariant } = + sisyphusResolution; - let sisyphusConfig = createSisyphusAgent( - sisyphusModel, - availableAgents, - undefined, - availableSkills, - availableCategories, - useTaskSystem - ) + let sisyphusConfig = createSisyphusAgent( + sisyphusModel, + availableAgents, + undefined, + availableSkills, + availableCategories, + useTaskSystem, + ); - if (sisyphusResolvedVariant) { - sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant } - } + if (sisyphusResolvedVariant) { + sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant }; + } - sisyphusConfig = applyOverrides(sisyphusConfig, sisyphusOverride, mergedCategories, directory) - sisyphusConfig = applyEnvironmentContext(sisyphusConfig, directory, { - disableOmoEnv, - }) + sisyphusConfig = applyOverrides( + sisyphusConfig, + sisyphusOverride, + mergedCategories, + directory, + ); + sisyphusConfig = applyEnvironmentContext(sisyphusConfig, directory, { + disableOmoEnv, + }); - return sisyphusConfig + return sisyphusConfig; } diff --git a/src/config/schema/model-profile.ts b/src/config/schema/model-profile.ts new file mode 100644 index 0000000000..b7186ec3d2 --- /dev/null +++ b/src/config/schema/model-profile.ts @@ -0,0 +1,5 @@ +import { z } from "zod" + +export const ModelProfileSchema = z.enum(["premium", "balanced", "economy"]).optional() + +export type ModelProfile = z.infer diff --git a/src/config/schema/oh-my-opencode-config.ts b/src/config/schema/oh-my-opencode-config.ts index d24bbef4d5..7b285fde47 100644 --- a/src/config/schema/oh-my-opencode-config.ts +++ b/src/config/schema/oh-my-opencode-config.ts @@ -11,6 +11,7 @@ import { CommentCheckerConfigSchema } from "./comment-checker" import { BuiltinCommandNameSchema } from "./commands" import { ExperimentalConfigSchema } from "./experimental" import { GitMasterConfigSchema } from "./git-master" +import { ModelProfileSchema } from "./model-profile" import { NotificationConfigSchema } from "./notification" import { RalphLoopConfigSchema } from "./ralph-loop" import { RuntimeFallbackConfigSchema } from "./runtime-fallback" @@ -41,6 +42,7 @@ export const OhMyOpenCodeConfigSchema = z.object({ agents: AgentOverridesSchema.optional(), categories: CategoriesConfigSchema.optional(), claude_code: ClaudeCodeConfigSchema.optional(), + model_profile: ModelProfileSchema, sisyphus_agent: SisyphusAgentConfigSchema.optional(), comment_checker: CommentCheckerConfigSchema.optional(), experimental: ExperimentalConfigSchema.optional(), diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts index 9ceb92460a..917f0ed6ad 100644 --- a/src/plugin-handlers/agent-config-handler.ts +++ b/src/plugin-handlers/agent-config-handler.ts @@ -1,259 +1,284 @@ import { createBuiltinAgents } from "../agents"; import { createSisyphusJuniorAgentWithOverrides } from "../agents/sisyphus-junior"; import type { OhMyOpenCodeConfig } from "../config"; -import { log, migrateAgentConfig } from "../shared"; -import { AGENT_NAME_MAP } from "../shared/migration"; -import { getAgentDisplayName } from "../shared/agent-display-names"; import { - discoverConfigSourceSkills, - discoverOpencodeGlobalSkills, - discoverOpencodeProjectSkills, - discoverProjectClaudeSkills, - discoverUserClaudeSkills, + loadProjectAgents, + loadUserAgents, +} from "../features/claude-code-agent-loader"; +import { + discoverConfigSourceSkills, + discoverOpencodeGlobalSkills, + discoverOpencodeProjectSkills, + discoverProjectClaudeSkills, + discoverUserClaudeSkills, } from "../features/opencode-skill-loader"; -import { loadProjectAgents, loadUserAgents } from "../features/claude-code-agent-loader"; -import type { PluginComponents } from "./plugin-components-loader"; -import { reorderAgentsByPriority } from "./agent-priority-order"; +import { log, migrateAgentConfig } from "../shared"; +import { getAgentDisplayName } from "../shared/agent-display-names"; +import { AGENT_NAME_MAP } from "../shared/migration"; import { remapAgentKeysToDisplayNames } from "./agent-key-remapper"; -import { buildPrometheusAgentConfig } from "./prometheus-agent-config-builder"; +import { reorderAgentsByPriority } from "./agent-priority-order"; import { buildPlanDemoteConfig } from "./plan-model-inheritance"; +import type { PluginComponents } from "./plugin-components-loader"; +import { buildPrometheusAgentConfig } from "./prometheus-agent-config-builder"; type AgentConfigRecord = Record | undefined> & { - build?: Record; - plan?: Record; + build?: Record; + plan?: Record; }; -function getConfiguredDefaultAgent(config: Record): string | undefined { - const defaultAgent = config.default_agent; - if (typeof defaultAgent !== "string") return undefined; +function getConfiguredDefaultAgent( + config: Record, +): string | undefined { + const defaultAgent = config.default_agent; + if (typeof defaultAgent !== "string") return undefined; - const trimmedDefaultAgent = defaultAgent.trim(); - return trimmedDefaultAgent.length > 0 ? trimmedDefaultAgent : undefined; + const trimmedDefaultAgent = defaultAgent.trim(); + return trimmedDefaultAgent.length > 0 ? trimmedDefaultAgent : undefined; } export async function applyAgentConfig(params: { - config: Record; - pluginConfig: OhMyOpenCodeConfig; - ctx: { directory: string; client?: any }; - pluginComponents: PluginComponents; + config: Record; + pluginConfig: OhMyOpenCodeConfig; + ctx: { directory: string; client?: any }; + pluginComponents: PluginComponents; }): Promise> { - const migratedDisabledAgents = (params.pluginConfig.disabled_agents ?? []).map( - (agent) => { - return AGENT_NAME_MAP[agent.toLowerCase()] ?? AGENT_NAME_MAP[agent] ?? agent; - }, - ) as typeof params.pluginConfig.disabled_agents; + const migratedDisabledAgents = ( + params.pluginConfig.disabled_agents ?? [] + ).map((agent) => { + return ( + AGENT_NAME_MAP[agent.toLowerCase()] ?? AGENT_NAME_MAP[agent] ?? agent + ); + }) as typeof params.pluginConfig.disabled_agents; - const includeClaudeSkillsForAwareness = params.pluginConfig.claude_code?.skills ?? true; - const [ - discoveredConfigSourceSkills, - discoveredUserSkills, - discoveredProjectSkills, - discoveredOpencodeGlobalSkills, - discoveredOpencodeProjectSkills, - ] = await Promise.all([ - discoverConfigSourceSkills({ - config: params.pluginConfig.skills, - configDir: params.ctx.directory, - }), - includeClaudeSkillsForAwareness ? discoverUserClaudeSkills() : Promise.resolve([]), - includeClaudeSkillsForAwareness - ? discoverProjectClaudeSkills(params.ctx.directory) - : Promise.resolve([]), - discoverOpencodeGlobalSkills(), - discoverOpencodeProjectSkills(params.ctx.directory), - ]); + const includeClaudeSkillsForAwareness = + params.pluginConfig.claude_code?.skills ?? true; + const [ + discoveredConfigSourceSkills, + discoveredUserSkills, + discoveredProjectSkills, + discoveredOpencodeGlobalSkills, + discoveredOpencodeProjectSkills, + ] = await Promise.all([ + discoverConfigSourceSkills({ + config: params.pluginConfig.skills, + configDir: params.ctx.directory, + }), + includeClaudeSkillsForAwareness + ? discoverUserClaudeSkills() + : Promise.resolve([]), + includeClaudeSkillsForAwareness + ? discoverProjectClaudeSkills(params.ctx.directory) + : Promise.resolve([]), + discoverOpencodeGlobalSkills(), + discoverOpencodeProjectSkills(params.ctx.directory), + ]); - const allDiscoveredSkills = [ - ...discoveredConfigSourceSkills, - ...discoveredOpencodeProjectSkills, - ...discoveredProjectSkills, - ...discoveredOpencodeGlobalSkills, - ...discoveredUserSkills, - ]; + const allDiscoveredSkills = [ + ...discoveredConfigSourceSkills, + ...discoveredOpencodeProjectSkills, + ...discoveredProjectSkills, + ...discoveredOpencodeGlobalSkills, + ...discoveredUserSkills, + ]; - const browserProvider = - params.pluginConfig.browser_automation_engine?.provider ?? "playwright"; - const currentModel = params.config.model as string | undefined; - const disabledSkills = new Set(params.pluginConfig.disabled_skills ?? []); - const useTaskSystem = params.pluginConfig.experimental?.task_system ?? false; - const disableOmoEnv = params.pluginConfig.experimental?.disable_omo_env ?? false; + const browserProvider = + params.pluginConfig.browser_automation_engine?.provider ?? "playwright"; + const currentModel = params.config.model as string | undefined; + const modelProfile = params.pluginConfig.model_profile; + const disabledSkills = new Set( + params.pluginConfig.disabled_skills ?? [], + ); + const useTaskSystem = params.pluginConfig.experimental?.task_system ?? false; + const disableOmoEnv = + params.pluginConfig.experimental?.disable_omo_env ?? false; - const builtinAgents = await createBuiltinAgents( - migratedDisabledAgents, - params.pluginConfig.agents, - params.ctx.directory, - currentModel, - params.pluginConfig.categories, - params.pluginConfig.git_master, - allDiscoveredSkills, - params.ctx.client, - browserProvider, - currentModel, - disabledSkills, - useTaskSystem, - disableOmoEnv, - ); + const builtinAgents = await createBuiltinAgents( + migratedDisabledAgents, + params.pluginConfig.agents, + params.ctx.directory, + currentModel, + params.pluginConfig.categories, + params.pluginConfig.git_master, + allDiscoveredSkills, + params.ctx.client, + browserProvider, + currentModel, + disabledSkills, + useTaskSystem, + disableOmoEnv, + modelProfile, + ); - const includeClaudeAgents = params.pluginConfig.claude_code?.agents ?? true; - const userAgents = includeClaudeAgents ? loadUserAgents() : {}; - const projectAgents = includeClaudeAgents ? loadProjectAgents(params.ctx.directory) : {}; + const includeClaudeAgents = params.pluginConfig.claude_code?.agents ?? true; + const userAgents = includeClaudeAgents ? loadUserAgents() : {}; + const projectAgents = includeClaudeAgents + ? loadProjectAgents(params.ctx.directory) + : {}; - const rawPluginAgents = params.pluginComponents.agents; - const pluginAgents = Object.fromEntries( - Object.entries(rawPluginAgents).map(([key, value]) => [ - key, - value ? migrateAgentConfig(value as Record) : value, - ]), - ); + const rawPluginAgents = params.pluginComponents.agents; + const pluginAgents = Object.fromEntries( + Object.entries(rawPluginAgents).map(([key, value]) => [ + key, + value ? migrateAgentConfig(value as Record) : value, + ]), + ); - const disabledAgentNames = new Set( - (migratedDisabledAgents ?? []).map(a => a.toLowerCase()) - ); + const disabledAgentNames = new Set( + (migratedDisabledAgents ?? []).map((a) => a.toLowerCase()), + ); - const filterDisabledAgents = (agents: Record) => - Object.fromEntries( - Object.entries(agents).filter(([name]) => !disabledAgentNames.has(name.toLowerCase())) - ); + const filterDisabledAgents = (agents: Record) => + Object.fromEntries( + Object.entries(agents).filter( + ([name]) => !disabledAgentNames.has(name.toLowerCase()), + ), + ); - const isSisyphusEnabled = params.pluginConfig.sisyphus_agent?.disabled !== true; - const builderEnabled = - params.pluginConfig.sisyphus_agent?.default_builder_enabled ?? false; - const plannerEnabled = params.pluginConfig.sisyphus_agent?.planner_enabled ?? true; - const replacePlan = params.pluginConfig.sisyphus_agent?.replace_plan ?? true; - const shouldDemotePlan = plannerEnabled && replacePlan; - const configuredDefaultAgent = getConfiguredDefaultAgent(params.config); + const isSisyphusEnabled = + params.pluginConfig.sisyphus_agent?.disabled !== true; + const builderEnabled = + params.pluginConfig.sisyphus_agent?.default_builder_enabled ?? false; + const plannerEnabled = + params.pluginConfig.sisyphus_agent?.planner_enabled ?? true; + const replacePlan = params.pluginConfig.sisyphus_agent?.replace_plan ?? true; + const shouldDemotePlan = plannerEnabled && replacePlan; + const configuredDefaultAgent = getConfiguredDefaultAgent(params.config); - const configAgent = params.config.agent as AgentConfigRecord | undefined; + const configAgent = params.config.agent as AgentConfigRecord | undefined; - if (isSisyphusEnabled && builtinAgents.sisyphus) { - if (configuredDefaultAgent) { - (params.config as { default_agent?: string }).default_agent = - getAgentDisplayName(configuredDefaultAgent); - } else { - (params.config as { default_agent?: string }).default_agent = - getAgentDisplayName("sisyphus"); - } + if (isSisyphusEnabled && builtinAgents.sisyphus) { + if (configuredDefaultAgent) { + (params.config as { default_agent?: string }).default_agent = + getAgentDisplayName(configuredDefaultAgent); + } else { + (params.config as { default_agent?: string }).default_agent = + getAgentDisplayName("sisyphus"); + } - const agentConfig: Record = { - sisyphus: builtinAgents.sisyphus, - }; + const agentConfig: Record = { + sisyphus: builtinAgents.sisyphus, + }; - agentConfig["sisyphus-junior"] = createSisyphusJuniorAgentWithOverrides( - params.pluginConfig.agents?.["sisyphus-junior"], - undefined, - useTaskSystem, - ); + agentConfig["sisyphus-junior"] = createSisyphusJuniorAgentWithOverrides( + params.pluginConfig.agents?.["sisyphus-junior"], + undefined, + useTaskSystem, + ); - if (builderEnabled) { - const { name: _buildName, ...buildConfigWithoutName } = - configAgent?.build ?? {}; - const migratedBuildConfig = migrateAgentConfig( - buildConfigWithoutName as Record, - ); - const override = params.pluginConfig.agents?.["OpenCode-Builder"]; - const base = { - ...migratedBuildConfig, - description: `${(configAgent?.build?.description as string) ?? "Build agent"} (OpenCode default)`, - }; - agentConfig["OpenCode-Builder"] = override ? { ...base, ...override } : base; - } + if (builderEnabled) { + const { name: _buildName, ...buildConfigWithoutName } = + configAgent?.build ?? {}; + const migratedBuildConfig = migrateAgentConfig( + buildConfigWithoutName as Record, + ); + const override = params.pluginConfig.agents?.["OpenCode-Builder"]; + const base = { + ...migratedBuildConfig, + description: `${(configAgent?.build?.description as string) ?? "Build agent"} (OpenCode default)`, + }; + agentConfig["OpenCode-Builder"] = override + ? { ...base, ...override } + : base; + } - if (plannerEnabled) { - const prometheusOverride = params.pluginConfig.agents?.["prometheus"] as - | (Record & { prompt_append?: string }) - | undefined; + if (plannerEnabled) { + const prometheusOverride = params.pluginConfig.agents?.["prometheus"] as + | (Record & { prompt_append?: string }) + | undefined; - agentConfig["prometheus"] = await buildPrometheusAgentConfig({ - configAgentPlan: configAgent?.plan, - pluginPrometheusOverride: prometheusOverride, - userCategories: params.pluginConfig.categories, - currentModel, - }); - } + agentConfig["prometheus"] = await buildPrometheusAgentConfig({ + configAgentPlan: configAgent?.plan, + pluginPrometheusOverride: prometheusOverride, + userCategories: params.pluginConfig.categories, + currentModel, + }); + } - const filteredConfigAgents = configAgent - ? Object.fromEntries( - Object.entries(configAgent) - .filter(([key]) => { - if (key === "build") return false; - if (key === "plan" && shouldDemotePlan) return false; - if (key in builtinAgents) return false; - return true; - }) - .map(([key, value]) => [ - key, - value ? migrateAgentConfig(value as Record) : value, - ]), - ) - : {}; + const filteredConfigAgents = configAgent + ? Object.fromEntries( + Object.entries(configAgent) + .filter(([key]) => { + if (key === "build") return false; + if (key === "plan" && shouldDemotePlan) return false; + if (key in builtinAgents) return false; + return true; + }) + .map(([key, value]) => [ + key, + value + ? migrateAgentConfig(value as Record) + : value, + ]), + ) + : {}; - const migratedBuild = configAgent?.build - ? migrateAgentConfig(configAgent.build as Record) - : {}; + const migratedBuild = configAgent?.build + ? migrateAgentConfig(configAgent.build as Record) + : {}; - const planDemoteConfig = shouldDemotePlan - ? buildPlanDemoteConfig( - agentConfig["prometheus"] as Record | undefined, - params.pluginConfig.agents?.plan as Record | undefined, - ) - : undefined; + const planDemoteConfig = shouldDemotePlan + ? buildPlanDemoteConfig( + agentConfig["prometheus"] as Record | undefined, + params.pluginConfig.agents?.plan as + | Record + | undefined, + ) + : undefined; - // Collect all builtin agent names to prevent user/project .md files from overriding them - const builtinAgentNames = new Set([ - ...Object.keys(agentConfig), - ...Object.keys(builtinAgents), - ]); + const builtinAgentNames = new Set([ + ...Object.keys(agentConfig), + ...Object.keys(builtinAgents), + ]); - // Filter user/project agents that duplicate builtin agents (they have mode: "subagent" hardcoded - // in loadAgentsFromDir which would incorrectly override the builtin mode: "primary") - const filteredUserAgents = Object.fromEntries( - Object.entries(userAgents).filter(([key]) => !builtinAgentNames.has(key)), - ); - const filteredProjectAgents = Object.fromEntries( - Object.entries(projectAgents).filter(([key]) => !builtinAgentNames.has(key)), - ); + const filteredUserAgents = Object.fromEntries( + Object.entries(userAgents).filter(([key]) => !builtinAgentNames.has(key)), + ); + const filteredProjectAgents = Object.fromEntries( + Object.entries(projectAgents).filter(([key]) => !builtinAgentNames.has(key)), + ); - params.config.agent = { - ...agentConfig, - ...Object.fromEntries( - Object.entries(builtinAgents).filter(([key]) => key !== "sisyphus"), - ), - ...filterDisabledAgents(filteredUserAgents), - ...filterDisabledAgents(filteredProjectAgents), - ...filterDisabledAgents(pluginAgents), - ...filteredConfigAgents, - build: { ...migratedBuild, mode: "subagent", hidden: true }, - ...(planDemoteConfig ? { plan: planDemoteConfig } : {}), - }; - } else { - // Filter user/project agents that duplicate builtin agents - const builtinAgentNames = new Set(Object.keys(builtinAgents)); - const filteredUserAgents = Object.fromEntries( - Object.entries(userAgents).filter(([key]) => !builtinAgentNames.has(key)), - ); - const filteredProjectAgents = Object.fromEntries( - Object.entries(projectAgents).filter(([key]) => !builtinAgentNames.has(key)), - ); + params.config.agent = { + ...agentConfig, + ...Object.fromEntries( + Object.entries(builtinAgents).filter(([key]) => key !== "sisyphus"), + ), + ...filterDisabledAgents(filteredUserAgents), + ...filterDisabledAgents(filteredProjectAgents), + ...filterDisabledAgents(pluginAgents), + ...filteredConfigAgents, + build: { ...migratedBuild, mode: "subagent", hidden: true }, + ...(planDemoteConfig ? { plan: planDemoteConfig } : {}), + }; + } else { + const builtinAgentNames = new Set(Object.keys(builtinAgents)); + const filteredUserAgents = Object.fromEntries( + Object.entries(userAgents).filter(([key]) => !builtinAgentNames.has(key)), + ); + const filteredProjectAgents = Object.fromEntries( + Object.entries(projectAgents).filter(([key]) => !builtinAgentNames.has(key)), + ); - params.config.agent = { - ...builtinAgents, - ...filterDisabledAgents(filteredUserAgents), - ...filterDisabledAgents(filteredProjectAgents), - ...filterDisabledAgents(pluginAgents), - ...configAgent, - }; - } + params.config.agent = { + ...builtinAgents, + ...filterDisabledAgents(filteredUserAgents), + ...filterDisabledAgents(filteredProjectAgents), + ...filterDisabledAgents(pluginAgents), + ...configAgent, + }; + } - if (params.config.agent) { - params.config.agent = remapAgentKeysToDisplayNames( - params.config.agent as Record, - ); - params.config.agent = reorderAgentsByPriority( - params.config.agent as Record, - ); - } + if (params.config.agent) { + params.config.agent = remapAgentKeysToDisplayNames( + params.config.agent as Record, + ); + params.config.agent = reorderAgentsByPriority( + params.config.agent as Record, + ); + } - const agentResult = params.config.agent as Record; - log("[config-handler] agents loaded", { agentKeys: Object.keys(agentResult) }); - return agentResult; + const agentResult = params.config.agent as Record; + log("[config-handler] agents loaded", { + agentKeys: Object.keys(agentResult), + }); + return agentResult; } diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts index 3b441c1970..69eb541d4c 100644 --- a/src/plugin/tool-registry.ts +++ b/src/plugin/tool-registry.ts @@ -1,148 +1,159 @@ -import type { ToolDefinition } from "@opencode-ai/plugin" - -import type { - AvailableCategory, -} from "../agents/dynamic-agent-prompt-builder" -import type { OhMyOpenCodeConfig } from "../config" -import type { PluginContext, ToolsRecord } from "./types" - +import type { ToolDefinition } from "@opencode-ai/plugin"; + +import type { AvailableCategory } from "../agents/dynamic-agent-prompt-builder"; +import type { OhMyOpenCodeConfig } from "../config"; +import type { Managers } from "../create-managers"; +import { getMainSessionID } from "../features/claude-code-session-state"; +import { log } from "../shared"; +import { filterDisabledTools } from "../shared/disabled-tools"; import { - builtinTools, - createBackgroundTools, - createCallOmoAgent, - createLookAt, - createSkillMcpTool, - createSkillTool, - createGrepTools, - createGlobTools, - createAstGrepTools, - createSessionManagerTools, - createDelegateTask, - discoverCommandsSync, - interactive_bash, - createTaskCreateTool, - createTaskGetTool, - createTaskList, - createTaskUpdateTool, - createHashlineEditTool, -} from "../tools" -import { getMainSessionID } from "../features/claude-code-session-state" -import { filterDisabledTools } from "../shared/disabled-tools" -import { log } from "../shared" - -import type { Managers } from "../create-managers" -import type { SkillContext } from "./skill-context" + builtinTools, + createAstGrepTools, + createBackgroundTools, + createCallOmoAgent, + createDelegateTask, + createGlobTools, + createGrepTools, + createHashlineEditTool, + createLookAt, + createSessionManagerTools, + createSkillMcpTool, + createSkillTool, + createTaskCreateTool, + createTaskGetTool, + createTaskList, + createTaskUpdateTool, + discoverCommandsSync, + interactive_bash, +} from "../tools"; +import type { SkillContext } from "./skill-context"; +import type { PluginContext, ToolsRecord } from "./types"; export type ToolRegistryResult = { - filteredTools: ToolsRecord - taskSystemEnabled: boolean -} + filteredTools: ToolsRecord; + taskSystemEnabled: boolean; +}; export function createToolRegistry(args: { - ctx: PluginContext - pluginConfig: OhMyOpenCodeConfig - managers: Pick - skillContext: SkillContext - availableCategories: AvailableCategory[] + ctx: PluginContext; + pluginConfig: OhMyOpenCodeConfig; + managers: Pick< + Managers, + "backgroundManager" | "tmuxSessionManager" | "skillMcpManager" + >; + skillContext: SkillContext; + availableCategories: AvailableCategory[]; }): ToolRegistryResult { - const { ctx, pluginConfig, managers, skillContext, availableCategories } = args - - const backgroundTools = createBackgroundTools(managers.backgroundManager, ctx.client) - const callOmoAgent = createCallOmoAgent(ctx, managers.backgroundManager, pluginConfig.disabled_agents ?? []) - - const isMultimodalLookerEnabled = !(pluginConfig.disabled_agents ?? []).some( - (agent) => agent.toLowerCase() === "multimodal-looker", - ) - const lookAt = isMultimodalLookerEnabled ? createLookAt(ctx) : null - - const delegateTask = createDelegateTask({ - manager: managers.backgroundManager, - client: ctx.client, - directory: ctx.directory, - userCategories: pluginConfig.categories, - agentOverrides: pluginConfig.agents, - gitMasterConfig: pluginConfig.git_master, - sisyphusJuniorModel: pluginConfig.agents?.["sisyphus-junior"]?.model, - browserProvider: skillContext.browserProvider, - disabledSkills: skillContext.disabledSkills, - availableCategories, - availableSkills: skillContext.availableSkills, - syncPollTimeoutMs: pluginConfig.background_task?.syncPollTimeoutMs, - onSyncSessionCreated: async (event) => { - log("[index] onSyncSessionCreated callback", { - sessionID: event.sessionID, - parentID: event.parentID, - title: event.title, - }) - await managers.tmuxSessionManager.onSessionCreated({ - type: "session.created", - properties: { - info: { - id: event.sessionID, - parentID: event.parentID, - title: event.title, - }, - }, - }) - }, - }) - - const getSessionIDForMcp = (): string => getMainSessionID() || "" - - const skillMcpTool = createSkillMcpTool({ - manager: managers.skillMcpManager, - getLoadedSkills: () => skillContext.mergedSkills, - getSessionID: getSessionIDForMcp, - }) - - const commands = discoverCommandsSync(ctx.directory, { - pluginsEnabled: pluginConfig.claude_code?.plugins ?? true, - enabledPluginsOverride: pluginConfig.claude_code?.plugins_override, - }) - const skillTool = createSkillTool({ - commands, - skills: skillContext.mergedSkills, - mcpManager: managers.skillMcpManager, - getSessionID: getSessionIDForMcp, - gitMasterConfig: pluginConfig.git_master, - }) - - const taskSystemEnabled = pluginConfig.experimental?.task_system ?? false - const taskToolsRecord: Record = taskSystemEnabled - ? { - task_create: createTaskCreateTool(pluginConfig, ctx), - task_get: createTaskGetTool(pluginConfig), - task_list: createTaskList(pluginConfig), - task_update: createTaskUpdateTool(pluginConfig, ctx), - } - : {} - - const hashlineEnabled = pluginConfig.hashline_edit ?? false - const hashlineToolsRecord: Record = hashlineEnabled - ? { edit: createHashlineEditTool() } - : {} - - const allTools: Record = { - ...builtinTools, - ...createGrepTools(ctx), - ...createGlobTools(ctx), - ...createAstGrepTools(ctx), - ...createSessionManagerTools(ctx), - ...backgroundTools, - call_omo_agent: callOmoAgent, - ...(lookAt ? { look_at: lookAt } : {}), - task: delegateTask, - skill_mcp: skillMcpTool, - skill: skillTool, - interactive_bash, - ...taskToolsRecord, - ...hashlineToolsRecord, - } - - const filteredTools = filterDisabledTools(allTools, pluginConfig.disabled_tools) - - return { - filteredTools, - taskSystemEnabled, - } + const { ctx, pluginConfig, managers, skillContext, availableCategories } = + args; + + const backgroundTools = createBackgroundTools( + managers.backgroundManager, + ctx.client, + ); + const callOmoAgent = createCallOmoAgent( + ctx, + managers.backgroundManager, + pluginConfig.disabled_agents ?? [], + ); + + const isMultimodalLookerEnabled = !(pluginConfig.disabled_agents ?? []).some( + (agent) => agent.toLowerCase() === "multimodal-looker", + ); + const lookAt = isMultimodalLookerEnabled ? createLookAt(ctx) : null; + + const delegateTask = createDelegateTask({ + manager: managers.backgroundManager, + client: ctx.client, + directory: ctx.directory, + userCategories: pluginConfig.categories, + agentOverrides: pluginConfig.agents, + gitMasterConfig: pluginConfig.git_master, + sisyphusJuniorModel: pluginConfig.agents?.["sisyphus-junior"]?.model, + modelProfile: pluginConfig.model_profile, + browserProvider: skillContext.browserProvider, + disabledSkills: skillContext.disabledSkills, + availableCategories, + availableSkills: skillContext.availableSkills, + syncPollTimeoutMs: pluginConfig.background_task?.syncPollTimeoutMs, + onSyncSessionCreated: async (event) => { + log("[index] onSyncSessionCreated callback", { + sessionID: event.sessionID, + parentID: event.parentID, + title: event.title, + }); + await managers.tmuxSessionManager.onSessionCreated({ + type: "session.created", + properties: { + info: { + id: event.sessionID, + parentID: event.parentID, + title: event.title, + }, + }, + }); + }, + }); + + const getSessionIDForMcp = (): string => getMainSessionID() || ""; + + const skillMcpTool = createSkillMcpTool({ + manager: managers.skillMcpManager, + getLoadedSkills: () => skillContext.mergedSkills, + getSessionID: getSessionIDForMcp, + }); + + const commands = discoverCommandsSync(ctx.directory, { + pluginsEnabled: pluginConfig.claude_code?.plugins ?? true, + enabledPluginsOverride: pluginConfig.claude_code?.plugins_override, + }); + const skillTool = createSkillTool({ + commands, + skills: skillContext.mergedSkills, + mcpManager: managers.skillMcpManager, + getSessionID: getSessionIDForMcp, + gitMasterConfig: pluginConfig.git_master, + }); + + const taskSystemEnabled = pluginConfig.experimental?.task_system ?? false; + const taskToolsRecord: Record = taskSystemEnabled + ? { + task_create: createTaskCreateTool(pluginConfig, ctx), + task_get: createTaskGetTool(pluginConfig), + task_list: createTaskList(pluginConfig), + task_update: createTaskUpdateTool(pluginConfig, ctx), + } + : {}; + + const hashlineEnabled = pluginConfig.hashline_edit ?? false; + const hashlineToolsRecord: Record = hashlineEnabled + ? { edit: createHashlineEditTool() } + : {}; + + const allTools: Record = { + ...builtinTools, + ...createGrepTools(ctx), + ...createGlobTools(ctx), + ...createAstGrepTools(ctx), + ...createSessionManagerTools(ctx), + ...backgroundTools, + call_omo_agent: callOmoAgent, + ...(lookAt ? { look_at: lookAt } : {}), + task: delegateTask, + skill_mcp: skillMcpTool, + skill: skillTool, + interactive_bash, + ...taskToolsRecord, + ...hashlineToolsRecord, + }; + + const filteredTools = filterDisabledTools( + allTools, + pluginConfig.disabled_tools, + ); + + return { + filteredTools, + taskSystemEnabled, + }; } diff --git a/src/shared/model-registry/cost-estimation.test.ts b/src/shared/model-registry/cost-estimation.test.ts new file mode 100644 index 0000000000..21494122ca --- /dev/null +++ b/src/shared/model-registry/cost-estimation.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from "bun:test" +import { estimateModelCost, compareModelCosts } from "./cost-estimation" + +describe("Cost Estimation", () => { + describe("#given estimateModelCost function", () => { + describe("#when looking up claude-opus-4-6", () => { + it("#then returns cost estimate with expensive label", () => { + const estimate = estimateModelCost("claude-opus-4-6") + expect(estimate).toBeDefined() + expect(estimate?.model).toBe("claude-opus-4-6") + expect(estimate?.inputPer1MTokens).toBeGreaterThan(0) + expect(estimate?.outputPer1MTokens).toBeGreaterThan(0) + expect(estimate?.label).toBe("expensive") + }) + }) + + describe("#when looking up claude-haiku-4-5", () => { + it("#then returns cost estimate with cheap label", () => { + const estimate = estimateModelCost("claude-haiku-4-5") + expect(estimate).toBeDefined() + expect(estimate?.model).toBe("claude-haiku-4-5") + expect(estimate?.inputPer1MTokens).toBeGreaterThan(0) + expect(estimate?.outputPer1MTokens).toBeGreaterThan(0) + expect(estimate?.label).toBe("cheap") + }) + }) + + describe("#when looking up gpt-5.4", () => { + it("#then returns cost estimate with very-expensive label", () => { + const estimate = estimateModelCost("gpt-5.4") + expect(estimate).toBeDefined() + expect(estimate?.model).toBe("gpt-5.4") + expect(estimate?.label).toBe("very-expensive") + }) + }) + + describe("#when looking up gpt-5-nano", () => { + it("#then returns cost estimate with cheap label", () => { + const estimate = estimateModelCost("gpt-5-nano") + expect(estimate).toBeDefined() + expect(estimate?.model).toBe("gpt-5-nano") + expect(estimate?.label).toBe("cheap") + }) + }) + + describe("#when looking up nonexistent model", () => { + it("#then returns undefined", () => { + const estimate = estimateModelCost("nonexistent-model-xyz") + expect(estimate).toBeUndefined() + }) + }) + + describe("#when looking up gemini-3-flash", () => { + it("#then returns cost estimate with very-cheap label", () => { + const estimate = estimateModelCost("gemini-3-flash") + expect(estimate).toBeDefined() + expect(estimate?.model).toBe("gemini-3-flash") + expect(estimate?.label).toBe("very-cheap") + }) + }) + + describe("#when looking up free model", () => { + it("#then returns cost estimate with free label", () => { + const estimate = estimateModelCost("minimax-m2.5-free") + expect(estimate).toBeDefined() + expect(estimate?.model).toBe("minimax-m2.5-free") + expect(estimate?.label).toBe("free") + }) + }) + }) + + describe("#given compareModelCosts function", () => { + describe("#when comparing multiple models", () => { + it("#then returns array sorted by cost cheapest first", () => { + const models = ["claude-opus-4-6", "claude-haiku-4-5", "gpt-5.4"] + const sorted = compareModelCosts(models) + expect(sorted.length).toBe(3) + expect(sorted[0].model).toBe("claude-haiku-4-5") + expect(sorted[sorted.length - 1].model).toBe("gpt-5.4") + }) + }) + + describe("#when comparing with nonexistent models", () => { + it("#then filters out undefined results", () => { + const models = ["claude-haiku-4-5", "nonexistent", "gpt-5-nano"] + const sorted = compareModelCosts(models) + expect(sorted.length).toBe(2) + expect(sorted.every(e => e !== undefined)).toBe(true) + }) + }) + + describe("#when comparing empty array", () => { + it("#then returns empty array", () => { + const sorted = compareModelCosts([]) + expect(sorted.length).toBe(0) + }) + }) + + describe("#when comparing single model", () => { + it("#then returns array with one element", () => { + const sorted = compareModelCosts(["claude-haiku-4-5"]) + expect(sorted.length).toBe(1) + expect(sorted[0].model).toBe("claude-haiku-4-5") + }) + }) + + describe("#when comparing models with same cost", () => { + it("#then maintains stable sort order", () => { + const models = ["claude-haiku-4-5", "gpt-5-nano"] + const sorted = compareModelCosts(models) + expect(sorted.length).toBe(2) + expect(sorted[0].label).toBe("cheap") + expect(sorted[1].label).toBe("cheap") + }) + }) + }) + + describe("#given cost label thresholds", () => { + describe("#when input cost is $0", () => { + it("#then label is free", () => { + const estimate = estimateModelCost("minimax-m2.5-free") + expect(estimate?.label).toBe("free") + }) + }) + + describe("#when input cost is between $0 and $0.5", () => { + it("#then label is very-cheap", () => { + const estimate = estimateModelCost("gemini-3-flash") + expect(estimate?.label).toBe("very-cheap") + }) + }) + + describe("#when input cost is between $0.5 and $2", () => { + it("#then label is cheap", () => { + const estimate = estimateModelCost("claude-haiku-4-5") + expect(estimate?.label).toBe("cheap") + }) + }) + + describe("#when input cost is between $2 and $10", () => { + it("#then label is moderate", () => { + const estimate = estimateModelCost("claude-sonnet-4-6") + expect(estimate?.label).toBe("moderate") + }) + }) + + describe("#when input cost is between $10 and $30", () => { + it("#then label is expensive", () => { + const estimate = estimateModelCost("claude-opus-4-6") + expect(estimate?.label).toBe("expensive") + }) + }) + + describe("#when input cost is $30 or more", () => { + it("#then label is very-expensive", () => { + const estimate = estimateModelCost("gpt-5.4") + expect(estimate?.label).toBe("very-expensive") + }) + }) + }) +}) diff --git a/src/shared/model-registry/cost-estimation.ts b/src/shared/model-registry/cost-estimation.ts new file mode 100644 index 0000000000..a2ed72de4e --- /dev/null +++ b/src/shared/model-registry/cost-estimation.ts @@ -0,0 +1,43 @@ +import { MODEL_REGISTRY } from "./registry" + +export type CostLabel = "free" | "very-cheap" | "cheap" | "moderate" | "expensive" | "very-expensive" + +export interface CostEstimate { + model: string + inputPer1MTokens: number + outputPer1MTokens: number + label: CostLabel +} + +function assignCostLabel(inputCost: number): CostLabel { + if (inputCost === 0) return "free" + if (inputCost < 0.5) return "very-cheap" + if (inputCost < 2) return "cheap" + if (inputCost < 10) return "moderate" + if (inputCost < 30) return "expensive" + return "very-expensive" +} + +export function estimateModelCost(modelName: string): CostEstimate | undefined { + const entry = MODEL_REGISTRY[modelName] + if (!entry) return undefined + + const inputCost = entry.costPer1MInputTokens ?? 0 + const outputCost = entry.costPer1MOutputTokens ?? 0 + const label = assignCostLabel(inputCost) + + return { + model: modelName, + inputPer1MTokens: inputCost, + outputPer1MTokens: outputCost, + label, + } +} + +export function compareModelCosts(models: string[]): CostEstimate[] { + const estimates = models + .map(model => estimateModelCost(model)) + .filter((estimate): estimate is CostEstimate => estimate !== undefined) + + return estimates.sort((a, b) => a.inputPer1MTokens - b.inputPer1MTokens) +} diff --git a/src/shared/model-registry/index.ts b/src/shared/model-registry/index.ts new file mode 100644 index 0000000000..d228d4d229 --- /dev/null +++ b/src/shared/model-registry/index.ts @@ -0,0 +1,6 @@ +export type { ModelCapability, ModelTier, SpeedClass, ModelEntry, ModelRegistry } from "./types" +export { MODEL_REGISTRY, isModelUnstable } from "./registry" +export type { ProfileName, ProfileOverride } from "./profiles" +export { PROFILE_PRESETS, DEFAULT_PROFILE, getProfileOverride } from "./profiles" +export type { CostLabel, CostEstimate } from "./cost-estimation" +export { estimateModelCost, compareModelCosts } from "./cost-estimation" diff --git a/src/shared/model-registry/integration.test.ts b/src/shared/model-registry/integration.test.ts new file mode 100644 index 0000000000..b15e5b1aa7 --- /dev/null +++ b/src/shared/model-registry/integration.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "bun:test"; +import { applyOverrides } from "../../agents/builtin-agents/agent-overrides"; +import { applyModelResolution } from "../../agents/builtin-agents/model-resolution"; +import { createExploreAgent } from "../../agents/explore"; +import { createMetisAgent } from "../../agents/metis"; +import { AGENT_MODEL_REQUIREMENTS } from "../../shared"; +import { mergeCategories } from "../../shared/merge-categories"; +import { MODEL_REGISTRY } from "./registry"; + +type IntegrationConfig = { + agents?: Record; + model_profile?: "premium" | "balanced" | "economy"; +}; + +const ALL_AVAILABLE_MODELS = new Set([ + ...Object.keys(MODEL_REGISTRY), + "openai/gpt-5.4", + "anthropic/claude-sonnet-4-6", + "google/gemini-3-flash", +]); + +function resolveAgent(config: IntegrationConfig, agentName: string) { + return applyModelResolution({ + userModel: config.agents?.[agentName]?.model, + profileName: config.model_profile, + agentName, + requirement: AGENT_MODEL_REQUIREMENTS[agentName], + availableModels: ALL_AVAILABLE_MODELS, + systemDefaultModel: "claude-opus-4-6", + }); +} + +describe("model registry integration", () => { + describe("#given empty config", () => { + describe("#when resolving against hardcoded requirements", () => { + test("#then agents resolve to the same hardcoded models", () => { + expect(resolveAgent({}, "sisyphus")?.model).toBe("claude-opus-4-6"); + expect(resolveAgent({}, "oracle")?.model).toBe("openai/gpt-5.4"); + expect(resolveAgent({}, "explore")?.model).toBe("grok-code-fast-1"); + }); + }); + }); + + describe("#given profile and manual override", () => { + describe("#when oracle model is manually pinned", () => { + test("#then manual override beats profile override", () => { + const result = resolveAgent( + { + agents: { + oracle: { model: "openai/gpt-5.4" }, + }, + model_profile: "economy", + }, + "oracle", + ); + + expect(result?.model).toBe("openai/gpt-5.4"); + }); + }); + }); + + describe("#given economy profile", () => { + describe("#when resolving profile-aware models", () => { + test("#then sisyphus downgrades to sonnet and oracle downgrades to flash", () => { + const sisyphus = resolveAgent({ model_profile: "economy" }, "sisyphus"); + const oracle = resolveAgent({ model_profile: "economy" }, "oracle"); + + expect(sisyphus?.model).toBe("claude-sonnet-4-6"); + expect(sisyphus?.model).not.toBe("claude-opus-4-6"); + expect(oracle?.model).toBe("gemini-3-flash"); + }); + }); + }); + + describe("#given premium profile", () => { + describe("#when comparing with no profile", () => { + test("#then behavior is unchanged", () => { + const withoutProfile = resolveAgent({}, "sisyphus"); + const withPremiumProfile = resolveAgent({ model_profile: "premium" }, "sisyphus"); + + expect(withPremiumProfile).toEqual(withoutProfile); + }); + }); + }); + + describe("#given balanced profile", () => { + describe("#when comparing with no profile", () => { + test("#then behavior is unchanged", () => { + const withoutProfile = resolveAgent({}, "oracle"); + const withBalancedProfile = resolveAgent( + { model_profile: "balanced" }, + "oracle", + ); + + expect(withBalancedProfile).toEqual(withoutProfile); + }); + }); + }); + + describe("#given agent override with fallback_models", () => { + describe("#when applying overrides to explore", () => { + test("#then fallback_models are preserved", () => { + const base = createExploreAgent("grok-code-fast-1"); + const mergedCategories = mergeCategories(); + const overridden = applyOverrides( + base, + { + fallback_models: ["provider-a/model-a", "provider-b/model-b"], + }, + mergedCategories, + ); + + expect((overridden as { fallback_models?: string[] }).fallback_models).toEqual([ + "provider-a/model-a", + "provider-b/model-b", + ]); + }); + }); + }); + + describe("#given metis category override", () => { + describe("#when category is switched to quick", () => { + test("#then quick category model is applied", () => { + const base = createMetisAgent("claude-opus-4-6"); + const mergedCategories = mergeCategories(); + const overridden = applyOverrides( + base, + { category: "quick" }, + mergedCategories, + ); + + expect(overridden.model).toContain("claude-haiku-4-5"); + expect(overridden.model).not.toContain("claude-opus-4-6"); + expect((overridden as { category?: string }).category).toBe("quick"); + }); + }); + }); +}); diff --git a/src/shared/model-registry/profile-injection.test.ts b/src/shared/model-registry/profile-injection.test.ts new file mode 100644 index 0000000000..b2e94d9959 --- /dev/null +++ b/src/shared/model-registry/profile-injection.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "bun:test"; +import { applyModelResolution } from "../../agents/builtin-agents/model-resolution"; +import { AGENT_MODEL_REQUIREMENTS } from "../../shared"; + +describe("Profile override injection", () => { + describe("#given no manual agent model override", () => { + describe("#when profile override exists for agent", () => { + it("#then profile override model is injected as userModel", () => { + const result = applyModelResolution({ + profileName: "economy", + agentName: "sisyphus", + requirement: AGENT_MODEL_REQUIREMENTS.sisyphus, + availableModels: new Set(["anthropic/claude-sonnet-4-6"]), + systemDefaultModel: "anthropic/claude-opus-4-6", + }); + + expect(result).toMatchObject({ + model: "claude-sonnet-4-6", + provenance: "override", + }); + }); + }); + }); + + describe("#given manual agent model override", () => { + describe("#when profile override also exists", () => { + it("#then manual override wins over profile", () => { + const result = applyModelResolution({ + userModel: "openai/gpt-5.4", + profileName: "economy", + agentName: "sisyphus", + requirement: AGENT_MODEL_REQUIREMENTS.sisyphus, + availableModels: new Set([ + "openai/gpt-5.4", + "anthropic/claude-sonnet-4-6", + ]), + systemDefaultModel: "anthropic/claude-opus-4-6", + }); + + expect(result).toMatchObject({ + model: "openai/gpt-5.4", + provenance: "override", + }); + }); + }); + }); + + describe("#given balanced profile", () => { + describe("#when resolving model with and without profile name", () => { + it("#then behavior remains unchanged", () => { + const withoutProfile = applyModelResolution({ + requirement: AGENT_MODEL_REQUIREMENTS.oracle, + availableModels: new Set(["openai/gpt-5.4"]), + systemDefaultModel: "anthropic/claude-opus-4-6", + }); + + const withBalancedProfile = applyModelResolution({ + profileName: "balanced", + agentName: "oracle", + requirement: AGENT_MODEL_REQUIREMENTS.oracle, + availableModels: new Set(["openai/gpt-5.4"]), + systemDefaultModel: "anthropic/claude-opus-4-6", + }); + + expect(withBalancedProfile?.model).toEqual(withoutProfile?.model); + expect(withBalancedProfile?.provenance).toEqual(withoutProfile?.provenance); + }); + }); + }); +}); diff --git a/src/shared/model-registry/profiles.test.ts b/src/shared/model-registry/profiles.test.ts new file mode 100644 index 0000000000..e1d61acb04 --- /dev/null +++ b/src/shared/model-registry/profiles.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "bun:test" +import { PROFILE_PRESETS, DEFAULT_PROFILE, getProfileOverride } from "./profiles" + +describe("Model Profiles", () => { + describe("#given profile presets", () => { + describe("#when accessing premium profile", () => { + it("#then returns empty overrides", () => { + expect(PROFILE_PRESETS.premium).toEqual({}) + }) + }) + + describe("#when accessing balanced profile", () => { + it("#then returns empty overrides", () => { + expect(PROFILE_PRESETS.balanced).toEqual({}) + }) + }) + + describe("#when accessing economy profile", () => { + it("#then has overrides for expensive agents", () => { + const economy = PROFILE_PRESETS.economy + expect(economy.sisyphus).toBeDefined() + expect(economy.oracle).toBeDefined() + expect(economy.metis).toBeDefined() + expect(economy.momus).toBeDefined() + expect(economy.prometheus).toBeDefined() + expect("multimodal-looker" in economy).toBe(true) + }) + + it("#then has no overrides for cheap agents", () => { + const economy = PROFILE_PRESETS.economy + expect(economy.hephaestus).toBeUndefined() + expect(economy.explore).toBeUndefined() + expect(economy.librarian).toBeUndefined() + expect(economy.atlas).toBeUndefined() + expect(economy["sisyphus-junior"]).toBeUndefined() + }) + + it("#then sisyphus downgrades to claude-sonnet-4-6", () => { + const override = PROFILE_PRESETS.economy.sisyphus + expect(override?.model).toBe("claude-sonnet-4-6") + expect(override?.variant).toBeUndefined() + }) + + it("#then oracle downgrades to gemini-3-flash", () => { + const override = PROFILE_PRESETS.economy.oracle + expect(override?.model).toBe("gemini-3-flash") + expect(override?.variant).toBeUndefined() + }) + + it("#then metis downgrades to gpt-5.4 medium", () => { + const override = PROFILE_PRESETS.economy.metis + expect(override?.model).toBe("gpt-5.4") + expect(override?.variant).toBe("medium") + }) + + it("#then momus downgrades to gemini-3.1-pro medium", () => { + const override = PROFILE_PRESETS.economy.momus + expect(override?.model).toBe("gemini-3.1-pro") + expect(override?.variant).toBe("medium") + }) + + it("#then prometheus downgrades to gpt-5.4 medium", () => { + const override = PROFILE_PRESETS.economy.prometheus + expect(override?.model).toBe("gpt-5.4") + expect(override?.variant).toBe("medium") + }) + + it("#then multimodal-looker downgrades to gemini-3-flash", () => { + const override = PROFILE_PRESETS.economy["multimodal-looker"] + expect(override?.model).toBe("gemini-3-flash") + expect(override?.variant).toBeUndefined() + }) + }) + }) + + describe("#given default profile", () => { + it("#then is balanced", () => { + expect(DEFAULT_PROFILE).toBe("balanced") + }) + }) + + describe("#given getProfileOverride function", () => { + describe("#when looking up premium profile", () => { + it("#then returns undefined for any agent", () => { + expect(getProfileOverride("premium", "sisyphus")).toBeUndefined() + expect(getProfileOverride("premium", "oracle")).toBeUndefined() + }) + }) + + describe("#when looking up balanced profile", () => { + it("#then returns undefined for any agent", () => { + expect(getProfileOverride("balanced", "sisyphus")).toBeUndefined() + expect(getProfileOverride("balanced", "oracle")).toBeUndefined() + }) + }) + + describe("#when looking up economy profile", () => { + it("#then returns override for sisyphus", () => { + const override = getProfileOverride("economy", "sisyphus") + expect(override).toEqual({ model: "claude-sonnet-4-6" }) + }) + + it("#then returns undefined for hephaestus", () => { + expect(getProfileOverride("economy", "hephaestus")).toBeUndefined() + }) + + it("#then returns undefined for unknown agent", () => { + expect(getProfileOverride("economy", "unknown-agent")).toBeUndefined() + }) + }) + }) +}) diff --git a/src/shared/model-registry/profiles.ts b/src/shared/model-registry/profiles.ts new file mode 100644 index 0000000000..bd77386d2b --- /dev/null +++ b/src/shared/model-registry/profiles.ts @@ -0,0 +1,31 @@ +export type ProfileName = "premium" | "balanced" | "economy" + +export interface ProfileOverride { + model: string + variant?: string +} + +export const PROFILE_PRESETS: Record> = { + premium: {}, + balanced: {}, + economy: { + sisyphus: { model: "claude-sonnet-4-6" }, + oracle: { model: "gemini-3-flash" }, + metis: { model: "gpt-5.4", variant: "medium" }, + momus: { model: "gemini-3.1-pro", variant: "medium" }, + prometheus: { model: "gpt-5.4", variant: "medium" }, + "multimodal-looker": { model: "gemini-3-flash" }, + ultrabrain: { model: "gpt-5.4", variant: "medium" }, + deep: { model: "gpt-5.4" }, + }, +} + +export const DEFAULT_PROFILE: ProfileName = "balanced" + +export function getProfileOverride( + profileName: ProfileName, + agentName: string, +): ProfileOverride | undefined { + const preset = PROFILE_PRESETS[profileName] + return preset[agentName] +} diff --git a/src/shared/model-registry/registry.test.ts b/src/shared/model-registry/registry.test.ts new file mode 100644 index 0000000000..bc16d5c160 --- /dev/null +++ b/src/shared/model-registry/registry.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test" +import { + AGENT_MODEL_REQUIREMENTS, + CATEGORY_MODEL_REQUIREMENTS, +} from "../model-requirements" +import { MODEL_REGISTRY, isModelUnstable } from "./registry" + +const UNSTABLE_MODELS = [ + "gemini-3.1-pro", + "gemini-3-flash", + "kimi-k2.5", + "minimax-m2.5-free", +] as const + +function getExpectedProvidersByModel(): Map { + const providersByModel = new Map>() + const requirements = [AGENT_MODEL_REQUIREMENTS, CATEGORY_MODEL_REQUIREMENTS] + + for (const requirementGroup of requirements) { + for (const requirement of Object.values(requirementGroup)) { + for (const entry of requirement.fallbackChain) { + const providers = providersByModel.get(entry.model) ?? new Set() + + for (const provider of entry.providers) { + providers.add(provider) + } + + providersByModel.set(entry.model, providers) + } + } + } + + return new Map( + [...providersByModel.entries()].map(([model, providers]) => [model, [...providers].sort()]), + ) +} + +describe("MODEL_REGISTRY", () => { + describe("#given agent fallback chains", () => { + test("#when checking registry entries #then every agent fallback model exists", () => { + // given + const agentModels = new Set( + Object.values(AGENT_MODEL_REQUIREMENTS).flatMap((requirement) => + requirement.fallbackChain.map((entry) => entry.model), + ), + ) + + // when + const missingModels = [...agentModels].filter((model) => !(model in MODEL_REGISTRY)) + + // then + expect(missingModels).toEqual([]) + }) + }) + + describe("#given registry model names", () => { + test("#when validating registry keys #then all names are bare model ids", () => { + // given + const modelNames = Object.keys(MODEL_REGISTRY) + + // when + const prefixedModels = modelNames.filter((modelName) => modelName.includes("/")) + + // then + expect(prefixedModels).toEqual([]) + }) + }) + + describe("#given unstable model families", () => { + test("#when reading unstable entries #then unstable models are explicitly marked", () => { + // given + const unstableEntries = UNSTABLE_MODELS.map((model) => MODEL_REGISTRY[model]) + + // when + const missingFlags = unstableEntries.filter((entry) => entry?.isUnstable !== true) + + // then + expect(missingFlags).toEqual([]) + }) + }) + + describe("#given isModelUnstable function", () => { + test("#when checking known registry model #then uses registry entry", () => { + expect(isModelUnstable("gemini-3.1-pro")).toBe(true) + expect(isModelUnstable("claude-opus-4-6")).toBe(false) + }) + + test("#when checking unlisted model with unstable family name #then falls back to substring match", () => { + expect(isModelUnstable("gemini-3.1-pro-preview")).toBe(true) + expect(isModelUnstable("kimi-k3-experimental")).toBe(true) + expect(isModelUnstable("minimax-m3-turbo")).toBe(true) + }) + + test("#when checking case variants #then matches case-insensitively", () => { + expect(isModelUnstable("GEMINI-3.1-PRO")).toBe(true) + expect(isModelUnstable("Kimi-K2.5")).toBe(true) + }) + + test("#when checking stable unlisted model #then returns false", () => { + expect(isModelUnstable("gpt-6-turbo")).toBe(false) + expect(isModelUnstable("claude-next")).toBe(false) + }) + + test("#when input is undefined #then returns false", () => { + expect(isModelUnstable(undefined)).toBe(false) + }) + }) + + describe("#given current fallback-chain provider data", () => { + test("#when comparing registry providers #then provider arrays match current model requirements", () => { + // given + const expectedProvidersByModel = getExpectedProvidersByModel() + + // when + const mismatches = [...expectedProvidersByModel.entries()].filter(([model, providers]) => { + const registryProviders = [...(MODEL_REGISTRY[model]?.providers ?? [])].sort() + + return JSON.stringify(registryProviders) !== JSON.stringify(providers) + }) + + // then + expect(mismatches).toEqual([]) + }) + }) +}) diff --git a/src/shared/model-registry/registry.ts b/src/shared/model-registry/registry.ts new file mode 100644 index 0000000000..3b013bf6e2 --- /dev/null +++ b/src/shared/model-registry/registry.ts @@ -0,0 +1,224 @@ +import type { ModelRegistry } from "./types" + +function deriveUnstableFamilies(registry: ModelRegistry): Set { + const families = new Set() + for (const entry of Object.values(registry)) { + if (entry.isUnstable && entry.family) families.add(entry.family) + } + return families +} + +export const MODEL_REGISTRY: ModelRegistry = { + "claude-opus-4-6": { + name: "Claude Opus 4.6", + providers: ["anthropic", "github-copilot", "opencode"], + capabilities: ["strong-reasoning", "code-generation", "long-context"], + tier: "premium", + speed: "normal", + costPer1MInputTokens: 15, + costPer1MOutputTokens: 45, + contextWindow: 200000, + isUnstable: false, + family: "claude", + }, + "claude-sonnet-4-6": { + name: "Claude Sonnet 4.6", + providers: ["anthropic", "github-copilot", "opencode"], + capabilities: ["strong-reasoning", "code-generation", "long-context"], + tier: "standard", + speed: "normal", + costPer1MInputTokens: 3, + costPer1MOutputTokens: 15, + contextWindow: 200000, + isUnstable: false, + family: "claude", + }, + "claude-haiku-4-5": { + name: "Claude Haiku 4.5", + providers: ["anthropic", "github-copilot", "opencode"], + capabilities: ["fast-inference", "code-generation"], + tier: "economy", + speed: "fast", + costPer1MInputTokens: 0.8, + costPer1MOutputTokens: 4, + contextWindow: 200000, + isUnstable: false, + family: "claude", + }, + "gpt-5.4": { + name: "GPT-5.4", + providers: ["openai", "github-copilot", "opencode"], + capabilities: ["strong-reasoning", "code-generation", "multimodal"], + tier: "premium", + speed: "normal", + costPer1MInputTokens: 30, + costPer1MOutputTokens: 120, + contextWindow: 128000, + isUnstable: false, + family: "gpt", + }, + "gpt-5.3-codex": { + name: "GPT-5.3 Codex", + providers: ["openai", "venice", "opencode"], + capabilities: ["autonomous-execution", "code-generation"], + tier: "premium", + speed: "normal", + costPer1MInputTokens: 25, + costPer1MOutputTokens: 100, + contextWindow: 128000, + isUnstable: false, + family: "gpt", + }, + "gpt-5-nano": { + name: "GPT-5 Nano", + providers: ["openai", "github-copilot", "opencode"], + capabilities: ["fast-inference", "code-generation"], + tier: "economy", + speed: "fast", + costPer1MInputTokens: 0.5, + costPer1MOutputTokens: 2, + contextWindow: 128000, + isUnstable: false, + family: "gpt", + }, + "gemini-3.1-pro": { + name: "Gemini 3.1 Pro", + providers: ["google", "github-copilot", "opencode"], + capabilities: ["strong-reasoning", "code-generation", "multimodal"], + tier: "premium", + speed: "normal", + costPer1MInputTokens: 7.5, + costPer1MOutputTokens: 30, + contextWindow: 1000000, + isUnstable: true, + family: "gemini", + }, + "gemini-3-flash": { + name: "Gemini 3 Flash", + providers: ["google", "github-copilot", "opencode"], + capabilities: ["fast-inference", "code-generation", "multimodal"], + tier: "economy", + speed: "fast", + costPer1MInputTokens: 0.075, + costPer1MOutputTokens: 0.3, + contextWindow: 1000000, + isUnstable: true, + family: "gemini", + }, + "k2p5": { + name: "Kimi K2.5", + providers: ["kimi-for-coding"], + capabilities: ["strong-reasoning", "code-generation", "long-context"], + tier: "standard", + speed: "normal", + costPer1MInputTokens: 5, + costPer1MOutputTokens: 15, + contextWindow: 200000, + isUnstable: true, + family: "kimi", + }, + "kimi-k2.5": { + name: "Kimi K2.5", + providers: [ + "opencode", + "moonshotai", + "moonshotai-cn", + "firmware", + "ollama-cloud", + "aihubmix", + ], + capabilities: ["strong-reasoning", "code-generation", "long-context"], + tier: "standard", + speed: "normal", + costPer1MInputTokens: 5, + costPer1MOutputTokens: 15, + contextWindow: 200000, + isUnstable: true, + family: "kimi", + }, + "glm-5": { + name: "GLM-5", + providers: ["zai-coding-plan", "opencode"], + capabilities: ["strong-reasoning", "code-generation"], + tier: "standard", + speed: "normal", + costPer1MInputTokens: 8, + costPer1MOutputTokens: 20, + contextWindow: 128000, + isUnstable: false, + family: "glm", + }, + "glm-4.6v": { + name: "GLM-4.6V", + providers: ["zai-coding-plan"], + capabilities: ["strong-reasoning", "code-generation", "multimodal"], + tier: "standard", + speed: "normal", + costPer1MInputTokens: 6, + costPer1MOutputTokens: 18, + contextWindow: 128000, + isUnstable: false, + family: "glm", + }, + "minimax-m2.5-free": { + name: "Minimax M2.5 Free", + providers: ["opencode"], + capabilities: ["code-generation", "instruction-following"], + tier: "economy", + speed: "fast", + costPer1MInputTokens: 0, + costPer1MOutputTokens: 0, + contextWindow: 200000, + isUnstable: true, + family: "minimax", + }, + "abab-5": { + name: "Minimax ABAB 5", + providers: ["opencode"], + capabilities: ["code-generation", "instruction-following"], + tier: "economy", + speed: "fast", + costPer1MInputTokens: 0, + costPer1MOutputTokens: 0, + contextWindow: 200000, + isUnstable: true, + family: "minimax", + }, + "grok-code-fast-1": { + name: "Grok Code Fast 1", + providers: ["github-copilot"], + capabilities: ["fast-inference", "code-generation"], + tier: "economy", + speed: "fast", + costPer1MInputTokens: 1, + costPer1MOutputTokens: 5, + contextWindow: 128000, + isUnstable: false, + family: "grok", + }, + "big-pickle": { + name: "Big Pickle", + providers: ["opencode"], + capabilities: ["code-generation", "instruction-following"], + tier: "economy", + speed: "normal", + costPer1MInputTokens: 0, + costPer1MOutputTokens: 0, + contextWindow: 128000, + isUnstable: false, + family: "big-pickle", + }, +} + +const UNSTABLE_FAMILIES = deriveUnstableFamilies(MODEL_REGISTRY) + +export function isModelUnstable(modelID: string | undefined): boolean { + if (!modelID) return false + const entry = MODEL_REGISTRY[modelID] + if (entry) return entry.isUnstable === true + const lower = modelID.toLowerCase() + for (const family of UNSTABLE_FAMILIES) { + if (lower.includes(family)) return true + } + return false +} diff --git a/src/shared/model-registry/types.ts b/src/shared/model-registry/types.ts new file mode 100644 index 0000000000..a9ad073d54 --- /dev/null +++ b/src/shared/model-registry/types.ts @@ -0,0 +1,28 @@ +export type ModelCapability = + | "strong-reasoning" + | "fast-inference" + | "code-generation" + | "multimodal" + | "long-context" + | "instruction-following" + | "creative-writing" + | "autonomous-execution" + +export type ModelTier = "premium" | "standard" | "economy" + +export type SpeedClass = "fast" | "normal" | "slow-ok" + +export type ModelEntry = { + name: string + providers: string[] + capabilities: ModelCapability[] + tier: ModelTier + speed: SpeedClass + costPer1MInputTokens?: number + costPer1MOutputTokens?: number + contextWindow?: number + isUnstable?: boolean + family?: string +} + +export type ModelRegistry = Record diff --git a/src/shared/model-resolution-pipeline.ts b/src/shared/model-resolution-pipeline.ts index c51cad3711..082fce127a 100644 --- a/src/shared/model-resolution-pipeline.ts +++ b/src/shared/model-resolution-pipeline.ts @@ -9,6 +9,7 @@ export type ModelResolutionRequest = { intent?: { uiSelectedModel?: string userModel?: string + userVariant?: string userFallbackModels?: string[] categoryDefaultModel?: string } @@ -19,6 +20,8 @@ export type ModelResolutionRequest = { policy?: { fallbackChain?: FallbackEntry[] systemDefaultModel?: string + profileName?: string + agentName?: string } } @@ -45,17 +48,18 @@ export function resolveModelPipeline( const availableModels = constraints.availableModels const fallbackChain = policy?.fallbackChain const systemDefaultModel = policy?.systemDefaultModel + const agentPrefix = policy?.agentName ? `Agent ${policy.agentName} ` : "" const normalizedUiModel = normalizeModel(intent?.uiSelectedModel) if (normalizedUiModel) { - log("Model resolved via UI selection", { model: normalizedUiModel }) - return { model: normalizedUiModel, provenance: "override" } + log(`[model-resolution] ${agentPrefix}resolved to ${normalizedUiModel} via uiSelected override`) + return { model: normalizedUiModel, provenance: "override", reason: "UI-selected model override" } } const normalizedUserModel = normalizeModel(intent?.userModel) if (normalizedUserModel) { - log("Model resolved via config override", { model: normalizedUserModel }) - return { model: normalizedUserModel, provenance: "override" } + log(`[model-resolution] ${agentPrefix}resolved to ${normalizedUserModel} via userModel override`) + return { model: normalizedUserModel, provenance: "override", variant: intent?.userVariant, reason: "User-configured model override" } } const normalizedCategoryDefault = normalizeModel(intent?.categoryDefaultModel) @@ -66,32 +70,24 @@ export function resolveModelPipeline( const providerHint = parts.length >= 2 ? [parts[0]] : undefined const match = fuzzyMatchModel(normalizedCategoryDefault, availableModels, providerHint) if (match) { - log("Model resolved via category default (fuzzy matched)", { - original: normalizedCategoryDefault, - matched: match, - }) - return { model: match, provenance: "category-default", attempted } + log(`[model-resolution] ${agentPrefix}category-default: ` + match) + return { model: match, provenance: "category-default", attempted, reason: "Category default model (fuzzy matched)" } } } else { const connectedProviders = constraints.connectedProviders ?? connectedProvidersCache.readConnectedProvidersCache() if (connectedProviders === null) { - log("Model resolved via category default (no cache, first run)", { - model: normalizedCategoryDefault, - }) - return { model: normalizedCategoryDefault, provenance: "category-default", attempted } + log(`[model-resolution] ${agentPrefix}category-default: ` + normalizedCategoryDefault) + return { model: normalizedCategoryDefault, provenance: "category-default", attempted, reason: "Category default model (no provider cache)" } } const parts = normalizedCategoryDefault.split("/") if (parts.length >= 2) { const provider = parts[0] - if (connectedProviders.includes(provider)) { - const modelName = parts.slice(1).join("/") - const transformedModel = `${provider}/${transformModelForProvider(provider, modelName)}` - log("Model resolved via category default (connected provider)", { - model: transformedModel, - original: normalizedCategoryDefault, - }) - return { model: transformedModel, provenance: "category-default", attempted } - } + if (connectedProviders.includes(provider)) { + const modelName = parts.slice(1).join("/") + const transformedModel = `${provider}/${transformModelForProvider(provider, modelName)}` + log(`[model-resolution] ${agentPrefix}category-default: ` + transformedModel) + return { model: transformedModel, provenance: "category-default", attempted, reason: "Category default model (connected provider)" } + } } } log("Category default model not available, falling through to fallback chain", { @@ -112,12 +108,12 @@ export function resolveModelPipeline( const parts = model.split("/") if (parts.length >= 2) { const provider = parts[0] - if (connectedSet.has(provider)) { - const modelName = parts.slice(1).join("/") - const transformedModel = `${provider}/${transformModelForProvider(provider, modelName)}` - log("Model resolved via user fallback_models (connected provider)", { model: transformedModel, original: model }) - return { model: transformedModel, provenance: "provider-fallback", attempted } - } + if (connectedSet.has(provider)) { + const modelName = parts.slice(1).join("/") + const transformedModel = `${provider}/${transformModelForProvider(provider, modelName)}` + log(`[model-resolution] ${agentPrefix}provider-fallback: ` + transformedModel + " (tried: " + attempted.join(", ") + ")") + return { model: transformedModel, provenance: "provider-fallback", attempted, reason: "User fallback model (connected provider)" } + } } } log("No connected provider found in user fallback_models, falling through to hardcoded chain") @@ -127,11 +123,11 @@ export function resolveModelPipeline( attempted.push(model) const parts = model.split("/") const providerHint = parts.length >= 2 ? [parts[0]] : undefined - const match = fuzzyMatchModel(model, availableModels, providerHint) - if (match) { - log("Model resolved via user fallback_models (availability confirmed)", { model: model, match }) - return { model: match, provenance: "provider-fallback", attempted } - } + const match = fuzzyMatchModel(model, availableModels, providerHint) + if (match) { + log(`[model-resolution] ${agentPrefix}provider-fallback: ` + match + " (tried: " + attempted.join(", ") + ")") + return { model: match, provenance: "provider-fallback", attempted, reason: "User fallback model (availability confirmed)" } + } } log("No available model found in user fallback_models, falling through to hardcoded chain") } @@ -147,21 +143,18 @@ export function resolveModelPipeline( } else { for (const entry of fallbackChain) { for (const provider of entry.providers) { - if (connectedSet.has(provider)) { - const transformedModelId = transformModelForProvider(provider, entry.model) - const model = `${provider}/${transformedModelId}` - log("Model resolved via fallback chain (connected provider)", { - provider, - model: transformedModelId, - variant: entry.variant, - }) - return { - model, - provenance: "provider-fallback", - variant: entry.variant, - attempted, - } - } + if (connectedSet.has(provider)) { + const transformedModelId = transformModelForProvider(provider, entry.model) + const model = `${provider}/${transformedModelId}` + log(`[model-resolution] ${agentPrefix}provider-fallback: ` + model + " (tried: " + attempted.join(", ") + ")") + return { + model, + provenance: "provider-fallback", + variant: entry.variant, + attempted, + reason: "Fallback chain model (connected provider)", + } + } } } log("No connected provider found in fallback chain, falling through to system default") @@ -170,47 +163,40 @@ export function resolveModelPipeline( for (const entry of fallbackChain) { for (const provider of entry.providers) { const fullModel = `${provider}/${entry.model}` - const match = fuzzyMatchModel(fullModel, availableModels, [provider]) - if (match) { - log("Model resolved via fallback chain (availability confirmed)", { - provider, - model: entry.model, - match, - variant: entry.variant, - }) - return { - model: match, - provenance: "provider-fallback", - variant: entry.variant, - attempted, - } - } + const match = fuzzyMatchModel(fullModel, availableModels, [provider]) + if (match) { + log(`[model-resolution] ${agentPrefix}provider-fallback: ` + match + " (tried: " + attempted.join(", ") + ")") + return { + model: match, + provenance: "provider-fallback", + variant: entry.variant, + attempted, + reason: "Fallback chain model (availability confirmed)", + } + } } - const crossProviderMatch = fuzzyMatchModel(entry.model, availableModels) - if (crossProviderMatch) { - log("Model resolved via fallback chain (cross-provider fuzzy match)", { - model: entry.model, - match: crossProviderMatch, - variant: entry.variant, - }) - return { - model: crossProviderMatch, - provenance: "provider-fallback", - variant: entry.variant, - attempted, - } - } + const crossProviderMatch = fuzzyMatchModel(entry.model, availableModels) + if (crossProviderMatch) { + log(`[model-resolution] ${agentPrefix}provider-fallback: ` + crossProviderMatch + " (tried: " + attempted.join(", ") + ")") + return { + model: crossProviderMatch, + provenance: "provider-fallback", + variant: entry.variant, + attempted, + reason: "Fallback chain model (cross-provider fuzzy match)", + } + } } log("No available model found in fallback chain, falling through to system default") } } if (systemDefaultModel === undefined) { - log("No model resolved - systemDefaultModel not configured") + log(`[model-resolution] ${agentPrefix}no model resolved - systemDefaultModel not configured`) return undefined } - log("Model resolved via system default", { model: systemDefaultModel }) - return { model: systemDefaultModel, provenance: "system-default", attempted } + log(`[model-resolution] ${agentPrefix}system-default: ` + systemDefaultModel) + return { model: systemDefaultModel, provenance: "system-default", attempted, reason: "System default model (ultimate fallback)" } } diff --git a/src/tools/delegate-task/categories.ts b/src/tools/delegate-task/categories.ts index af6f6410f8..6d649979b8 100644 --- a/src/tools/delegate-task/categories.ts +++ b/src/tools/delegate-task/categories.ts @@ -1,21 +1,74 @@ -import type { CategoryConfig, CategoriesConfig } from "../../config/schema" -import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS } from "./constants" -import { resolveModel } from "../../shared/model-resolver" -import { isModelAvailable } from "../../shared/model-availability" -import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements" -import { log } from "../../shared/logger" +import type { CategoriesConfig, CategoryConfig } from "../../config/schema"; +import { log } from "../../shared/logger"; +import { + fuzzyMatchModel, + isModelAvailable, +} from "../../shared/model-availability"; +import type { ProfileName } from "../../shared/model-registry"; +import { getProfileOverride } from "../../shared/model-registry"; +import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"; +import { resolveModel } from "../../shared/model-resolver"; +import { CATEGORY_PROMPT_APPENDS, DEFAULT_CATEGORIES } from "./constants"; export interface ResolveCategoryConfigOptions { - userCategories?: CategoriesConfig - inheritedModel?: string - systemDefaultModel?: string - availableModels?: Set + userCategories?: CategoriesConfig; + modelProfile?: ProfileName; + inheritedModel?: string; + systemDefaultModel?: string; + availableModels?: Set; } export interface ResolveCategoryConfigResult { - config: CategoryConfig - promptAppend: string - model: string | undefined + config: CategoryConfig; + promptAppend: string; + model: string | undefined; + usedProfileOverride: boolean; +} + +function resolveProfileCategoryModel(input: { + categoryName: string; + defaultModel?: string; + modelProfile?: ProfileName; + userModel?: string; + availableModels?: Set; +}): { model?: string; variant?: string } { + const { + categoryName, + defaultModel, + modelProfile, + userModel, + availableModels, + } = input; + + if (userModel || !modelProfile) { + return {}; + } + + const profileOverride = getProfileOverride(modelProfile, categoryName); + if (!profileOverride) { + return {}; + } + + const matchedModel = availableModels + ? (fuzzyMatchModel(profileOverride.model, availableModels) ?? undefined) + : undefined; + if (matchedModel) { + return { model: matchedModel, variant: profileOverride.variant }; + } + + if (availableModels && availableModels.size > 0) { + return {}; + } + + const defaultProvider = defaultModel?.split("/")[0]; + if (!defaultProvider) { + return {}; + } + + return { + model: `${defaultProvider}/${profileOverride.model}`, + variant: profileOverride.variant, + }; } /** @@ -23,52 +76,72 @@ export interface ResolveCategoryConfigResult { * Merges default and user configurations, handles model resolution. */ export function resolveCategoryConfig( - categoryName: string, - options: ResolveCategoryConfigOptions + categoryName: string, + options: ResolveCategoryConfigOptions, ): ResolveCategoryConfigResult | null { - const { userCategories, inheritedModel: _inheritedModel, systemDefaultModel, availableModels } = options - - const defaultConfig = DEFAULT_CATEGORIES[categoryName] - const userConfig = userCategories?.[categoryName] - const hasExplicitUserConfig = userConfig !== undefined - - if (userConfig?.disable) { - return null - } - - const categoryReq = CATEGORY_MODEL_REQUIREMENTS[categoryName] - if (categoryReq?.requiresModel && availableModels && !hasExplicitUserConfig) { - if (!isModelAvailable(categoryReq.requiresModel, availableModels)) { - log(`[resolveCategoryConfig] Category ${categoryName} requires ${categoryReq.requiresModel} but not available`) - return null - } - } - const defaultPromptAppend = CATEGORY_PROMPT_APPENDS[categoryName] ?? "" - - if (!defaultConfig && !userConfig) { - return null - } - - // Model priority for categories: user override > category default > system default - // Categories have explicit models - no inheritance from parent session - const model = resolveModel({ - userModel: userConfig?.model, - inheritedModel: defaultConfig?.model, // Category's built-in model takes precedence over system default - systemDefault: systemDefaultModel, - }) - const config: CategoryConfig = { - ...defaultConfig, - ...userConfig, - model, - variant: userConfig?.variant ?? defaultConfig?.variant, - } - - let promptAppend = defaultPromptAppend - if (userConfig?.prompt_append) { - promptAppend = defaultPromptAppend - ? defaultPromptAppend + "\n\n" + userConfig.prompt_append - : userConfig.prompt_append - } - - return { config, promptAppend, model } + const { + userCategories, + modelProfile, + inheritedModel: _inheritedModel, + systemDefaultModel, + availableModels, + } = options; + + const defaultConfig = DEFAULT_CATEGORIES[categoryName]; + const userConfig = userCategories?.[categoryName]; + const hasExplicitUserConfig = userConfig !== undefined; + + if (userConfig?.disable) { + return null; + } + + const profileOverride = resolveProfileCategoryModel({ + categoryName, + defaultModel: defaultConfig?.model, + modelProfile, + userModel: userConfig?.model, + availableModels, + }); + + const categoryReq = CATEGORY_MODEL_REQUIREMENTS[categoryName]; + if (categoryReq?.requiresModel && availableModels && !hasExplicitUserConfig && !profileOverride.model) { + if (!isModelAvailable(categoryReq.requiresModel, availableModels)) { + log( + `[resolveCategoryConfig] Category ${categoryName} requires ${categoryReq.requiresModel} but not available`, + ); + return null; + } + } + const defaultPromptAppend = CATEGORY_PROMPT_APPENDS[categoryName] ?? ""; + + if (!defaultConfig && !userConfig) { + return null; + } + + const model = resolveModel({ + userModel: userConfig?.model ?? profileOverride.model, + inheritedModel: defaultConfig?.model, + systemDefault: systemDefaultModel, + }); + const config: CategoryConfig = { + ...defaultConfig, + ...userConfig, + model, + variant: + userConfig?.variant ?? profileOverride.variant ?? defaultConfig?.variant, + }; + + let promptAppend = defaultPromptAppend; + if (userConfig?.prompt_append) { + promptAppend = defaultPromptAppend + ? `${defaultPromptAppend}\n\n${userConfig.prompt_append}` + : userConfig.prompt_append; + } + + return { + config, + promptAppend, + model, + usedProfileOverride: Boolean(profileOverride.model), + }; } diff --git a/src/tools/delegate-task/category-profile.test.ts b/src/tools/delegate-task/category-profile.test.ts new file mode 100644 index 0000000000..32ec51f073 --- /dev/null +++ b/src/tools/delegate-task/category-profile.test.ts @@ -0,0 +1,118 @@ +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import * as availableModels from "./available-models"; +import { resolveCategoryExecution } from "./category-resolver"; +import type { ExecutorContext } from "./executor-types"; + +describe("category profile override injection", () => { + let availableModelsSpy: ReturnType | undefined; + + beforeEach(() => { + mock.restore(); + availableModelsSpy = spyOn( + availableModels, + "getAvailableModelsForDelegateTask", + ); + }); + + afterEach(() => { + availableModelsSpy?.mockRestore(); + }); + + function createExecutorContext( + overrides?: Partial, + ): ExecutorContext { + return { + client: {} as ExecutorContext["client"], + manager: {} as ExecutorContext["manager"], + directory: "/tmp/test", + userCategories: {}, + sisyphusJuniorModel: undefined, + ...overrides, + }; + } + + test("economy profile overrides ultrabrain category model", async () => { + const args = { + category: "ultrabrain", + prompt: "Solve the hard problem", + description: "Profile override test", + run_in_background: false, + load_skills: [], + }; + const executorCtx = { + ...createExecutorContext(), + modelProfile: "economy", + } as ExecutorContext; + + availableModelsSpy?.mockResolvedValue( + new Set(["openai/gpt-5.4", "openai/gpt-5.3-codex"]), + ); + + const result = await resolveCategoryExecution( + args, + executorCtx, + undefined, + "anthropic/claude-sonnet-4-6", + ); + + expect(result.error).toBeUndefined(); + expect(result.actualModel).toBe("openai/gpt-5.4"); + expect(result.categoryModel).toEqual({ + providerID: "openai", + modelID: "gpt-5.4", + variant: "medium", + }); + }); + + test("manual category config beats economy profile override", async () => { + const args = { + category: "ultrabrain", + prompt: "Solve the hard problem", + description: "Manual override test", + run_in_background: false, + load_skills: [], + }; + const executorCtx = { + ...createExecutorContext({ + userCategories: { + ultrabrain: { + model: "anthropic/claude-opus-4-6", + variant: "max", + }, + }, + }), + modelProfile: "economy", + } as ExecutorContext; + + availableModelsSpy?.mockResolvedValue( + new Set([ + "openai/gpt-5.4", + "anthropic/claude-opus-4-6", + "openai/gpt-5.3-codex", + ]), + ); + + const result = await resolveCategoryExecution( + args, + executorCtx, + undefined, + "anthropic/claude-sonnet-4-6", + ); + + expect(result.error).toBeUndefined(); + expect(result.actualModel).toBe("anthropic/claude-opus-4-6"); + expect(result.categoryModel).toEqual({ + providerID: "anthropic", + modelID: "claude-opus-4-6", + variant: "max", + }); + }); +}); diff --git a/src/tools/delegate-task/category-resolver.test.ts b/src/tools/delegate-task/category-resolver.test.ts index a2397c4d2e..5a5ff45fd1 100644 --- a/src/tools/delegate-task/category-resolver.test.ts +++ b/src/tools/delegate-task/category-resolver.test.ts @@ -75,4 +75,70 @@ describe("resolveCategoryExecution", () => { expect(result.error).toContain("Unknown category") expect(result.error).toContain("definitely-not-a-real-category-xyz123") }) + + test("detects gemini models as unstable via registry", async () => { + //#given + const args = { + category: "visual-engineering", + prompt: "test prompt", + description: "Test task", + run_in_background: false, + load_skills: [], + blockedBy: undefined, + enableSkillTools: false, + } + const executorCtx = createMockExecutorContext() + const inheritedModel = "gemini-3.1-pro" + const systemDefaultModel = "anthropic/claude-sonnet-4-6" + + //#when + const result = await resolveCategoryExecution(args, executorCtx, inheritedModel, systemDefaultModel) + + //#then + expect(result.isUnstableAgent).toBe(true) + }) + + test("detects kimi models as unstable via registry", async () => { + //#given + const args = { + category: "writing", + prompt: "test prompt", + description: "Test task", + run_in_background: false, + load_skills: [], + blockedBy: undefined, + enableSkillTools: false, + } + const executorCtx = createMockExecutorContext() + const inheritedModel = "kimi-k2.5" + const systemDefaultModel = "anthropic/claude-sonnet-4-6" + + //#when + const result = await resolveCategoryExecution(args, executorCtx, inheritedModel, systemDefaultModel) + + //#then + expect(result.isUnstableAgent).toBe(true) + }) + + test("does not mark claude models as unstable", async () => { + //#given + const args = { + category: "quick", + prompt: "test prompt", + description: "Test task", + run_in_background: false, + load_skills: [], + blockedBy: undefined, + enableSkillTools: false, + } + const executorCtx = createMockExecutorContext() + const inheritedModel = "claude-opus-4-6" + const systemDefaultModel = "anthropic/claude-sonnet-4-6" + + //#when + const result = await resolveCategoryExecution(args, executorCtx, inheritedModel, systemDefaultModel) + + //#then + expect(result.isUnstableAgent).toBe(false) + }) }) diff --git a/src/tools/delegate-task/category-resolver.ts b/src/tools/delegate-task/category-resolver.ts index bc516dce77..063023cd87 100644 --- a/src/tools/delegate-task/category-resolver.ts +++ b/src/tools/delegate-task/category-resolver.ts @@ -1,191 +1,229 @@ -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 { parseModelString } from "./model-string-parser" -import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements" -import { getAvailableModelsForDelegateTask } from "./available-models" -import { resolveModelForDelegateTask } from "./model-selection" +import type { ModelFallbackInfo } from "../../features/task-toast-manager/types"; +import { mergeCategories } from "../../shared/merge-categories"; +import { isModelUnstable } from "../../shared/model-registry"; +import type { FallbackEntry } from "../../shared/model-requirements"; +import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"; +import { getAvailableModelsForDelegateTask } from "./available-models"; +import { resolveCategoryConfig } from "./categories"; +import type { ExecutorContext } from "./executor-types"; +import { resolveModelForDelegateTask } from "./model-selection"; +import { parseModelString } from "./model-string-parser"; +import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"; +import type { DelegateTaskArgs } from "./types"; export interface CategoryResolutionResult { - agentToUse: string - categoryModel: { providerID: string; modelID: string; variant?: string } | undefined - categoryPromptAppend: string | undefined - maxPromptTokens?: number - modelInfo: ModelFallbackInfo | undefined - actualModel: string | undefined - isUnstableAgent: boolean - fallbackChain?: FallbackEntry[] // For runtime retry on model errors - error?: string + agentToUse: string; + categoryModel: + | { providerID: string; modelID: string; variant?: string } + | 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 + args: DelegateTaskArgs, + executorCtx: ExecutorContext, + inheritedModel: string | undefined, + systemDefaultModel: string | undefined, ): Promise { - const { client, userCategories, sisyphusJuniorModel } = executorCtx - - const availableModels = await getAvailableModelsForDelegateTask(client) - - const categoryName = args.category! - const enabledCategories = mergeCategories(userCategories) - const categoryExists = enabledCategories[categoryName] !== undefined - - 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. + const { client, userCategories, sisyphusJuniorModel, modelProfile } = + executorCtx; + + const availableModels = await getAvailableModelsForDelegateTask(client); + + const categoryName = args.category; + if (!categoryName) { + return { + agentToUse: "", + categoryModel: undefined, + categoryPromptAppend: undefined, + maxPromptTokens: undefined, + modelInfo: undefined, + actualModel: undefined, + isUnstableAgent: false, + error: "Category is required for category resolution.", + }; + } + const enabledCategories = mergeCategories(userCategories); + const categoryExists = enabledCategories[categoryName] !== undefined; + + const resolved = resolveCategoryConfig(categoryName, { + userCategories, + modelProfile, + 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 oh-my-opencode.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!] - let actualModel: string | undefined - let modelInfo: ModelFallbackInfo | undefined - let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined - - 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" } - } - } else { - const resolution = resolveModelForDelegateTask({ - userModel: explicitCategoryModel ?? overrideModel, - categoryDefaultModel: resolved.model, - fallbackChain: requirement.fallbackChain, - availableModels, - systemDefaultModel, - }) - - if (resolution) { - const { model: resolvedModel, variant: resolvedVariant } = resolution - 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 - ? (variantToUse ? { ...parsedModel, variant: variantToUse } : parsedModel) - : undefined - } - } - - if (!categoryModel && actualModel) { - const parsedModel = parseModelString(actualModel) - categoryModel = parsedModel ?? undefined - } - const categoryPromptAppend = resolved.promptAppend || undefined - - if (!categoryModel && !actualModel) { - 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}". + }; + } + + 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[categoryName]; + let actualModel: string | undefined; + let modelInfo: ModelFallbackInfo | undefined; + let categoryModel: + | { providerID: string; modelID: string; variant?: string } + | undefined; + + const overrideModel = sisyphusJuniorModel; + const explicitCategoryModel = userCategories?.[categoryName]?.model; + const hasPriorityOverride = Boolean( + explicitCategoryModel || overrideModel || resolved.usedProfileOverride, + ); + + if (!requirement) { + actualModel = explicitCategoryModel ?? overrideModel ?? resolved.model; + if (actualModel) { + modelInfo = hasPriorityOverride + ? { model: actualModel, type: "user-defined", source: "override" } + : { + model: actualModel, + type: "system-default", + source: "system-default", + }; + } + } else { + const resolution = resolveModelForDelegateTask({ + userModel: explicitCategoryModel ?? overrideModel, + categoryDefaultModel: resolved.model, + fallbackChain: requirement.fallbackChain, + availableModels, + systemDefaultModel, + }); + + if (resolution) { + const { model: resolvedModel, variant: resolvedVariant } = resolution; + 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" = hasPriorityOverride + ? "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?.[categoryName]?.variant ?? + resolvedVariant ?? + resolved.config.variant; + categoryModel = parsedModel + ? variantToUse + ? { ...parsedModel, variant: variantToUse } + : parsedModel + : undefined; + } + } + + if (!categoryModel && actualModel) { + const parsedModel = parseModelString(actualModel); + categoryModel = parsedModel ?? undefined; + } + const categoryPromptAppend = resolved.promptAppend || undefined; + + if (!categoryModel && !actualModel) { + 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 "${categoryName}". Configure in one of: 1. OpenCode: Set "model" in opencode.json 2. Oh-My-OpenCode: Set category model in oh-my-opencode.json 3. Provider: Connect a provider with available models -Current category: ${args.category} +Current category: ${categoryName} Available categories: ${categoryNames.join(", ")}`, - } - } - - const unstableModel = actualModel?.toLowerCase() - const categoryConfigModel = resolved.config.model?.toLowerCase() - const isUnstableAgent = resolved.config.is_unstable_agent === true || [unstableModel, categoryConfigModel].some(m => m ? m.includes("gemini") || m.includes("minimax") || m.includes("kimi") : false) - - return { - agentToUse: SISYPHUS_JUNIOR_AGENT, - categoryModel, - categoryPromptAppend, - maxPromptTokens: resolved.config.max_prompt_tokens, - modelInfo, - actualModel, - isUnstableAgent, - fallbackChain: requirement?.fallbackChain, - } + }; + } + + const actualModelID = actualModel + ? parseModelString(actualModel)?.modelID + : undefined; + const configModelID = resolved.config.model + ? parseModelString(resolved.config.model)?.modelID + : undefined; + const isUnstableAgent = + resolved.config.is_unstable_agent === true || + isModelUnstable(actualModelID) || + isModelUnstable(configModelID); + + return { + agentToUse: SISYPHUS_JUNIOR_AGENT, + categoryModel, + categoryPromptAppend, + maxPromptTokens: resolved.config.max_prompt_tokens, + modelInfo, + actualModel, + isUnstableAgent, + fallbackChain: requirement?.fallbackChain, + }; } diff --git a/src/tools/delegate-task/executor-types.ts b/src/tools/delegate-task/executor-types.ts index ad8c018793..2568bea5cf 100644 --- a/src/tools/delegate-task/executor-types.ts +++ b/src/tools/delegate-task/executor-types.ts @@ -1,38 +1,49 @@ -import type { BackgroundManager } from "../../features/background-agent" -import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider, AgentOverrides } from "../../config/schema" -import type { OpencodeClient } from "./types" +import type { + AgentOverrides, + BrowserAutomationProvider, + CategoriesConfig, + GitMasterConfig, +} from "../../config/schema"; +import type { BackgroundManager } from "../../features/background-agent"; +import type { ProfileName } from "../../shared/model-registry"; +import type { OpencodeClient } from "./types"; export interface ExecutorContext { - manager: BackgroundManager - client: OpencodeClient - directory: string - userCategories?: CategoriesConfig - gitMasterConfig?: GitMasterConfig - sisyphusJuniorModel?: string - browserProvider?: BrowserAutomationProvider - agentOverrides?: AgentOverrides - onSyncSessionCreated?: (event: { sessionID: string; parentID: string; title: string }) => Promise - syncPollTimeoutMs?: number + manager: BackgroundManager; + client: OpencodeClient; + directory: string; + userCategories?: CategoriesConfig; + gitMasterConfig?: GitMasterConfig; + sisyphusJuniorModel?: string; + modelProfile?: ProfileName; + browserProvider?: BrowserAutomationProvider; + agentOverrides?: AgentOverrides; + onSyncSessionCreated?: (event: { + sessionID: string; + parentID: string; + title: string; + }) => Promise; + syncPollTimeoutMs?: number; } export interface ParentContext { - sessionID: string - messageID: string - agent?: string - model?: { providerID: string; modelID: string; variant?: string } + sessionID: string; + messageID: string; + agent?: string; + model?: { providerID: string; modelID: string; variant?: string }; } export interface SessionMessage { - info?: { - id?: string - role?: string - time?: { created?: number } - finish?: string - agent?: string - model?: { providerID: string; modelID: string; variant?: string } - modelID?: string - providerID?: string - variant?: string - } - parts?: Array<{ type?: string; text?: string }> + info?: { + id?: string; + role?: string; + time?: { created?: number }; + finish?: string; + agent?: string; + model?: { providerID: string; modelID: string; variant?: string }; + modelID?: string; + providerID?: string; + variant?: string; + }; + parts?: Array<{ type?: string; text?: string }>; } diff --git a/src/tools/delegate-task/types.ts b/src/tools/delegate-task/types.ts index c51a1bde14..c413960f42 100644 --- a/src/tools/delegate-task/types.ts +++ b/src/tools/delegate-task/types.ts @@ -1,85 +1,95 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import type { BackgroundManager } from "../../features/background-agent" -import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider, AgentOverrides } from "../../config/schema" +import type { PluginInput } from "@opencode-ai/plugin"; import type { - AvailableCategory, - AvailableSkill, -} from "../../agents/dynamic-agent-prompt-builder" + AvailableCategory, + AvailableSkill, +} from "../../agents/dynamic-agent-prompt-builder"; +import type { + AgentOverrides, + BrowserAutomationProvider, + CategoriesConfig, + GitMasterConfig, +} from "../../config/schema"; +import type { BackgroundManager } from "../../features/background-agent"; +import type { ProfileName } from "../../shared/model-registry"; -export type OpencodeClient = PluginInput["client"] +export type OpencodeClient = PluginInput["client"]; export interface DelegateTaskArgs { - description: string - prompt: string - category?: string - subagent_type?: string - run_in_background: boolean - session_id?: string - command?: string - load_skills: string[] - execute?: { - task_id: string - task_dir?: string - } + description: string; + prompt: string; + category?: string; + subagent_type?: string; + run_in_background: boolean; + session_id?: string; + command?: string; + load_skills: string[]; + execute?: { + task_id: string; + task_dir?: string; + }; } export interface ToolContextWithMetadata { - sessionID: string - messageID: string - agent: string - abort: AbortSignal - metadata?: (input: { title?: string; metadata?: Record }) => void | Promise - /** - * Tool call ID injected by OpenCode's internal context (not in plugin ToolContext type, - * but present at runtime via spread in fromPlugin()). Used for metadata store keying. - */ - callID?: string - /** @deprecated OpenCode internal naming may vary across versions */ - callId?: string - /** @deprecated OpenCode internal naming may vary across versions */ - call_id?: string + sessionID: string; + messageID: string; + agent: string; + abort: AbortSignal; + metadata?: (input: { + title?: string; + metadata?: Record; + }) => void | Promise; + /** + * Tool call ID injected by OpenCode's internal context (not in plugin ToolContext type, + * but present at runtime via spread in fromPlugin()). Used for metadata store keying. + */ + callID?: string; + /** @deprecated OpenCode internal naming may vary across versions */ + callId?: string; + /** @deprecated OpenCode internal naming may vary across versions */ + call_id?: string; } export interface SyncSessionCreatedEvent { - sessionID: string - parentID: string - title: string + sessionID: string; + parentID: string; + title: string; } export interface DelegateTaskToolOptions { - manager: BackgroundManager - client: OpencodeClient - directory: string - /** - * Test hook: bypass global cache reads (Bun runs tests in parallel). - * If provided, resolveCategoryExecution/resolveSubagentExecution uses this instead of reading from disk cache. - */ - connectedProvidersOverride?: string[] | null - /** - * Test hook: bypass fetchAvailableModels() by providing an explicit available model set. - */ - availableModelsOverride?: Set - userCategories?: CategoriesConfig - gitMasterConfig?: GitMasterConfig - sisyphusJuniorModel?: string - browserProvider?: BrowserAutomationProvider - disabledSkills?: Set - availableCategories?: AvailableCategory[] - availableSkills?: AvailableSkill[] - agentOverrides?: AgentOverrides - onSyncSessionCreated?: (event: SyncSessionCreatedEvent) => Promise - syncPollTimeoutMs?: number + manager: BackgroundManager; + client: OpencodeClient; + directory: string; + /** + * Test hook: bypass global cache reads (Bun runs tests in parallel). + * If provided, resolveCategoryExecution/resolveSubagentExecution uses this instead of reading from disk cache. + */ + connectedProvidersOverride?: string[] | null; + /** + * Test hook: bypass fetchAvailableModels() by providing an explicit available model set. + */ + availableModelsOverride?: Set; + userCategories?: CategoriesConfig; + gitMasterConfig?: GitMasterConfig; + sisyphusJuniorModel?: string; + modelProfile?: ProfileName; + browserProvider?: BrowserAutomationProvider; + disabledSkills?: Set; + availableCategories?: AvailableCategory[]; + availableSkills?: AvailableSkill[]; + agentOverrides?: AgentOverrides; + onSyncSessionCreated?: (event: SyncSessionCreatedEvent) => Promise; + syncPollTimeoutMs?: number; } export interface BuildSystemContentInput { - skillContent?: string - skillContents?: string[] - categoryPromptAppend?: string - agentsContext?: string - planAgentPrepend?: string - maxPromptTokens?: number - model?: { providerID: string; modelID: string; variant?: string } - agentName?: string - availableCategories?: AvailableCategory[] - availableSkills?: AvailableSkill[] + skillContent?: string; + skillContents?: string[]; + categoryPromptAppend?: string; + agentsContext?: string; + planAgentPrepend?: string; + maxPromptTokens?: number; + model?: { providerID: string; modelID: string; variant?: string }; + agentName?: string; + availableCategories?: AvailableCategory[]; + availableSkills?: AvailableSkill[]; }