import { z } from 'zod'; import { Error2, ErrorCodes, isError2 } from '#/errors'; import { isPlainObject } from '#/app/config/toml'; import { type EnvBindings, envBindings, stripEnvBoundFields, type IConfigService, } from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; import { THINKING_SECTION } from '#/app/kosongConfig/configSection'; import type { IModelCatalog, Model } from '#/llm-adapter/model/catalog'; import { declaredDefaultEffortForModel, modelSupportsThinking, modelSupportsThinkingEffort, normalizeRequestedThinkingEffort, type ThinkingConfig, } from '#/llm-adapter/model/thinking'; export const SUBAGENT_SECTION = 'subagent'; export const SECONDARY_MODEL_SECTION = 'secondaryModel'; export const SubagentConfigSchema = z.object({ timeoutMs: z.number().int().min(0).optional(), }); export type SubagentConfig = z.infer; export const SecondaryModelConfigSchema = z.object({ defaultModel: z.string().min(1).optional(), models: z.record(z.string(), z.string()).optional(), force: z.boolean().optional(), model: z.string().min(1).optional(), maxContextSize: z.number().int().min(1).optional(), maxInputSize: z.number().int().min(1).optional(), maxOutputSize: z.number().int().min(1).optional(), capabilities: z.array(z.string()).optional(), displayName: z.string().optional(), reasoningKey: z.string().optional(), adaptiveThinking: z.boolean().optional(), supportEfforts: z.array(z.string()).optional(), defaultEffort: z.string().optional(), offEffort: z.string().optional(), }); export type SecondaryModelConfig = z.infer; export const DEFAULT_SUBAGENT_TIMEOUT_MS = 2 * 60 * 60 * 1000; export const SUBAGENT_TIMEOUT_ENV = 'KIMI_SUBAGENT_TIMEOUT_MS'; function parseTimeoutMsEnv(raw: string): number | undefined { const parsed = Number(raw); return Number.isInteger(parsed) && parsed >= 1 ? parsed : undefined; } export const subagentEnvBindings: EnvBindings = envBindings( SubagentConfigSchema, { timeoutMs: { env: SUBAGENT_TIMEOUT_ENV, parse: parseTimeoutMsEnv }, }, ); export const stripSubagentEnv = stripEnvBoundFields(subagentEnvBindings); registerConfigSection(SUBAGENT_SECTION, SubagentConfigSchema, { defaultValue: { timeoutMs: DEFAULT_SUBAGENT_TIMEOUT_MS }, env: subagentEnvBindings, stripEnv: stripSubagentEnv, }); registerConfigSection(SECONDARY_MODEL_SECTION, SecondaryModelConfigSchema); export function resolveSubagentTimeoutMs(config: IConfigService): number { return ( config.get(SUBAGENT_SECTION)?.timeoutMs ?? DEFAULT_SUBAGENT_TIMEOUT_MS ); } export const PRIMARY_SUBAGENT_MODEL_CHOICE = 'primary'; export interface SubagentModelPool { readonly defaultModel?: string; readonly models: Record; } export function resolveSubagentModelPool(config: IConfigService): SubagentModelPool | undefined { const section = config.get(SECONDARY_MODEL_SECTION); if (section?.models !== undefined) { return { defaultModel: section.defaultModel, models: section.models }; } if (section?.defaultModel !== undefined) { return { defaultModel: section.defaultModel, models: { [section.defaultModel]: '' } }; } if (section?.model !== undefined) { return { defaultModel: section.model, models: { [section.model]: '' } }; } return undefined; } export const SECONDARY_MODEL_FORCE_REQUIRES_DEFAULT_MESSAGE = '[secondary_model].default_model is required when [secondary_model].force is set'; export const SECONDARY_MODEL_FORCE_EXCLUDES_MODELS_MESSAGE = '[secondary_model].force cannot be combined with [secondary_model.models]: the pool table only exists to offer the main agent a choice, and force removes that choice'; export function isSubagentModelForced(config: IConfigService): boolean { return config.get(SECONDARY_MODEL_SECTION)?.force === true; } export function exposesSubagentModelChoice(config: IConfigService): boolean { if (isSubagentModelForced(config)) return false; return resolveSubagentModelPool(config) !== undefined; } export const SECONDARY_MODEL_DEFAULT_MODEL_REQUIRED_MESSAGE = '[secondary_model].default_model is required when [secondary_model.models] is configured'; export const SECONDARY_MODEL_PRIMARY_MODEL_RESERVED_MESSAGE = `[secondary_model.models] key "${PRIMARY_SUBAGENT_MODEL_CHOICE}" is reserved: it always binds the caller's own model. Rename the pool entry.`; export function assertValidSubagentModelPool( pool: SubagentModelPool, modelCatalog: IModelCatalog, ): void { if (Object.hasOwn(pool.models, PRIMARY_SUBAGENT_MODEL_CHOICE)) { throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_PRIMARY_MODEL_RESERVED_MESSAGE, { details: { section: SECONDARY_MODEL_SECTION, field: 'models', model: PRIMARY_SUBAGENT_MODEL_CHOICE, }, }); } const aliases = Object.keys(pool.models); if (pool.defaultModel === undefined) { throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_DEFAULT_MODEL_REQUIRED_MESSAGE, { details: { section: SECONDARY_MODEL_SECTION, field: 'defaultModel' }, }); } if (!Object.hasOwn(pool.models, pool.defaultModel)) { throw new Error2( ErrorCodes.CONFIG_INVALID, `[secondary_model].default_model "${pool.defaultModel}" is not a [secondary_model.models] key. Available models: ${aliases.join(', ')}.`, { details: { model: pool.defaultModel, availableModels: aliases } }, ); } for (const alias of aliases) { try { modelCatalog.get(alias); } catch (error) { throw new Error2( ErrorCodes.CONFIG_INVALID, `[secondary_model.models] entry "${alias}" could not be resolved: ${error instanceof Error ? error.message : String(error)}`, { cause: error, details: { model: alias } }, ); } } } export function assertValidSubagentModelConfig( config: IConfigService, modelCatalog: IModelCatalog, ): void { const section = config.get(SECONDARY_MODEL_SECTION); if (section?.force === true) { if (section.models !== undefined) { throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_FORCE_EXCLUDES_MODELS_MESSAGE, { details: { section: SECONDARY_MODEL_SECTION, field: 'force' }, }); } if (section.defaultModel === undefined && section.model === undefined) { throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_FORCE_REQUIRES_DEFAULT_MESSAGE, { details: { section: SECONDARY_MODEL_SECTION, field: 'defaultModel' }, }); } } const pool = resolveSubagentModelPool(config); if (pool !== undefined) assertValidSubagentModelPool(pool, modelCatalog); assertValidSubagentDefaultEffort(section, pool, modelCatalog); } function assertValidSubagentDefaultEffort( section: SecondaryModelConfig | undefined, pool: SubagentModelPool | undefined, modelCatalog: IModelCatalog, ): void { const effort = section?.defaultEffort === undefined ? undefined : normalizeRequestedThinkingEffort(section.defaultEffort); if (effort === undefined || pool === undefined) return; for (const alias of Object.keys(pool.models)) { const model = modelCatalog.get(alias); if (effort === 'off' && model.alwaysThinking === true) { throw new Error2( ErrorCodes.CONFIG_INVALID, `[secondary_model].default_effort "off" cannot disable thinking for model "${alias}", which always reasons. Choose a concrete thinking effort instead of "off".`, { details: { section: SECONDARY_MODEL_SECTION, field: 'defaultEffort', model: alias, effort, }, }, ); } if (modelSupportsThinkingEffort(effort, model, true)) continue; if (!modelSupportsThinking(model)) { throw new Error2( ErrorCodes.CONFIG_INVALID, `[secondary_model].default_effort "${effort}" is set but model "${alias}" does not support thinking.`, { details: { section: SECONDARY_MODEL_SECTION, field: 'defaultEffort', model: alias, effort, }, }, ); } throw new Error2( ErrorCodes.CONFIG_INVALID, `[secondary_model].default_effort "${effort}" is not supported by model "${alias}". Supported efforts: ${model.supportEfforts?.join(', ')}.`, { details: { section: SECONDARY_MODEL_SECTION, field: 'defaultEffort', model: alias, effort, }, }, ); } } export type SubagentModelSource = 'forced' | 'primary_override' | 'inherited' | 'secondary_pool'; export function resolveSubagentBinding( config: IConfigService, own: { modelAlias: string; thinkingLevel: string }, requested?: string, ): { model: string; thinking?: string; modelSource: SubagentModelSource } { const section = config.get(SECONDARY_MODEL_SECTION); if (section?.force === true) { if (section.models !== undefined) { throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_FORCE_EXCLUDES_MODELS_MESSAGE, { details: { section: SECONDARY_MODEL_SECTION, field: 'force' }, }); } const forcedModel = section.defaultModel ?? section.model; if (forcedModel === undefined) { throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_FORCE_REQUIRES_DEFAULT_MESSAGE, { details: { section: SECONDARY_MODEL_SECTION, field: 'defaultModel' }, }); } if (requested !== undefined) { throw new Error2( ErrorCodes.CONFIG_INVALID, `Invalid model "${requested}": [secondary_model].force is set, so every subagent binds "${forcedModel}" (omit the model parameter).`, { details: { model: requested } }, ); } return { model: forcedModel, thinking: section.defaultEffort, modelSource: 'forced' }; } if (requested === PRIMARY_SUBAGENT_MODEL_CHOICE) { return { model: own.modelAlias, thinking: own.thinkingLevel, modelSource: 'primary_override' }; } const pool = resolveSubagentModelPool(config); if (pool === undefined) { if (requested !== undefined) { throw new Error2( ErrorCodes.CONFIG_INVALID, `Invalid model "${requested}": no [secondary_model.models] pool is configured, so subagents inherit the caller's model (pass "primary" or omit the model parameter).`, { details: { model: requested } }, ); } return { model: own.modelAlias, thinking: own.thinkingLevel, modelSource: 'inherited' }; } if (Object.hasOwn(pool.models, PRIMARY_SUBAGENT_MODEL_CHOICE)) { throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_PRIMARY_MODEL_RESERVED_MESSAGE, { details: { section: SECONDARY_MODEL_SECTION, field: 'models', model: PRIMARY_SUBAGENT_MODEL_CHOICE, }, }); } const choice = requested ?? pool.defaultModel; if (choice === undefined) { throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_DEFAULT_MODEL_REQUIRED_MESSAGE, { details: { section: SECONDARY_MODEL_SECTION, field: 'defaultModel' }, }); } if (!Object.hasOwn(pool.models, choice)) { const available = [...Object.keys(pool.models), PRIMARY_SUBAGENT_MODEL_CHOICE]; throw new Error2( ErrorCodes.CONFIG_INVALID, `Invalid model "${choice}". Available models: ${available.join(', ')}.`, { details: { model: choice, availableModels: available } }, ); } return { model: choice, thinking: section?.defaultEffort, modelSource: 'secondary_pool' }; } export function resolveSubagentThinking( config: IConfigService, model: Model | undefined, explicit: string | undefined, ): string | undefined { if (explicit !== undefined) return explicit; if (config.get(THINKING_SECTION)?.enabled === false) return undefined; return declaredDefaultEffortForModel(model); } export function buildSubagentModelDescriptions( config: IConfigService, callerModelAlias: string | undefined, ): string | undefined { if (!exposesSubagentModelChoice(config)) return undefined; const pool = resolveSubagentModelPool(config)!; const lines = ['Available models (pass via model):']; const defaultModel = pool.defaultModel; for (const alias of orderedPoolAliases(pool)) { const marker = alias === defaultModel ? ' [default]' : ''; lines.push(formatPoolLine(`${alias}${marker}`, pool.models[alias]!)); } const primaryLabel = callerModelAlias === undefined ? PRIMARY_SUBAGENT_MODEL_CHOICE : `${PRIMARY_SUBAGENT_MODEL_CHOICE} (= ${callerModelAlias})`; lines.push( `- ${primaryLabel}: your current model and thinking level`, ); lines.push("Pool entries don't inherit your thinking level."); return lines.join('\n'); } export function buildSubagentModelSummary(config: IConfigService): string | undefined { if (!exposesSubagentModelChoice(config)) return undefined; const pool = resolveSubagentModelPool(config)!; const labels = orderedPoolAliases(pool).map((alias) => alias === pool.defaultModel ? `${alias} [default]` : alias, ); labels.push(`${PRIMARY_SUBAGENT_MODEL_CHOICE} (your current model and thinking level)`); return `Available models (pass via model): ${labels.join(', ')}.`; } function orderedPoolAliases(pool: SubagentModelPool): string[] { const aliases = Object.keys(pool.models); const defaultModel = pool.defaultModel; if (defaultModel === undefined || !Object.hasOwn(pool.models, defaultModel)) return aliases; return [defaultModel, ...aliases.filter((alias) => alias !== defaultModel)]; } function formatPoolLine(label: string, description: string): string { return description === '' ? `- ${label}` : `- ${label}: ${description}`; } export function stripSubagentModelParameter( parameters: Record, ): Record { const properties = parameters['properties']; if (!isPlainObject(properties) || !('model' in properties)) return parameters; const nextProperties = { ...properties }; delete nextProperties['model']; const next: Record = { ...parameters, properties: nextProperties }; const required = parameters['required']; if (Array.isArray(required) && required.includes('model')) { next['required'] = required.filter((entry) => entry !== 'model'); } return next; } export function stripSubagentForkParameter( parameters: Record, ): Record { const properties = parameters['properties']; if (!isPlainObject(properties) || !('fork' in properties)) return parameters; const nextProperties = { ...properties }; delete nextProperties['fork']; const next: Record = { ...parameters, properties: nextProperties }; const required = parameters['required']; if (Array.isArray(required) && required.includes('fork')) { next['required'] = required.filter((entry) => entry !== 'fork'); } return next; } export function wrapSubagentModelError( error: unknown, boundModel: string, callerModelAlias: string | undefined, ): unknown { if (boundModel === callerModelAlias) return error; if (!isError2(error) || error.code !== ErrorCodes.CONFIG_INVALID) return error; if (error.details?.['model'] !== boundModel) return error; return new Error2( error.code, `${error.message} (subagent model "${boundModel}" comes from [secondary_model.models] — check that it names a valid [models] entry)`, { cause: error, name: error.name, details: { ...error.details, subagentModel: boundModel, subagentModelConfig: { section: 'secondary_model.models', }, }, }, ); } export function formatSubagentTimeoutDescription(ms: number): string { if (ms % (60 * 60 * 1000) === 0) { const h = ms / (60 * 60 * 1000); return `${h} hour${h === 1 ? '' : 's'}`; } if (ms % (60 * 1000) === 0) { const m = ms / (60 * 1000); return `${m} minute${m === 1 ? '' : 's'}`; } if (ms % 1000 === 0) { const s = ms / 1000; return `${s} second${s === 1 ? '' : 's'}`; } return `${ms} ms`; }