refactor(agents): make agent creation async with model resolution

Convert createBuiltinAgents to async function that fetches available models at plugin init time. Add case-insensitive matching helpers and integrate discovered skills from both builtin and user sources.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
justsisyphus
2026-01-22 22:44:43 +09:00
parent 76211a3185
commit de3a6aae11
2 changed files with 117 additions and 31 deletions

View File

@@ -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")

View File

@@ -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<T>(obj: Record<string, T> | 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<string, AgentConfig> {
gitMasterConfig?: GitMasterConfig,
discoveredSkills: LoadedSkill[] = [],
client?: any
): Promise<Record<string, AgentConfig>> {
if (!systemDefaultModel) {
throw new Error("createBuiltinAgents requires systemDefaultModel")
}
// Fetch available models at plugin init
const availableModels = client ? await fetchAvailableModels(client) : new Set<string>()
const result: Record<string, AgentConfig> = {}
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)