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:
64
src/tools/delegate-task/available-models.ts
Normal file
64
src/tools/delegate-task/available-models.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
67
src/tools/delegate-task/model-selection.ts
Normal file
67
src/tools/delegate-task/model-selection.ts
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user