From bdc3c3c694abc19ed3ec6cb7fa85885e2767d9f6 Mon Sep 17 00:00:00 2001 From: Gustavo Miranda Date: Sat, 8 Aug 2026 13:40:49 -0300 Subject: [PATCH] fix(agents): route worker models by available profiles --- README.md | 61 ++++ src/query/model.test.ts | 10 + src/query/model.ts | 14 +- src/services/api/agentRouting.test.ts | 204 ++++++++++++- src/services/api/agentRouting.ts | 275 +++++++++++++++--- src/tools/AgentTool/AgentTool.tsx | 18 +- src/tools/AgentTool/built-in/exploreAgent.ts | 3 + src/tools/AgentTool/runAgent.ts | 49 +++- src/tools/SkillTool/SkillTool.ts | 3 +- src/utils/model/agent.test.ts | 22 +- src/utils/model/agent.ts | 31 +- .../processUserInput/processSlashCommand.tsx | 5 +- src/utils/settings/types.ts | 37 ++- 13 files changed, 664 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index d431df9587..f9df7389ba 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,67 @@ verboo /login `WebFetch` works via basic HTTP plus HTML-to-markdown conversion. It may fail on JavaScript-rendered sites or sites that block plain HTTP requests. +## Agent Model Routing + +Verboo resolves subagent profiles against the authenticated `/models` catalog, +so routing adapts automatically to the models included in the current account. +The built-in read-only `Explore` agent uses the `fast` profile by default. + +Configure other agents in `~/.verboo/settings.json`: + +```json +{ + "agentRouting": { + "Explore": "fast", + "worker-review": { "profile": "review" }, + "worker-backend": { "profile": "coding" }, + "worker-tests": { "profile": "testing" }, + "default": { "profile": "balanced" } + } +} +``` + +Available profiles are `fast`, `review`, `coding`, `testing`, and `balanced`. Each profile +selects the first preferred model that is actually available to the logged-in +account. When no candidate is available, routing inherits the parent model. + +Skills can use the same dynamic profiles in `SKILL.md` frontmatter: + +```yaml +--- +name: worker-review +model: profile:review +context: fork +--- +``` + +To opt into an unknown future model as a last resort: + +```json +{ + "agentRouting": { + "Explore": { "profile": "fast", "fallback": "first-available" } + } +} +``` + +An exact Verboo model can also be requested without configuring another API +key. The route is used only when that model exists in the authenticated catalog: + +```json +{ + "agentRouting": { + "worker-review": { + "model": "deepseek-v4-pro", + "provider": "inherit" + } + } +} +``` + +The legacy `agentModels` plus string `agentRouting` format for external +OpenAI-compatible providers remains supported. + --- ## Headless gRPC Server diff --git a/src/query/model.test.ts b/src/query/model.test.ts index bd86116ac2..7969023708 100644 --- a/src/query/model.test.ts +++ b/src/query/model.test.ts @@ -30,4 +30,14 @@ describe('resolveQueryTurnModel', () => { }), ).toBe('max/deepseek-v4-pro') }) + + test('falls back to the session model when a profile has no available candidate', () => { + expect( + resolveQueryTurnModel({ + permissionMode: 'default', + turnModel: 'profile:review', + sessionModel: 'max/deepseek-v4-pro', + }), + ).toBe('max/deepseek-v4-pro') + }) }) diff --git a/src/query/model.ts b/src/query/model.ts index ef6b17d568..46133db0fb 100644 --- a/src/query/model.ts +++ b/src/query/model.ts @@ -1,4 +1,9 @@ import type { PermissionMode } from '../utils/permissions/PermissionMode.js' +import { + parseAgentModelProfileReference, + resolveAgentProfileModel, +} from '../services/api/agentRouting.js' +import { getCachedVerbooModels } from '../services/api/verbooModels.js' import { getDefaultMainLoopModelSetting, getRuntimeMainLoopModel, @@ -26,8 +31,15 @@ export function resolveQueryTurnModel({ sessionModel, exceeds200kTokens = false, }: ResolveQueryTurnModelParams): string { + const turnProfile = parseAgentModelProfileReference(turnModel) + const profileModel = turnProfile + ? resolveAgentProfileModel( + turnProfile, + getCachedVerbooModels() ?? [], + ) + : null const requestedModel = - turnModel ?? + (turnProfile ? profileModel : turnModel) ?? parseUserSpecifiedModel( sessionModel ?? getDefaultMainLoopModelSetting(), ) diff --git a/src/services/api/agentRouting.test.ts b/src/services/api/agentRouting.test.ts index 1522b54548..80a0f69e25 100644 --- a/src/services/api/agentRouting.test.ts +++ b/src/services/api/agentRouting.test.ts @@ -1,6 +1,18 @@ import { describe, expect, test } from 'bun:test' -import { resolveAgentProvider } from './agentRouting.js' -import type { SettingsJson } from '../../utils/settings/types.js' +import { + parseAgentModelProfileReference, + resolveAgentProfileModel, + resolveAgentProvider, + resolveAgentRoute, +} from './agentRouting.js' +import { + SettingsSchema, + type SettingsJson, +} from '../../utils/settings/types.js' +import type { VerbooModel } from './verbooModels.js' + +const models = (...ids: string[]): VerbooModel[] => + ids.map(id => ({ id, raw: { id } })) const baseSettings = { agentModels: { @@ -123,3 +135,191 @@ describe('resolveAgentProvider', () => { expect(result?.model).toBe('deepseek-chat') }) }) + +describe('resolveAgentRoute profiles', () => { + const proCatalog = models( + 'pro/qwen3.6-27b', + 'pro/deepseek-v4-flash', + 'pro/mimo-v2.5', + 'pro/glm-4.7-flash', + ) + const maxCatalog = [ + ...proCatalog, + ...models( + 'max/minimax-m3', + 'max/deepseek-v4-pro', + 'max/mimo-v2.5-pro', + ), + ] + const ultraCatalog = [ + ...maxCatalog, + ...models('ultra/kimi-k2.7', 'ultra/glm-5.2'), + ] + + test('parses skill model profile references', () => { + expect(parseAgentModelProfileReference('profile:review')).toBe('review') + expect(parseAgentModelProfileReference('PROFILE:FAST')).toBe('fast') + expect(parseAgentModelProfileReference('fast')).toBeNull() + expect(parseAgentModelProfileReference('unknown-model')).toBeNull() + }) + + test('resolves a skill profile against the authenticated catalog', () => { + expect(resolveAgentProfileModel('review', proCatalog)).toBe( + 'pro/qwen3.6-27b', + ) + expect(resolveAgentProfileModel('review', maxCatalog)).toBe( + 'max/deepseek-v4-pro', + ) + }) + + test('testing profile preserves the plan-qualified Qwen model ID', () => { + expect( + resolveAgentProfileModel( + 'testing', + models('max/mimo-v2.5', 'max/qwen3.6-27b'), + ), + ).toBe('max/qwen3.6-27b') + }) + + test('routes built-in Explore to fast by default in Verboo mode', () => { + const settings = {} as SettingsJson + + expect( + resolveAgentRoute(undefined, 'Explore', settings, proCatalog), + ).toEqual({ + model: 'pro/deepseek-v4-flash', + source: 'profile', + profile: 'fast', + }) + }) + + test.each([ + ['Pro', proCatalog, 'pro/deepseek-v4-flash'], + ['Max', maxCatalog, 'pro/deepseek-v4-flash'], + ['Ultra', ultraCatalog, 'pro/deepseek-v4-flash'], + ])('selects a fast model from the %s catalog', (_plan, catalog, expected) => { + const settings = { + agentRouting: { Explore: 'fast' }, + } as SettingsJson + + expect(resolveAgentRoute(undefined, 'Explore', settings, catalog)).toEqual({ + model: expected, + source: 'profile', + profile: 'fast', + }) + }) + + test.each([ + ['Pro', proCatalog, 'pro/qwen3.6-27b'], + ['Max', maxCatalog, 'max/deepseek-v4-pro'], + ['Ultra', ultraCatalog, 'max/deepseek-v4-pro'], + ])( + 'selects the best available review model from the %s catalog', + (_plan, catalog, expected) => { + const settings = { + agentRouting: { 'worker-review': { profile: 'review' } }, + } as SettingsJson + + expect( + resolveAgentRoute( + undefined, + 'worker-review', + settings, + catalog, + ), + ).toEqual({ + model: expected, + source: 'profile', + profile: 'review', + }) + }, + ) + + test('preserves the exact canonical ID returned by the catalog', () => { + const settings = { + agentRouting: { + Explore: { model: 'deepseek-v4-flash', provider: 'inherit' }, + }, + } as SettingsJson + + expect( + resolveAgentRoute(undefined, 'Explore', settings, proCatalog), + ).toEqual({ + model: 'pro/deepseek-v4-flash', + source: 'verboo-model', + }) + }) + + test('inherits when an explicit model is unavailable', () => { + const settings = { + agentRouting: { + Explore: { model: 'deepseek-v4-pro', provider: 'inherit' }, + }, + } as SettingsJson + + expect( + resolveAgentRoute(undefined, 'Explore', settings, proCatalog), + ).toBeNull() + }) + + test('can opt into the first available model for unknown future catalogs', () => { + const futureCatalog = models('future/new-fast-model') + const settings = { + agentRouting: { + Explore: { profile: 'fast', fallback: 'first-available' }, + }, + } as SettingsJson + + expect( + resolveAgentRoute(undefined, 'Explore', settings, futureCatalog), + ).toEqual({ + model: 'future/new-fast-model', + source: 'profile', + profile: 'fast', + }) + }) + + test('keeps legacy external provider routing ahead of profile names', () => { + const settings = { + agentModels: { + fast: { base_url: 'https://fast.example.com/v1', api_key: 'secret' }, + }, + agentRouting: { Explore: 'fast' }, + } as SettingsJson + + expect( + resolveAgentRoute(undefined, 'Explore', settings, proCatalog), + ).toEqual({ + model: 'fast', + source: 'external-provider', + providerOverride: { + model: 'fast', + baseURL: 'https://fast.example.com/v1', + apiKey: 'secret', + }, + }) + }) + + test('settings schema accepts profile and inherited-model routes', () => { + const result = SettingsSchema().safeParse({ + agentRouting: { + Explore: 'fast', + 'worker-review': { profile: 'review' }, + 'worker-tests': { profile: 'testing' }, + custom: { model: 'max/mimo-v2.5-pro', provider: 'inherit' }, + }, + }) + + expect(result.success).toBe(true) + }) + + test('settings schema rejects unknown profiles', () => { + const result = SettingsSchema().safeParse({ + agentRouting: { + Explore: { profile: 'turbo' }, + }, + }) + + expect(result.success).toBe(false) + }) +}) diff --git a/src/services/api/agentRouting.ts b/src/services/api/agentRouting.ts index 1afdf7959c..54dcf314cd 100644 --- a/src/services/api/agentRouting.ts +++ b/src/services/api/agentRouting.ts @@ -1,4 +1,76 @@ import type { SettingsJson } from '../../utils/settings/types.js' +import type { VerbooModel } from './verbooModels.js' + +export const AGENT_MODEL_PROFILES = { + fast: [ + 'deepseek-v4-flash', + 'glm-4.7-flash', + 'mimo-v2.5', + 'qwen3.6-27b', + 'minimax-m3', + 'mimo-v2.5-pro', + 'deepseek-v4-pro', + 'kimi-k2.7', + 'glm-5.2', + ], + review: [ + 'deepseek-v4-pro', + 'mimo-v2.5-pro', + 'glm-5.2', + 'kimi-k2.7', + 'qwen3.6-27b', + 'minimax-m3', + 'mimo-v2.5', + 'deepseek-v4-flash', + 'glm-4.7-flash', + ], + coding: [ + 'mimo-v2.5-pro', + 'deepseek-v4-pro', + 'glm-5.2', + 'kimi-k2.7', + 'qwen3.6-27b', + 'minimax-m3', + 'mimo-v2.5', + 'deepseek-v4-flash', + 'glm-4.7-flash', + ], + testing: [ + 'qwen3.6-27b', + 'deepseek-v4-pro', + 'mimo-v2.5-pro', + 'mimo-v2.5', + 'minimax-m3', + 'deepseek-v4-flash', + 'glm-5.2', + 'kimi-k2.7', + 'glm-4.7-flash', + ], + balanced: [ + 'mimo-v2.5', + 'qwen3.6-27b', + 'minimax-m3', + 'deepseek-v4-flash', + 'glm-4.7-flash', + 'mimo-v2.5-pro', + 'deepseek-v4-pro', + 'kimi-k2.7', + 'glm-5.2', + ], +} as const + +export type AgentModelProfile = keyof typeof AGENT_MODEL_PROFILES + +const DEFAULT_AGENT_PROFILES: Readonly> = { + explore: 'fast', +} + +export interface ResolvedAgentRoute { + model: string + source: 'external-provider' | 'profile' | 'verboo-model' + profile?: AgentModelProfile + providerOverride?: ProviderOverride +} /** * Provider override resolved from agent routing config. @@ -20,56 +92,193 @@ function normalize(key: string): string { return key.toLowerCase().replace(/[-_]/g, '') } +function canonicalModelName(model: string): string { + const normalized = model.trim().toLowerCase().replace(/\[1m\]$/i, '') + return normalized.split('/').at(-1) ?? normalized +} + +function isAgentModelProfile(value: string): value is AgentModelProfile { + return Object.hasOwn(AGENT_MODEL_PROFILES, value) +} + +export function parseAgentModelProfileReference( + value: string | undefined, +): AgentModelProfile | null { + if (!value) return null + const normalized = value.trim().toLowerCase() + if (!normalized.startsWith('profile:')) return null + const candidate = normalized.slice('profile:'.length) + return isAgentModelProfile(candidate) ? candidate : null +} + +function findRoutingValue( + name: string | undefined, + subagentType: string | undefined, + settings: SettingsJson | null, +): NonNullable[string] | undefined { + const routing = settings?.agentRouting + if (!routing) return undefined + + const normalizedRouting = new Map< + string, + NonNullable[string] + >() + for (const [key, value] of Object.entries(routing)) { + const normalizedKey = normalize(key) + if (normalizedRouting.has(normalizedKey)) { + console.error( + `[agentRouting] Warning: routing key "${key}" collides with an existing key after normalization (both map to "${normalizedKey}"). First entry wins.`, + ) + } + if (!normalizedRouting.has(normalizedKey)) { + normalizedRouting.set(normalizedKey, value) + } + } + + for (const candidate of [name, subagentType, 'default'].filter( + Boolean, + ) as string[]) { + const match = normalizedRouting.get(normalize(candidate)) + if (match !== undefined) return match + } + + return undefined +} + +function findAvailableModel( + requestedModel: string, + availableModels: readonly VerbooModel[], +): VerbooModel | undefined { + const exact = availableModels.find( + model => model.id.toLowerCase() === requestedModel.toLowerCase(), + ) + if (exact) return exact + + const canonicalRequested = canonicalModelName(requestedModel) + return availableModels.find( + model => canonicalModelName(model.id) === canonicalRequested, + ) +} + +export function resolveAgentProfileModel( + profile: AgentModelProfile, + availableModels: readonly VerbooModel[], +): string | null { + const selected = AGENT_MODEL_PROFILES[profile] + .map(candidate => findAvailableModel(candidate, availableModels)) + .find((model): model is VerbooModel => model !== undefined) + return selected?.id ?? null +} + /** - * Look up agent.routing by name or subagent_type, then resolve via agent.models. + * Resolve an agent route against the authenticated Verboo model catalog. * - * Priority: name > subagentType > "default" > null (use global provider) + * String values remain backwards compatible with agentModels. If no external + * provider exists under that name, known profile names and exact Verboo model + * IDs are resolved only when present in the account's available model catalog. */ -export function resolveAgentProvider( +export function resolveAgentRoute( name: string | undefined, subagentType: string | undefined, settings: SettingsJson | null, -): ProviderOverride | null { + availableModels: readonly VerbooModel[] = [], +): ResolvedAgentRoute | null { if (!settings) return null - const routing = settings.agentRouting - const models = settings.agentModels - if (!routing || !models) return null + const routingValue = findRoutingValue(name, subagentType, settings) + if (routingValue === undefined) { + const defaultProfile = subagentType + ? DEFAULT_AGENT_PROFILES[normalize(subagentType)] + : undefined + if (!defaultProfile) return null - // Build normalized lookup from routing config. - // Warn on duplicate normalized keys (e.g. "explore-agent" and "explore_agent" - // both normalize to "exploreagent") to prevent silent shadowing. - const normalizedRouting = new Map() - for (const [key, value] of Object.entries(routing)) { - const nk = normalize(key) - if (normalizedRouting.has(nk)) { - console.error(`[agentRouting] Warning: routing key "${key}" collides with an existing key after normalization (both map to "${nk}"). First entry wins.`) + const selectedModel = resolveAgentProfileModel( + defaultProfile, + availableModels, + ) + return selectedModel + ? { + model: selectedModel, + source: 'profile', + profile: defaultProfile, + } + : null + } + + if (typeof routingValue === 'string') { + const externalModel = settings.agentModels?.[routingValue] + if (externalModel) { + const providerOverride = { + model: routingValue, + baseURL: externalModel.base_url, + apiKey: externalModel.api_key, + } + return { + model: routingValue, + source: 'external-provider', + providerOverride, + } } - if (!normalizedRouting.has(nk)) { - normalizedRouting.set(nk, value) + + if (isAgentModelProfile(routingValue)) { + const selectedModel = resolveAgentProfileModel( + routingValue, + availableModels, + ) + return selectedModel + ? { + model: selectedModel, + source: 'profile', + profile: routingValue, + } + : null } + + const selected = findAvailableModel(routingValue, availableModels) + return selected + ? { model: selected.id, source: 'verboo-model' } + : null } - // Try name first, then subagentType, then "default" - const candidates = [name, subagentType, 'default'].filter(Boolean) as string[] - let modelName: string | undefined + if ('model' in routingValue) { + const selected = findAvailableModel(routingValue.model, availableModels) + return selected + ? { model: selected.id, source: 'verboo-model' } + : null + } - for (const candidate of candidates) { - const match = normalizedRouting.get(normalize(candidate)) - if (match) { - modelName = match - break + const selectedModel = resolveAgentProfileModel( + routingValue.profile, + availableModels, + ) + if (selectedModel) { + return { + model: selectedModel, + source: 'profile', + profile: routingValue.profile, } } - if (!modelName) return null + if (routingValue.fallback === 'first-available' && availableModels[0]) { + return { + model: availableModels[0].id, + source: 'profile', + profile: routingValue.profile, + } + } - const modelConfig = models[modelName] - if (!modelConfig) return null + return null +} - return { - model: modelName, - baseURL: modelConfig.base_url, - apiKey: modelConfig.api_key, - } +/** + * Look up agent.routing by name or subagent_type, then resolve via agent.models. + * + * Priority: name > subagentType > "default" > null (use global provider) + */ +export function resolveAgentProvider( + name: string | undefined, + subagentType: string | undefined, + settings: SettingsJson | null, +): ProviderOverride | null { + return resolveAgentRoute(name, subagentType, settings)?.providerOverride ?? null } diff --git a/src/tools/AgentTool/AgentTool.tsx b/src/tools/AgentTool/AgentTool.tsx index 376bd91a0e..56cec09adc 100644 --- a/src/tools/AgentTool/AgentTool.tsx +++ b/src/tools/AgentTool/AgentTool.tsx @@ -11,6 +11,8 @@ import { startAgentSummarization } from '../../services/AgentSummary/agentSummar import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; import { clearDumpState } from '../../services/api/dumpPrompts.js'; +import { resolveAgentRoute } from '../../services/api/agentRouting.js'; +import { getCachedVerbooModels } from '../../services/api/verbooModels.js'; import { completeAgentTask as completeAsyncAgent, createActivityDescriptionResolver, createProgressTracker, enqueueAgentNotification, failAgentTask as failAsyncAgent, getProgressUpdate, getTokenCountFromTracker, isLocalAgentTask, killAsyncAgent, registerAgentForeground, registerAsyncAgent, unregisterAgentForeground, updateAgentProgress as updateAsyncAgentProgress, updateProgressFromMessage } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; import { checkRemoteAgentEligibility, formatPreconditionError, getRemoteTaskSessionUrl, registerRemoteAgentTask } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js'; import { assembleToolPool } from '../../tools.js'; @@ -31,6 +33,7 @@ import type { PermissionResult } from '../../utils/permissions/PermissionResult. import { filterDeniedAgents, getDenyRuleForAgent } from '../../utils/permissions/permissions.js'; import { enqueueSdkEvent } from '../../utils/sdkEventQueue.js'; import { writeAgentMetadata } from '../../utils/sessionStorage.js'; +import { getInitialSettings } from '../../utils/settings/settings.js'; import { sleep } from '../../utils/sleep.js'; import { buildEffectiveSystemPrompt } from '../../utils/systemPrompt.js'; import { asSystemPrompt } from '../../utils/systemPromptType.js'; @@ -415,8 +418,10 @@ export const AgentTool = buildTool({ setAgentColor(selectedAgent.agentType, selectedAgent.color); } - // Resolve agent params for logging (these are already resolved in runAgent) - const resolvedAgentModel = getAgentModel(selectedAgent.model, toolUseContext.options.mainLoopModel, isForkPath ? undefined : model, permissionMode); + // Resolve the same catalog-aware route used by runAgent so telemetry and + // progress UI report the model that will actually execute the work. + const configuredRoute = resolveAgentRoute(name, selectedAgent.agentType, getInitialSettings(), getCachedVerbooModels() ?? []); + const resolvedAgentModel = configuredRoute?.model ?? getAgentModel(selectedAgent.model, toolUseContext.options.mainLoopModel, isForkPath ? undefined : model, permissionMode); logEvent('tengu_agent_tool_selected', { agent_type: selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, model: resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, @@ -428,8 +433,13 @@ export const AgentTool = buildTool({ is_fork: isForkPath }); - // Resolve effective isolation mode (explicit param overrides agent def) - const effectiveIsolation = isolation ?? selectedAgent.isolation; + // The built-in Explore agent is strictly read-only. A worktree adds setup + // and cleanup latency without providing any isolation benefit. + const isReadOnlyBuiltInExplore = isBuiltInAgent(selectedAgent) && selectedAgent.agentType === 'Explore'; + const effectiveIsolation = isReadOnlyBuiltInExplore ? selectedAgent.isolation : isolation ?? selectedAgent.isolation; + if (isReadOnlyBuiltInExplore && isolation) { + logForDebugging(`[AgentTool] Ignoring isolation=${isolation} for read-only built-in Explore agent`); + } // Remote isolation: delegate to CCR. Gated internal-only — the guard enables // dead code elimination of the entire block for external builds. diff --git a/src/tools/AgentTool/built-in/exploreAgent.ts b/src/tools/AgentTool/built-in/exploreAgent.ts index 021120a71e..a9ddf68281 100644 --- a/src/tools/AgentTool/built-in/exploreAgent.ts +++ b/src/tools/AgentTool/built-in/exploreAgent.ts @@ -75,6 +75,9 @@ export const EXPLORE_AGENT: BuiltInAgentDefinition = { baseDir: 'built-in', // Use haiku for speed — explore is a fast read-only search agent model: 'haiku', + // Bound repository discovery. Each turn can issue parallel reads/searches, + // so four turns provide enough breadth without letting Plan wait indefinitely. + maxTurns: 4, // Explore is a fast read-only search agent — it doesn't need commit/PR/lint // rules from CLAUDE.md. The main agent has full context and interprets results. omitClaudeMd: true, diff --git a/src/tools/AgentTool/runAgent.ts b/src/tools/AgentTool/runAgent.ts index b249131a02..97eb6b791f 100644 --- a/src/tools/AgentTool/runAgent.ts +++ b/src/tools/AgentTool/runAgent.ts @@ -15,7 +15,13 @@ import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' import { query } from '../../query.js' import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js' +import { + parseAgentModelProfileReference, + resolveAgentProfileModel, + resolveAgentRoute, +} from '../../services/api/agentRouting.js' import { cleanupAgentTracking } from '../../services/api/promptCacheBreakDetection.js' +import { getCachedVerbooModels } from '../../services/api/verbooModels.js' import { connectToServer, fetchToolsForClient, @@ -57,9 +63,7 @@ import { clearSessionHooks } from '../../utils/hooks/sessionHooks.js' import { executeSubagentStartHooks } from '../../utils/hooks.js' import { createUserMessage } from '../../utils/messages.js' import { getAgentModel } from '../../utils/model/agent.js' -import { resolveAgentProvider } from '../../services/api/agentRouting.js' import { getInitialSettings } from '../../utils/settings/settings.js' -import type { ModelAlias } from '../../utils/model/aliases.js' import { clearAgentTranscriptSubdir, recordSidechainTranscript, @@ -283,7 +287,7 @@ export async function* runAgent({ abortController?: AbortController agentId?: AgentId } - model?: ModelAlias + model?: string maxTurns?: number /** Preserve toolUseResult on messages for subagents with viewable transcripts */ preserveToolUseResults?: boolean @@ -337,20 +341,41 @@ export async function* runAgent({ const rootSetAppState = toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState - const resolvedAgentModel = getAgentModel( + const configuredRoute = resolveAgentRoute( + agentName, + agentDefinition.agentType, + getInitialSettings(), + getCachedVerbooModels() ?? [], + ) + const toolModelProfile = parseAgentModelProfileReference(model) + const profileToolModel = toolModelProfile + ? resolveAgentProfileModel( + toolModelProfile, + getCachedVerbooModels() ?? [], + ) ?? undefined + : model + if (toolModelProfile) { + logForDebugging( + `[agentRouting] agent=${agentDefinition.agentType} source=skill-profile profile=${toolModelProfile} model=${profileToolModel ?? 'inherit'}`, + ) + } + const resolvedAgentModel = configuredRoute?.model ?? getAgentModel( agentDefinition.model, toolUseContext.options.mainLoopModel, - model, + profileToolModel, permissionMode, ) - // Resolve per-agent provider routing from settings - const providerOverride = resolveAgentProvider( - agentName, - agentDefinition.agentType, - getInitialSettings(), - ) - const effectiveModel = providerOverride ? providerOverride.model : resolvedAgentModel + // External provider routes carry their own credentials. Profile and exact + // Verboo routes reuse the authenticated provider from the parent session. + const providerOverride = configuredRoute?.providerOverride + const effectiveModel = configuredRoute?.model ?? resolvedAgentModel + + if (configuredRoute) { + logForDebugging( + `[agentRouting] agent=${agentDefinition.agentType} source=${configuredRoute.source} model=${effectiveModel}${configuredRoute.profile ? ` profile=${configuredRoute.profile}` : ''}`, + ) + } const agentId = override?.agentId ? override.agentId : createAgentId() diff --git a/src/tools/SkillTool/SkillTool.ts b/src/tools/SkillTool/SkillTool.ts index 519e957ff4..3721cc54c9 100644 --- a/src/tools/SkillTool/SkillTool.ts +++ b/src/tools/SkillTool/SkillTool.ts @@ -55,7 +55,6 @@ import { import { parseFrontmatter } from '../../utils/frontmatterParser.js' import { lazySchema } from '../../utils/lazySchema.js' import { createUserMessage, normalizeMessages } from '../../utils/messages.js' -import type { ModelAlias } from '../../utils/model/aliases.js' import { resolveSkillModelOverride } from '../../utils/model/model.js' import { recordSkillUsage } from '../../utils/suggestions/skillUsageTracking.js' import { createAgentId } from '../../utils/uuid.js' @@ -230,7 +229,7 @@ async function executeForkedSkill( canUseTool, isAsync: false, querySource: 'agent:custom', - model: command.model as ModelAlias | undefined, + model: command.model, availableTools: context.options.tools, override: { agentId }, })) { diff --git a/src/utils/model/agent.test.ts b/src/utils/model/agent.test.ts index 9b979ab0cc..6db39fa264 100644 --- a/src/utils/model/agent.test.ts +++ b/src/utils/model/agent.test.ts @@ -64,6 +64,26 @@ describe('getAgentModel provider-aware fallback', () => { }) describe('Non-Claude-native providers', () => { + test.each(['haiku', 'sonnet', 'opus'])( + 'tool-specified %s alias inherits the Sol parent', + async alias => { + mock.module('./providers.js', () => ({ + getAPIProvider: () => 'firstParty', + isFirstPartyAnthropicBaseUrl: () => false, + })) + + const { getAgentModel } = await import('./agent.js') + const result = getAgentModel( + 'inherit', + 'gpt-5.6-sol', + alias, + 'plan', + ) + + expect(result).toBe('gpt-5.6-sol') + }, + ) + test('haiku alias inherits parent model for OpenAI provider', async () => { mock.module('./providers.js', () => ({ getAPIProvider: () => 'openai', @@ -258,4 +278,4 @@ describe('getAgentModel provider-aware fallback', () => { expect(checkIsClaudeNativeProvider()).toBe(false) }) }) -}) \ No newline at end of file +}) diff --git a/src/utils/model/agent.ts b/src/utils/model/agent.ts index 904873e534..8484bb3d97 100644 --- a/src/utils/model/agent.ts +++ b/src/utils/model/agent.ts @@ -1,6 +1,6 @@ import type { PermissionMode } from '../permissions/PermissionMode.js' import { capitalize } from '../stringUtils.js' -import { MODEL_ALIASES, type ModelAlias } from './aliases.js' +import { MODEL_ALIASES } from './aliases.js' import { applyBedrockRegionPrefix, getBedrockRegionPrefix } from './bedrock.js' import { getCanonicalName, @@ -37,7 +37,7 @@ export function getDefaultSubagentModel(): string { export function getAgentModel( agentModel: string | undefined, parentModel: string, - toolSpecifiedModel?: ModelAlias, + toolSpecifiedModel?: string, permissionMode?: PermissionMode, ): string { if (process.env.CLAUDE_CODE_SUBAGENT_MODEL) { @@ -71,6 +71,16 @@ export function getAgentModel( if (aliasMatchesParentTier(toolSpecifiedModel, parentModel)) { return parentModel } + if ( + isClaudeFamilyAlias(toolSpecifiedModel) && + !checkIsClaudeNativeProvider() + ) { + return getRuntimeMainLoopModel({ + permissionMode: permissionMode ?? 'default', + mainLoopModel: parentModel, + exceeds200kTokens: false, + }) + } const model = parseUserSpecifiedModel(toolSpecifiedModel) return applyParentRegionPrefix(model, toolSpecifiedModel) } @@ -82,11 +92,11 @@ export function getAgentModel( // have guaranteed haiku/sonnet model availability. Custom Anthropic-compatible // endpoints, OpenAI-shim, Gemini, Mistral, and other providers may not have // equivalent models, causing "model not found" errors when resolving aliases. - // For haiku/sonnet aliases on non-Claude-native providers, inherit parent model. - // Note: 'opus' is NOT included here because it's handled separately by - // aliasMatchesParentTier() which checks if parent's tier matches the alias. + // Claude family aliases cannot be resolved by non-Claude-native providers. + // Inherit the parent instead of sending a literal haiku/sonnet/opus ID that + // the provider will reject. if ( - (agentModelWithExp === 'haiku' || agentModelWithExp === 'sonnet') && + isClaudeFamilyAlias(agentModelWithExp) && !checkIsClaudeNativeProvider() ) { // Non-Claude-native provider → inherit parent model @@ -114,6 +124,15 @@ export function getAgentModel( return applyParentRegionPrefix(model, agentModelWithExp) } +function isClaudeFamilyAlias(model: string): boolean { + const normalized = model.trim().toLowerCase() + return ( + normalized === 'haiku' || + normalized === 'sonnet' || + normalized === 'opus' + ) +} + /** * Check if a bare family alias (opus/sonnet/haiku) matches the parent model's * tier. When it does, the subagent inherits the parent's exact model string diff --git a/src/utils/processUserInput/processSlashCommand.tsx b/src/utils/processUserInput/processSlashCommand.tsx index e03c71b876..a325213cc3 100644 --- a/src/utils/processUserInput/processSlashCommand.tsx +++ b/src/utils/processUserInput/processSlashCommand.tsx @@ -32,7 +32,6 @@ import { registerSkillHooks } from '../hooks/registerSkillHooks.js'; import { logError } from '../log.js'; import { enqueuePendingNotification } from '../messageQueueManager.js'; import { createCommandInputMessage, createSyntheticUserCaveatMessage, createSystemMessage, createUserInterruptionMessage, createUserMessage, formatCommandInputTags, isCompactBoundaryMessage, isSystemLocalCommandMessage, normalizeMessages, prepareUserContent } from '../messages.js'; -import type { ModelAlias } from '../model/aliases.js'; import { parseToolListFromCLI } from '../permissions/permissionSetup.js'; import { hasPermissionsToUseTool } from '../permissions/permissions.js'; import { isOfficialMarketplaceName, parsePluginIdentifier } from '../plugins/pluginIdentifier.js'; @@ -157,7 +156,7 @@ async function executeForkedSlashCommand(command: CommandBase & PromptCommand, a canUseTool, isAsync: true, querySource: 'agent:custom', - model: command.model as ModelAlias | undefined, + model: command.model, availableTools: freshTools, override: { agentId @@ -236,7 +235,7 @@ async function executeForkedSlashCommand(command: CommandBase & PromptCommand, a canUseTool, isAsync: false, querySource: 'agent:custom', - model: command.model as ModelAlias | undefined, + model: command.model, availableTools: context.options.tools })) { agentMessages.push(message); diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts index e739614a11..9f333a96f4 100644 --- a/src/utils/settings/types.ts +++ b/src/utils/settings/types.ts @@ -741,12 +741,41 @@ spinnerVerbs: z 'Example: { "deepseek-chat": { "base_url": "https://api.deepseek.com/v1", "api_key": "sk-xxx" } }', ), agentRouting: z - .record(z.string(), z.string()) + .record( + z.string(), + z.union([ + // Backwards-compatible form. When the value exists in agentModels, + // it routes to that external provider. Known profile names and + // available Verboo model IDs are resolved at runtime. + z.string().trim().min(1), + z + .object({ + profile: z.enum([ + 'fast', + 'review', + 'coding', + 'testing', + 'balanced', + ]), + fallback: z + .enum(['inherit', 'first-available']) + .optional(), + }) + .strict(), + z + .object({ + model: z.string().trim().min(1), + provider: z.literal('inherit').optional(), + }) + .strict(), + ]), + ) .optional() .describe( - 'Map of agent identifier (subagent_type or team member name) to model name. ' + - 'Use "default" key as fallback. Model name must exist in agentModels. ' + - 'Example: { "Explore": "deepseek-chat", "general-purpose": "gpt-4o", "default": "gpt-4o" }', + 'Map of agent identifier (subagent_type or team member name) to an external model, ' + + 'an available Verboo model, or a semantic profile. Use "default" as fallback. ' + + 'Examples: { "Explore": "fast", "worker-review": { "profile": "review" }, ' + + '"custom": { "model": "max/mimo-v2.5-pro", "provider": "inherit" } }', ), fastMode: z .boolean()