From c9be2e1696437354ba4e65773fb5331682b005d0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 18:03:15 +0900 Subject: [PATCH] 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 --- src/tools/delegate-task/available-models.ts | 64 ++++++++++++++++++ src/tools/delegate-task/categories.ts | 4 +- src/tools/delegate-task/category-resolver.ts | 55 +++++++-------- src/tools/delegate-task/model-selection.ts | 67 +++++++++++++++++++ .../delegate-task/parent-context-resolver.ts | 3 +- src/tools/delegate-task/subagent-resolver.ts | 32 ++++----- src/tools/delegate-task/sync-continuation.ts | 4 +- src/tools/delegate-task/sync-prompt-sender.ts | 2 +- .../delegate-task/sync-session-poller.ts | 2 +- src/tools/delegate-task/sync-task.ts | 2 +- src/tools/delegate-task/tools.ts | 2 +- 11 files changed, 178 insertions(+), 59 deletions(-) create mode 100644 src/tools/delegate-task/available-models.ts create mode 100644 src/tools/delegate-task/model-selection.ts diff --git a/src/tools/delegate-task/available-models.ts b/src/tools/delegate-task/available-models.ts new file mode 100644 index 00000000..711ac192 --- /dev/null +++ b/src/tools/delegate-task/available-models.ts @@ -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, + providerID: string, + models: Array | 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> { + const providerModelsCache = readProviderModelsCache() + + if (providerModelsCache?.models) { + const connected = new Set(providerModelsCache.connected) + + const out = new Set() + for (const [providerID, models] of Object.entries(providerModelsCache.models)) { + if (!connected.has(providerID)) continue + addFromProviderModels(out, providerID, models as Array | undefined) + } + return out + } + + const connectedProviders = readConnectedProvidersCache() + + if (!connectedProviders || connectedProviders.length === 0) { + return new Set() + } + + const modelList = (client as unknown as { model?: { list?: () => Promise } }) + ?.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() + 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() + } +} diff --git a/src/tools/delegate-task/categories.ts b/src/tools/delegate-task/categories.ts index 1ee544d2..a13c2185 100644 --- a/src/tools/delegate-task/categories.ts +++ b/src/tools/delegate-task/categories.ts @@ -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 diff --git a/src/tools/delegate-task/category-resolver.ts b/src/tools/delegate-task/category-resolver.ts index 3eba5c24..84df498a 100644 --- a/src/tools/delegate-task/category-resolver.ts +++ b/src/tools/delegate-task/category-resolver.ts @@ -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 { 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 } diff --git a/src/tools/delegate-task/model-selection.ts b/src/tools/delegate-task/model-selection.ts new file mode 100644 index 00000000..12987421 --- /dev/null +++ b/src/tools/delegate-task/model-selection.ts @@ -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 + 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 +} diff --git a/src/tools/delegate-task/parent-context-resolver.ts b/src/tools/delegate-task/parent-context-resolver.ts index 664cb8d9..cf231783 100644 --- a/src/tools/delegate-task/parent-context-resolver.ts +++ b/src/tools/delegate-task/parent-context-resolver.ts @@ -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) diff --git a/src/tools/delegate-task/subagent-resolver.ts b/src/tools/delegate-task/subagent-resolver.ts index 2ee6af35..0447416d 100644 --- a/src/tools/delegate-task/subagent-resolver.ts +++ b/src/tools/delegate-task/subagent-resolver.ts @@ -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 { diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts index 653df39e..8852a355 100644 --- a/src/tools/delegate-task/sync-continuation.ts +++ b/src/tools/delegate-task/sync-continuation.ts @@ -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" diff --git a/src/tools/delegate-task/sync-prompt-sender.ts b/src/tools/delegate-task/sync-prompt-sender.ts index d0829878..083e9df2 100644 --- a/src/tools/delegate-task/sync-prompt-sender.ts +++ b/src/tools/delegate-task/sync-prompt-sender.ts @@ -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( diff --git a/src/tools/delegate-task/sync-session-poller.ts b/src/tools/delegate-task/sync-session-poller.ts index 42832d7e..a9060e68 100644 --- a/src/tools/delegate-task/sync-session-poller.ts +++ b/src/tools/delegate-task/sync-session-poller.ts @@ -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, diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index 4d621d50..2838053d 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -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" diff --git a/src/tools/delegate-task/tools.ts b/src/tools/delegate-task/tools.ts index 1db72408..c668443b 100644 --- a/src/tools/delegate-task/tools.ts +++ b/src/tools/delegate-task/tools.ts @@ -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,