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 { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS } from "./constants"
import { resolveModel } from "../../shared"
import { resolveModel } from "../../shared/model-resolver"
import { isModelAvailable } from "../../shared/model-availability"
import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { log } from "../../shared"
import { log } from "../../shared/logger"
export interface ResolveCategoryConfigOptions {
userCategories?: CategoriesConfig

View File

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

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 { findNearestMessageWithFields, findFirstMessageWithAgent } from "../../features/hook-message-injector"
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 {
const messageDir = getMessageDir(ctx.sessionID)

View File

@@ -3,10 +3,9 @@ import type { ExecutorContext } from "./executor-types"
import { isPlanFamily } from "./constants"
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
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 { getAvailableModelsForDelegateTask } from "./available-models"
import { resolveModelForDelegateTask } from "./model-selection"
export async function resolveSubagentExecution(
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)
const agentRequirement = AGENT_MODEL_REQUIREMENTS[agentNameLower]
if (agentOverride?.model || agentRequirement) {
const connectedProviders = readConnectedProvidersCache()
const availableModels = await fetchAvailableModels(client, {
connectedProviders: connectedProviders ?? undefined,
})
if (agentOverride?.model || agentRequirement || matchedAgent.model) {
const availableModels = await getAvailableModelsForDelegateTask(client)
const matchedAgentModelStr = matchedAgent.model
? `${matchedAgent.model.providerID}/${matchedAgent.model.modelID}`
: undefined
const resolution = resolveModelPipeline({
intent: {
userModel: agentOverride?.model,
categoryDefaultModel: matchedAgentModelStr,
},
constraints: { availableModels },
policy: {
fallbackChain: agentRequirement?.fallbackChain,
systemDefaultModel: undefined,
},
const resolution = resolveModelForDelegateTask({
userModel: agentOverride?.model,
categoryDefaultModel: matchedAgentModelStr,
fallbackChain: agentRequirement?.fallbackChain,
availableModels,
systemDefaultModel: undefined,
})
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
}
}
} else if (matchedAgent.model) {
}
if (!categoryModel && matchedAgent.model) {
categoryModel = matchedAgent.model
}
} catch {

View File

@@ -3,7 +3,9 @@ import type { ExecutorContext, SessionMessage } from "./executor-types"
import { isPlanFamily } from "./constants"
import { storeToolMetadata } from "../../features/tool-metadata-store"
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 { formatDuration } from "./time-formatter"

View File

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

View File

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

View File

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

View File

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