refactor: extract model selection logic from delegate-task into focused modules

- Create available-models.ts for model availability checking
- Create model-selection.ts for category-to-model resolution logic
- Update category-resolver, subagent-resolver, and sync modules to import
  from new focused modules instead of monolithic sources
This commit is contained in:
YeonGyu-Kim 2026-02-08 18:03:15 +09:00
parent caf08af88b
commit c9be2e1696
11 changed files with 178 additions and 59 deletions

View File

@ -0,0 +1,64 @@
import type { OpencodeClient } from "./types"
import { log } from "../../shared/logger"
import { readConnectedProvidersCache, readProviderModelsCache } from "../../shared/connected-providers-cache"
function addFromProviderModels(
out: Set<string>,
providerID: string,
models: Array<string | { id?: string }> | undefined
): void {
if (!models) return
for (const item of models) {
const modelID = typeof item === "string" ? item : item?.id
if (!modelID) continue
out.add(`${providerID}/${modelID}`)
}
}
export async function getAvailableModelsForDelegateTask(client: OpencodeClient): Promise<Set<string>> {
const providerModelsCache = readProviderModelsCache()
if (providerModelsCache?.models) {
const connected = new Set(providerModelsCache.connected)
const out = new Set<string>()
for (const [providerID, models] of Object.entries(providerModelsCache.models)) {
if (!connected.has(providerID)) continue
addFromProviderModels(out, providerID, models as Array<string | { id?: string }> | undefined)
}
return out
}
const connectedProviders = readConnectedProvidersCache()
if (!connectedProviders || connectedProviders.length === 0) {
return new Set()
}
const modelList = (client as unknown as { model?: { list?: () => Promise<unknown> } })
?.model
?.list
if (!modelList) {
return new Set()
}
try {
const result = await modelList()
const rows = Array.isArray(result)
? result
: ((result as { data?: unknown }).data as Array<{ provider?: string; id?: string }> | undefined) ?? []
const connected = new Set(connectedProviders)
const out = new Set<string>()
for (const row of rows) {
if (!row?.provider || !row?.id) continue
if (!connected.has(row.provider)) continue
out.add(`${row.provider}/${row.id}`)
}
return out
} catch (err) {
log("[delegate-task] client.model.list failed", { error: String(err) })
return new Set()
}
}

View File

