diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts index 486bf5f1c..32c0244a9 100644 --- a/src/agents/utils.test.ts +++ b/src/agents/utils.test.ts @@ -5,11 +5,11 @@ import type { AgentConfig } from "@opencode-ai/sdk" const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-5" describe("createBuiltinAgents with model overrides", () => { - test("Sisyphus with default model has thinking config", () => { + test("Sisyphus with default model has thinking config", async () => { // #given - no overrides, using systemDefaultModel // #when - const agents = createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL) + const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL) // #then expect(agents.Sisyphus.model).toBe("anthropic/claude-opus-4-5") @@ -17,14 +17,14 @@ describe("createBuiltinAgents with model overrides", () => { expect(agents.Sisyphus.reasoningEffort).toBeUndefined() }) - test("Sisyphus with GPT model override has reasoningEffort, no thinking", () => { + test("Sisyphus with GPT model override has reasoningEffort, no thinking", async () => { // #given const overrides = { Sisyphus: { model: "github-copilot/gpt-5.2" }, } // #when - const agents = createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL) + const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL) // #then expect(agents.Sisyphus.model).toBe("github-copilot/gpt-5.2") @@ -32,12 +32,12 @@ describe("createBuiltinAgents with model overrides", () => { expect(agents.Sisyphus.thinking).toBeUndefined() }) - test("Sisyphus with systemDefaultModel GPT has reasoningEffort, no thinking", () => { + test("Sisyphus with systemDefaultModel GPT has reasoningEffort, no thinking", async () => { // #given const systemDefaultModel = "openai/gpt-5.2" // #when - const agents = createBuiltinAgents([], {}, undefined, systemDefaultModel) + const agents = await createBuiltinAgents([], {}, undefined, systemDefaultModel) // #then expect(agents.Sisyphus.model).toBe("openai/gpt-5.2") @@ -45,12 +45,12 @@ describe("createBuiltinAgents with model overrides", () => { expect(agents.Sisyphus.thinking).toBeUndefined() }) - test("Oracle with default model has reasoningEffort", () => { + test("Oracle with default model has reasoningEffort", async () => { // #given - no overrides, using systemDefaultModel for other agents // Oracle uses its own default model (openai/gpt-5.2) from the factory singleton // #when - const agents = createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL) + const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL) // #then - Oracle uses systemDefaultModel since model is now required expect(agents.oracle.model).toBe("anthropic/claude-opus-4-5") @@ -58,14 +58,14 @@ describe("createBuiltinAgents with model overrides", () => { expect(agents.oracle.reasoningEffort).toBeUndefined() }) - test("Oracle with GPT model override has reasoningEffort, no thinking", () => { + test("Oracle with GPT model override has reasoningEffort, no thinking", async () => { // #given const overrides = { oracle: { model: "openai/gpt-5.2" }, } // #when - const agents = createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL) + const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL) // #then expect(agents.oracle.model).toBe("openai/gpt-5.2") @@ -74,14 +74,14 @@ describe("createBuiltinAgents with model overrides", () => { expect(agents.oracle.thinking).toBeUndefined() }) - test("Oracle with Claude model override has thinking, no reasoningEffort", () => { + test("Oracle with Claude model override has thinking, no reasoningEffort", async () => { // #given const overrides = { oracle: { model: "anthropic/claude-sonnet-4" }, } // #when - const agents = createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL) + const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL) // #then expect(agents.oracle.model).toBe("anthropic/claude-sonnet-4") @@ -90,14 +90,14 @@ describe("createBuiltinAgents with model overrides", () => { expect(agents.oracle.textVerbosity).toBeUndefined() }) - test("non-model overrides are still applied after factory rebuild", () => { + test("non-model overrides are still applied after factory rebuild", async () => { // #given const overrides = { Sisyphus: { model: "github-copilot/gpt-5.2", temperature: 0.5 }, } // #when - const agents = createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL) + const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL) // #then expect(agents.Sisyphus.model).toBe("github-copilot/gpt-5.2") diff --git a/src/agents/utils.ts b/src/agents/utils.ts index 6bc3c5d86..333dbf430 100644 --- a/src/agents/utils.ts +++ b/src/agents/utils.ts @@ -10,10 +10,11 @@ import { createMetisAgent } from "./metis" import { createAtlasAgent } from "./atlas" import { createMomusAgent } from "./momus" import type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder" -import { deepMerge } from "../shared" +import { deepMerge, fetchAvailableModels, resolveModelWithFallback, AGENT_MODEL_REQUIREMENTS } from "../shared" import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants" import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content" import { createBuiltinSkills } from "../features/builtin-skills" +import type { LoadedSkill, SkillScope } from "../features/opencode-skill-loader/types" type AgentSource = AgentFactory | AgentConfig @@ -45,6 +46,22 @@ function isFactory(source: AgentSource): source is AgentFactory { return typeof source === "function" } +function findCaseInsensitive(obj: Record | undefined, key: string): T | undefined { + if (!obj) return undefined + const exactMatch = obj[key] + if (exactMatch !== undefined) return exactMatch + const lowerKey = key.toLowerCase() + for (const [k, v] of Object.entries(obj)) { + if (k.toLowerCase() === lowerKey) return v + } + return undefined +} + +function includesCaseInsensitive(arr: string[], value: string): boolean { + const lowerValue = value.toLowerCase() + return arr.some((item) => item.toLowerCase() === lowerValue) +} + export function buildAgent( source: AgentSource, model: string, @@ -131,18 +148,29 @@ function mergeAgentConfig( return merged } -export function createBuiltinAgents( +function mapScopeToLocation(scope: SkillScope): AvailableSkill["location"] { + if (scope === "user" || scope === "opencode") return "user" + if (scope === "project" || scope === "opencode-project") return "project" + return "plugin" +} + +export async function createBuiltinAgents( disabledAgents: BuiltinAgentName[] = [], agentOverrides: AgentOverrides = {}, directory?: string, systemDefaultModel?: string, categories?: CategoriesConfig, - gitMasterConfig?: GitMasterConfig -): Record { + gitMasterConfig?: GitMasterConfig, + discoveredSkills: LoadedSkill[] = [], + client?: any +): Promise> { if (!systemDefaultModel) { throw new Error("createBuiltinAgents requires systemDefaultModel") } + // Fetch available models at plugin init + const availableModels = client ? await fetchAvailableModels(client) : new Set() + const result: Record = {} const availableAgents: AvailableAgent[] = [] @@ -152,27 +180,54 @@ export function createBuiltinAgents( const availableCategories: AvailableCategory[] = Object.entries(mergedCategories).map(([name]) => ({ name, - description: CATEGORY_DESCRIPTIONS[name] ?? "General tasks", + description: categories?.[name]?.description ?? CATEGORY_DESCRIPTIONS[name] ?? "General tasks", })) const builtinSkills = createBuiltinSkills() - const availableSkills: AvailableSkill[] = builtinSkills.map((skill) => ({ + const builtinSkillNames = new Set(builtinSkills.map(s => s.name)) + + const builtinAvailable: AvailableSkill[] = builtinSkills.map((skill) => ({ name: skill.name, description: skill.description, location: "plugin" as const, })) + const discoveredAvailable: AvailableSkill[] = discoveredSkills + .filter(s => !builtinSkillNames.has(s.name)) + .map((skill) => ({ + name: skill.name, + description: skill.definition.description ?? "", + location: mapScopeToLocation(skill.scope), + })) + + const availableSkills: AvailableSkill[] = [...builtinAvailable, ...discoveredAvailable] + for (const [name, source] of Object.entries(agentSources)) { const agentName = name as BuiltinAgentName if (agentName === "Sisyphus") continue if (agentName === "Atlas") continue - if (disabledAgents.includes(agentName)) continue + if (includesCaseInsensitive(disabledAgents, agentName)) continue - const override = agentOverrides[agentName] - const model = override?.model ?? systemDefaultModel + const override = findCaseInsensitive(agentOverrides, agentName) + const requirement = AGENT_MODEL_REQUIREMENTS[agentName] + + // Use resolver to determine model + const { model } = resolveModelWithFallback({ + userModel: override?.model, + fallbackChain: requirement?.fallbackChain, + availableModels, + systemDefaultModel, + }) let config = buildAgent(source, model, mergedCategories, gitMasterConfig) + + // Apply variant from override or requirement + if (override?.variant) { + config = { ...config, variant: override.variant } + } else if (requirement?.variant) { + config = { ...config, variant: requirement.variant } + } if (agentName === "librarian" && directory && config.prompt) { const envContext = createEnvContext() @@ -197,7 +252,15 @@ export function createBuiltinAgents( if (!disabledAgents.includes("Sisyphus")) { const sisyphusOverride = agentOverrides["Sisyphus"] - const sisyphusModel = sisyphusOverride?.model ?? systemDefaultModel + const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["Sisyphus"] + + // Use resolver to determine model + const { model: sisyphusModel } = resolveModelWithFallback({ + userModel: sisyphusOverride?.model, + fallbackChain: sisyphusRequirement?.fallbackChain, + availableModels, + systemDefaultModel, + }) let sisyphusConfig = createSisyphusAgent( sisyphusModel, @@ -206,6 +269,13 @@ export function createBuiltinAgents( availableSkills, availableCategories ) + + // Apply variant from override or requirement + if (sisyphusOverride?.variant) { + sisyphusConfig = { ...sisyphusConfig, variant: sisyphusOverride.variant } + } else if (sisyphusRequirement?.variant) { + sisyphusConfig = { ...sisyphusConfig, variant: sisyphusRequirement.variant } + } if (directory && sisyphusConfig.prompt) { const envContext = createEnvContext() @@ -221,13 +291,29 @@ export function createBuiltinAgents( if (!disabledAgents.includes("Atlas")) { const orchestratorOverride = agentOverrides["Atlas"] - const orchestratorModel = orchestratorOverride?.model ?? systemDefaultModel - let orchestratorConfig = createAtlasAgent({ - model: orchestratorModel, - availableAgents, - availableSkills, - userCategories: categories, - }) + const atlasRequirement = AGENT_MODEL_REQUIREMENTS["Atlas"] + + // Use resolver to determine model + const { model: atlasModel } = resolveModelWithFallback({ + userModel: orchestratorOverride?.model, + fallbackChain: atlasRequirement?.fallbackChain, + availableModels, + systemDefaultModel, + }) + + let orchestratorConfig = createAtlasAgent({ + model: atlasModel, + availableAgents, + availableSkills, + userCategories: categories, + }) + + // Apply variant from override or requirement + if (orchestratorOverride?.variant) { + orchestratorConfig = { ...orchestratorConfig, variant: orchestratorOverride.variant } + } else if (atlasRequirement?.variant) { + orchestratorConfig = { ...orchestratorConfig, variant: atlasRequirement.variant } + } if (orchestratorOverride) { orchestratorConfig = mergeAgentConfig(orchestratorConfig, orchestratorOverride)