@ -1,9 +1,9 @@
import type { CategoryConfig, CategoriesConfig } from "../../config/schema" import type { CategoryConfig, CategoriesConfig } from "../../config/schema"
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS } from "./constants" import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS } from "./constants"
import { resolveModel } from "../../shared" import { resolveModel } from "../../shared/model-resolver"
import { isModelAvailable } from "../../shared/model-availability" import { isModelAvailable } from "../../shared/model-availability"
import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements" import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { log } from "../../shared" import { log } from "../../shared/logger"
export interface ResolveCategoryConfigOptions { export interface ResolveCategoryConfigOptions {
userCategories?: CategoriesConfig userCategories?: CategoriesConfig

View File

@ -5,10 +5,9 @@ import { DEFAULT_CATEGORIES } from "./constants"
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent" import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
import { resolveCategoryConfig } from "./categories" import { resolveCategoryConfig } from "./categories"
import { parseModelString } from "./model-string-parser" import { parseModelString } from "./model-string-parser"
import { fetchAvailableModels } from "../../shared/model-availability"
import { readConnectedProvidersCache } from "../../shared/connected-providers-cache"
import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements" import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { resolveModelPipeline } from "../../shared" import { getAvailableModelsForDelegateTask } from "./available-models"
import { resolveModelForDelegateTask } from "./model-selection"
export interface CategoryResolutionResult { export interface CategoryResolutionResult {
agentToUse: string agentToUse: string
@ -28,10 +27,7 @@ export async function resolveCategoryExecution(
): Promise<CategoryResolutionResult> { ): Promise<CategoryResolutionResult> {
const { client, userCategories, sisyphusJuniorModel } = executorCtx const { client, userCategories, sisyphusJuniorModel } = executorCtx
const connectedProviders = readConnectedProvidersCache() const availableModels = await getAvailableModelsForDelegateTask(client)
const availableModels = await fetchAvailableModels(client, {
connectedProviders: connectedProviders ?? undefined,
})
const resolved = resolveCategoryConfig(args.category!, { const resolved = resolveCategoryConfig(args.category!, {
userCategories, userCategories,
@ -71,20 +67,16 @@ export async function resolveCategoryExecution(
: { model: actualModel, type: "system-default", source: "system-default" } : { model: actualModel, type: "system-default", source: "system-default" }
} }
} else { } else {
const resolution = resolveModelPipeline({ const resolution = resolveModelForDelegateTask({
intent: { userModel: explicitCategoryModel ?? overrideModel,
userModel: explicitCategoryModel ?? overrideModel, categoryDefaultModel: resolved.model,
categoryDefaultModel: resolved.model, fallbackChain: requirement.fallbackChain,
}, availableModels,
constraints: { availableModels }, systemDefaultModel,
policy: {
fallbackChain: requirement.fallbackChain,
systemDefaultModel,
},
}) })
if (resolution) { if (resolution) {
const { model: resolvedModel, provenance, variant: resolvedVariant } = resolution const { model: resolvedModel, variant: resolvedVariant } = resolution
actualModel = resolvedModel actualModel = resolvedModel
if (!parseModelString(actualModel)) { if (!parseModelString(actualModel)) {
@ -99,20 +91,19 @@ export async function resolveCategoryExecution(
} }
} }
let type: "user-defined" | "inherited" | "category-default" | "system-default" const type: "user-defined" | "inherited" | "category-default" | "system-default" =
const source = provenance (explicitCategoryModel || overrideModel)
switch (provenance) { ? "user-defined"
case "override": : (systemDefaultModel && actualModel === systemDefaultModel)
type = "user-defined" ? "system-default"
break : "category-default"
case "category-default":
case "provider-fallback": const source: "override" | "category-default" | "system-default" =
type = "category-default" type === "user-defined"
break ? "override"
case "system-default": : type === "system-default"
type = "system-default" ? "system-default"
break : "category-default"
}
modelInfo = { model: actualModel, type, source } modelInfo = { model: actualModel, type, source }

View File

@ -0,0 +1,67 @@
import type { FallbackEntry } from "../../shared/model-requirements"
import { fuzzyMatchModel } from "../../shared/model-availability"
function normalizeModel(model?: string): string | undefined {
const trimmed = model?.trim()
return trimmed || undefined
}
export function resolveModelForDelegateTask(input: {
userModel?: string
categoryDefaultModel?: string
fallbackChain?: FallbackEntry[]
availableModels: Set<string>
systemDefaultModel?: string
}): { model: string; variant?: string } | undefined {
const userModel = normalizeModel(input.userModel)
if (userModel) {
return { model: userModel }
}
const categoryDefault = normalizeModel(input.categoryDefaultModel)
if (categoryDefault) {
if (input.availableModels.size === 0) {
return { model: categoryDefault }
}
const parts = categoryDefault.split("/")
const providerHint = parts.length >= 2 ? [parts[0]] : undefined
const match = fuzzyMatchModel(categoryDefault, input.availableModels, providerHint)
if (match) {
return { model: match }
}
}
const fallbackChain = input.fallbackChain
if (fallbackChain && fallbackChain.length > 0) {
if (input.availableModels.size === 0) {
const first = fallbackChain[0]
const provider = first?.providers?.[0]
if (provider) {
return { model: `${provider}/${first.model}`, variant: first.variant }
}
} else {
for (const entry of fallbackChain) {
for (const provider of entry.providers) {
const fullModel = `${provider}/${entry.model}`
const match = fuzzyMatchModel(fullModel, input.availableModels, [provider])
if (match) {
return { model: match, variant: entry.variant }
}
}
const crossProviderMatch = fuzzyMatchModel(entry.model, input.availableModels)
if (crossProviderMatch) {
return { model: crossProviderMatch, variant: entry.variant }
}
}
}
}
const systemDefaultModel = normalizeModel(input.systemDefaultModel)
if (systemDefaultModel) {
return { model: systemDefaultModel }
}
return undefined
}

View File

@ -2,7 +2,8 @@ import type { ToolContextWithMetadata } from "./types"
import type { ParentContext } from "./executor-types" import type { ParentContext } from "./executor-types"
import { findNearestMessageWithFields, findFirstMessageWithAgent } from "../../features/hook-message-injector" import { findNearestMessageWithFields, findFirstMessageWithAgent } from "../../features/hook-message-injector"
import { getSessionAgent } from "../../features/claude-code-session-state" import { getSessionAgent } from "../../features/claude-code-session-state"
import { log, getMessageDir } from "../../shared" import { log } from "../../shared/logger"
import { getMessageDir } from "../../shared/session-utils"
export function resolveParentContext(ctx: ToolContextWithMetadata): ParentContext { export function resolveParentContext(ctx: ToolContextWithMetadata): ParentContext {
const messageDir = getMessageDir(ctx.sessionID) const messageDir = getMessageDir(ctx.sessionID)

View File

@ -3,10 +3,9 @@ import type { ExecutorContext } from "./executor-types"
import { isPlanFamily } from "./constants" import { isPlanFamily } from "./constants"
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent" import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
import { parseModelString } from "./model-string-parser" import { parseModelString } from "./model-string-parser"
import { resolveModelPipeline } from "../../shared"
import { fetchAvailableModels } from "../../shared/model-availability"
import { readConnectedProvidersCache } from "../../shared/connected-providers-cache"
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements" import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { getAvailableModelsForDelegateTask } from "./available-models"
import { resolveModelForDelegateTask } from "./model-selection"
export async function resolveSubagentExecution( export async function resolveSubagentExecution(
args: DelegateTaskArgs, args: DelegateTaskArgs,
@ -86,26 +85,19 @@ Create the work plan directly - that's your job as the planning agent.`,
?? (agentOverrides ? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentNameLower)?.[1] : undefined) ?? (agentOverrides ? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentNameLower)?.[1] : undefined)
const agentRequirement = AGENT_MODEL_REQUIREMENTS[agentNameLower] const agentRequirement = AGENT_MODEL_REQUIREMENTS[agentNameLower]
if (agentOverride?.model || agentRequirement) { if (agentOverride?.model || agentRequirement || matchedAgent.model) {
const connectedProviders = readConnectedProvidersCache() const availableModels = await getAvailableModelsForDelegateTask(client)
const availableModels = await fetchAvailableModels(client, {
connectedProviders: connectedProviders ?? undefined,
})
const matchedAgentModelStr = matchedAgent.model const matchedAgentModelStr = matchedAgent.model
? `${matchedAgent.model.providerID}/${matchedAgent.model.modelID}` ? `${matchedAgent.model.providerID}/${matchedAgent.model.modelID}`
: undefined : undefined
const resolution = resolveModelPipeline({ const resolution = resolveModelForDelegateTask({
intent: { userModel: agentOverride?.model,
userModel: agentOverride?.model, categoryDefaultModel: matchedAgentModelStr,
categoryDefaultModel: matchedAgentModelStr, fallbackChain: agentRequirement?.fallbackChain,
}, availableModels,
constraints: { availableModels }, systemDefaultModel: undefined,
policy: {
fallbackChain: agentRequirement?.fallbackChain,
systemDefaultModel: undefined,
},
}) })
if (resolution) { if (resolution) {
@ -115,7 +107,9 @@ Create the work plan directly - that's your job as the planning agent.`,
categoryModel = variantToUse ? { ...parsed, variant: variantToUse } : parsed categoryModel = variantToUse ? { ...parsed, variant: variantToUse } : parsed
} }
} }
} else if (matchedAgent.model) { }
if (!categoryModel && matchedAgent.model) {
categoryModel = matchedAgent.model categoryModel = matchedAgent.model
} }
} catch { } catch {

View File

@ -3,7 +3,9 @@ import type { ExecutorContext, SessionMessage } from "./executor-types"
import { isPlanFamily } from "./constants" import { isPlanFamily } from "./constants"
import { storeToolMetadata } from "../../features/tool-metadata-store" import { storeToolMetadata } from "../../features/tool-metadata-store"
import { getTaskToastManager } from "../../features/task-toast-manager" import { getTaskToastManager } from "../../features/task-toast-manager"
import { getAgentToolRestrictions, getMessageDir, promptSyncWithModelSuggestionRetry } from "../../shared" import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions"
import { getMessageDir } from "../../shared/session-utils"
import { promptSyncWithModelSuggestionRetry } from "../../shared/model-suggestion-retry"
import { findNearestMessageWithFields } from "../../features/hook-message-injector" import { findNearestMessageWithFields } from "../../features/hook-message-injector"
import { formatDuration } from "./time-formatter" import { formatDuration } from "./time-formatter"

View File

@ -1,6 +1,6 @@
import type { DelegateTaskArgs, OpencodeClient } from "./types" import type { DelegateTaskArgs, OpencodeClient } from "./types"
import { isPlanFamily } from "./constants" import { isPlanFamily } from "./constants"
import { promptSyncWithModelSuggestionRetry } from "../../shared" import { promptSyncWithModelSuggestionRetry } from "../../shared/model-suggestion-retry"
import { formatDetailedError } from "./error-formatting" import { formatDetailedError } from "./error-formatting"
export async function sendSyncPrompt( export async function sendSyncPrompt(

View File

@ -1,6 +1,6 @@
import type { ToolContextWithMetadata, OpencodeClient } from "./types" import type { ToolContextWithMetadata, OpencodeClient } from "./types"
import { getTimingConfig } from "./timing" import { getTimingConfig } from "./timing"
import { log } from "../../shared" import { log } from "../../shared/logger"
export async function pollSyncSession( export async function pollSyncSession(
ctx: ToolContextWithMetadata, ctx: ToolContextWithMetadata,

View File

@ -4,7 +4,7 @@ import type { ExecutorContext, ParentContext } from "./executor-types"
import { getTaskToastManager } from "../../features/task-toast-manager" import { getTaskToastManager } from "../../features/task-toast-manager"
import { storeToolMetadata } from "../../features/tool-metadata-store" import { storeToolMetadata } from "../../features/tool-metadata-store"
import { subagentSessions } from "../../features/claude-code-session-state" import { subagentSessions } from "../../features/claude-code-session-state"
import { log } from "../../shared" import { log } from "../../shared/logger"
import { formatDuration } from "./time-formatter" import { formatDuration } from "./time-formatter"
import { formatDetailedError } from "./error-formatting" import { formatDetailedError } from "./error-formatting"
import { createSyncSession } from "./sync-session-creator" import { createSyncSession } from "./sync-session-creator"

View File

@ -1,7 +1,7 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin" import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import type { DelegateTaskArgs, ToolContextWithMetadata, DelegateTaskToolOptions } from "./types" import type { DelegateTaskArgs, ToolContextWithMetadata, DelegateTaskToolOptions } from "./types"
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "./constants" import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "./constants"
import { log } from "../../shared" import { log } from "../../shared/logger"
import { buildSystemContent } from "./prompt-builder" import { buildSystemContent } from "./prompt-builder"
import type { import type {
AvailableCategory, AvailableCategory,