-
Notifications
You must be signed in to change notification settings - Fork 679
feat(agent-core): dual-model-routing — separate subagent model and thinking effort #1996
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3f47332
6a07fe8
86e052d
142292e
36c6f97
f9473d4
3ebed81
30f7418
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| --- | ||
| "@moonshot-ai/kimi-code": minor | ||
| "@moonshot-ai/kimi-code-sdk": minor | ||
| --- | ||
|
|
||
| Add the `dual-model-routing` experimental feature: route the main agent and its subagents to different models. | ||
|
|
||
| When the feature is enabled (via `/experiments`, `KIMI_CODE_EXPERIMENTAL_DUAL_MODEL_ROUTING`, or the master `KIMI_CODE_EXPERIMENTAL_FLAG` switch), subagents use a dedicated subagent model instead of inheriting the main agent's model. The subagent model defaults to the new `default_subagent_model` config field and can be switched live via `/model`, which now opens a scope picker (Main agent / Subagents) when the feature is active. The footer status bar shows both the main and subagent models while the feature is on. Subagent thinking effort is also separable: it defaults to the new `default_subagent_thinking_effort` config field and can be switched live via the same `/model` scope picker (the chosen effort is persisted alongside the model). Note that the dedicated model and effort apply to delegated subagents only — the side-question (`/btw`) agent always inherits the main agent's model and effort, because it replays the main agent's prompt prefix and shares its prefix cache, so routing it elsewhere would re-process the whole conversation on a cold cache. | ||
|
|
||
| When the feature is disabled, all UI and runtime behavior is unchanged — subagents inherit the parent model as before, the footer shows only the main model, and `/model` keeps its singular behavior. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,7 @@ import { | |
| type ExperimentalFeatureDraftChange, | ||
| } from '../components/dialogs/experiments-selector'; | ||
| import { modelDisplayName, segmentsFor } from '../components/dialogs/model-selector'; | ||
| import { ModelScopeSelectorComponent, type ModelScope } from '../components/dialogs/model-scope-selector'; | ||
| import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector'; | ||
| import { PermissionSelectorComponent } from '../components/dialogs/permission-selector'; | ||
| import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector'; | ||
|
|
@@ -26,7 +27,7 @@ import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; | |
| import { formatErrorMessage } from '../utils/event-payload'; | ||
| import { thinkingEffortToConfig } from '../utils/thinking-config'; | ||
| import { showUsage } from './info'; | ||
| import { setExperimentalFeatures } from './experimental-flags'; | ||
| import { isExperimentalFlagEnabled, setExperimentalFeatures } from './experimental-flags'; | ||
| import type { SlashCommandHost } from './dispatch'; | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
|
|
@@ -239,6 +240,12 @@ export async function handleThemeCommand(host: SlashCommandHost, args: string): | |
| export async function handleModelCommand(host: SlashCommandHost, args: string): Promise<void> { | ||
| const alias = args.trim(); | ||
| await refreshModelsForPicker(host); | ||
| // When dual-model routing is active and no explicit alias is given, let the | ||
| // user choose which scope (main agent / subagents) to configure first. | ||
| if (alias.length === 0 && isExperimentalFlagEnabled('dual-model-routing')) { | ||
| showModelScopePicker(host); | ||
| return; | ||
| } | ||
| if (alias.length === 0) { | ||
| showModelPicker(host); | ||
| return; | ||
|
|
@@ -314,6 +321,155 @@ function showEffortPicker( | |
| // Pickers & config apply | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| /** | ||
| * Show the model-scope picker (Main agent / Subagents) used when the | ||
| * `dual-model-routing` experimental feature is active. Selecting a scope | ||
| * opens the matching model picker. | ||
| */ | ||
| function showModelScopePicker(host: SlashCommandHost): void { | ||
| host.mountEditorReplacement( | ||
| new ModelScopeSelectorComponent({ | ||
| currentModel: host.state.appState.model, | ||
| currentSubagentModel: host.state.appState.subagentModel, | ||
| currentSubagentThinkingEffort: host.state.appState.subagentThinkingEffort, | ||
| availableModels: host.state.appState.availableModels, | ||
| onSelect: (scope: ModelScope) => { | ||
| host.restoreEditor(); | ||
| if (scope === 'subagent') { | ||
| showSubagentModelPicker(host); | ||
| } else { | ||
| showModelPicker(host); | ||
| } | ||
| }, | ||
| onCancel: () => { | ||
| host.restoreEditor(); | ||
| }, | ||
| }), | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Show the model picker for the subagent scope (`dual-model-routing` | ||
| * experimental feature). Reuses the tabbed model selector; the selected | ||
| * alias and thinking effort are applied via `performSubagentModelSwitch`. | ||
| */ | ||
| function showSubagentModelPicker(host: SlashCommandHost): void { | ||
| const models = Object.fromEntries( | ||
| Object.entries(host.state.appState.availableModels).map(([alias, model]) => [ | ||
| alias, | ||
| effectiveModelForHost(host, model), | ||
| ]), | ||
| ); | ||
| const entries = Object.entries(models); | ||
| if (entries.length === 0) { | ||
| host.showNotice( | ||
| 'No models configured', | ||
| 'Run /login to sign in to Kimi, or /provider to add another provider from a model catalog.', | ||
| ); | ||
| return; | ||
| } | ||
| host.mountEditorReplacement( | ||
| new TabbedModelSelectorComponent({ | ||
| models, | ||
| currentValue: host.state.appState.subagentModel ?? host.state.appState.model, | ||
| currentThinkingEffort: host.state.appState.subagentThinkingEffort ?? host.state.appState.thinkingEffort, | ||
| // A subagent-model switch does not invalidate the MAIN conversation's | ||
| // prompt cache (only caches of subagents spawned after the change), so | ||
| // the standard cache warning does not apply here. | ||
| warning: undefined, | ||
| onSelect: ({ alias, thinking }) => { | ||
| host.restoreEditor(); | ||
| void performSubagentModelSwitch(host, alias, thinking, true); | ||
| }, | ||
| onSessionOnlySelect: ({ alias, thinking }) => { | ||
| host.restoreEditor(); | ||
| void performSubagentModelSwitch(host, alias, thinking, false); | ||
| }, | ||
| onCancel: () => { | ||
| host.restoreEditor(); | ||
| }, | ||
| }), | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Apply a subagent model selection. Pushes the live session override (both | ||
| * model and thinking effort) and, when persisting, writes `defaultSubagentModel` | ||
| * and `defaultSubagentThinkingEffort` to config.toml. | ||
| */ | ||
| async function performSubagentModelSwitch( | ||
| host: SlashCommandHost, | ||
| alias: string, | ||
| effort: string, | ||
| persist: boolean, | ||
| ): Promise<void> { | ||
| if (host.state.appState.streamingPhase !== 'idle') { | ||
| host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.'); | ||
| return; | ||
| } | ||
|
|
||
| // No-op guard: skip the RPC round-trips and status flash when neither the | ||
| // alias nor the effort actually changed. | ||
| if ( | ||
| alias === host.state.appState.subagentModel && | ||
| effort === host.state.appState.subagentThinkingEffort | ||
| ) { | ||
| const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]); | ||
| const effortSuffix = effort.length > 0 ? ` (effort: ${effort})` : ''; | ||
| host.showStatus(`Already using ${displayName}${effortSuffix} for subagents.`, 'success'); | ||
| return; | ||
| } | ||
|
|
||
| const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]); | ||
| const session = host.session; | ||
| try { | ||
| if (session !== undefined) { | ||
| await session.setSubagentModel(alias); | ||
| await session.setSubagentThinkingEffort(effort); | ||
| } | ||
| } catch (error) { | ||
| host.showError(`Failed to switch subagent model: ${formatErrorMessage(error)}`); | ||
| return; | ||
| } | ||
|
|
||
| host.setAppState({ subagentModel: alias, subagentThinkingEffort: effort }); | ||
|
|
||
| let persisted = false; | ||
| if (persist) { | ||
| try { | ||
| persisted = await persistSubagentModelSelection(host, alias, effort); | ||
| } catch (error) { | ||
| host.showError(`Switched subagents to ${displayName}, but failed to save default: ${formatErrorMessage(error)}`); | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| const effortSuffix = effort.length > 0 ? ` (effort: ${effort})` : ''; | ||
| const status = persist | ||
| ? persisted | ||
| ? `Saved ${displayName}${effortSuffix} as the default subagent model.` | ||
| : `Subagent model set to ${displayName}${effortSuffix}.` | ||
| : `Subagent model set to ${displayName}${effortSuffix} for this session only.`; | ||
| host.showStatus(status, 'success'); | ||
| host.track('subagent_model_switch', { model: alias, effort, persist }); | ||
| } | ||
|
|
||
| async function persistSubagentModelSelection( | ||
| host: SlashCommandHost, | ||
| alias: string, | ||
| effort: string, | ||
| ): Promise<boolean> { | ||
| const config = await host.harness.getConfig({ reload: true }); | ||
| const modelChanged = config.defaultSubagentModel !== alias; | ||
| const effortChanged = config.defaultSubagentThinkingEffort !== effort; | ||
| if (!modelChanged && !effortChanged) return false; | ||
| await host.harness.setConfig({ | ||
| ...(modelChanged ? { defaultSubagentModel: alias } : {}), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. fixed in 142292e. |
||
| ...(effortChanged ? { defaultSubagentThinkingEffort: effort } : {}), | ||
| }); | ||
| return true; | ||
| } | ||
|
|
||
| function showEditorPicker(host: SlashCommandHost): void { | ||
| const currentValue = host.state.appState.editorCommand ?? ''; | ||
| host.mountEditorReplacement( | ||
|
|
@@ -674,12 +830,25 @@ export async function applyExperimentalFeatureChanges( | |
| host.refreshSlashCommandAutocomplete(); | ||
| host.restoreEditor(); | ||
| if (host.session !== undefined) { | ||
| // A live session re-syncs appState (including subagent model/effort) | ||
| // via reloadSession + reloadCurrentSessionView, so no manual sync here. | ||
| await host.session.reloadSession(); | ||
| await host.reloadCurrentSessionView( | ||
| host.session, | ||
| 'Experimental features updated. Session reloaded.', | ||
| ); | ||
| } else { | ||
| // No session to reload — manually sync the subagent-model app state | ||
| // from config so the footer and /model scope picker reflect the flag. | ||
| const config = await host.harness.getConfig({ reload: true }); | ||
| host.setAppState({ | ||
| subagentModel: isExperimentalFlagEnabled('dual-model-routing') | ||
| ? (config.defaultSubagentModel ?? undefined) | ||
| : undefined, | ||
| subagentThinkingEffort: isExperimentalFlagEnabled('dual-model-routing') | ||
| ? (config.defaultSubagentThinkingEffort ?? undefined) | ||
| : undefined, | ||
| }); | ||
| host.showStatus('Experimental features updated.', 'success'); | ||
| } | ||
| host.track('experimental_features_apply', { changed: changes.length }); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,7 @@ import { effectiveModelAlias } from '@moonshot-ai/kimi-code-sdk'; | |
|
|
||
| import { ALL_TIPS, type ToolbarTip } from '#/tui/constant/tips'; | ||
| import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/dance'; | ||
| import { isExperimentalFlagEnabled } from '#/tui/commands/experimental-flags'; | ||
| import { currentTheme } from '#/tui/theme'; | ||
| import type { ColorPalette } from '#/tui/theme/colors'; | ||
| import type { AppState } from '#/tui/types'; | ||
|
|
@@ -141,6 +142,38 @@ function modelDisplayName(state: AppState): string { | |
| return effective?.displayName ?? effective?.model ?? state.model; | ||
| } | ||
|
|
||
| /** | ||
| * Display name for the subagent model (`dual-model-routing` experimental | ||
| * feature). Returns the empty string when no subagent model is set, so the | ||
| * caller can skip rendering it. | ||
| * | ||
| * When the subagent alias matches the main model but the provider differs | ||
| * (e.g. the same model served by a different provider), the provider id is | ||
| * prepended so the user can tell the two apart at a glance. | ||
| */ | ||
| function subagentModelDisplayName(state: AppState): string { | ||
| const alias = state.subagentModel; | ||
| if (alias === undefined || alias.length === 0) return ''; | ||
| const model = state.availableModels[alias]; | ||
| const effective = model === undefined ? undefined : effectiveModelAlias(model); | ||
| const base = effective?.displayName ?? effective?.model ?? alias; | ||
| // Prepend the provider when the subagent alias matches the main model but | ||
| // the underlying provider differs (same model, different route). | ||
| const mainModel = state.availableModels[state.model]; | ||
| const mainProvider = mainModel === undefined ? undefined : effectiveModelAlias(mainModel).provider; | ||
| const subProvider = effective?.provider; | ||
| // Prepend the provider when it differs from the main model's provider, | ||
| // so the user can distinguish two routes that serve the same model name | ||
| // (e.g. the same kimi-k3 via managed:kimi-code vs opencode-go). | ||
| const providerPrefix = | ||
| subProvider !== undefined && subProvider !== mainProvider ? `${subProvider}/` : ''; | ||
| // Append the thinking-effort suffix when a separate effort is set for | ||
| // subagents (e.g. "GLM-5.2 · high"). | ||
| const effort = state.subagentThinkingEffort; | ||
| if (effort !== undefined && effort.length > 0) return `${providerPrefix}${base} · ${effort}`; | ||
| return `${providerPrefix}${base}`; | ||
| } | ||
|
|
||
| function shortenCwd(path: string): string { | ||
| if (!path) return path; | ||
| const home = process.env['HOME'] ?? ''; | ||
|
|
@@ -284,6 +317,39 @@ export class FooterComponent implements Component { | |
| left.push(renderedModelLabel); | ||
| } | ||
|
|
||
| // Subagent model badge — shown when the `dual-model-routing` experimental | ||
| // feature is active and the subagent's effective settings differ from the | ||
| // main agent's. Three dimensions of distinctness, any one of which makes | ||
| // the badge relevant: (1) a different model alias, (2) the same alias on a | ||
| // different provider (e.g. the same model served elsewhere), or (3) a | ||
| // different thinking effort. When all three match the main agent there is | ||
| // nothing to surface. The flag check is defense-in-depth: | ||
| // getSubagentModel() / getStatus also gate on the flag, so | ||
| // appState.subagentModel should already be undefined when the feature is off. | ||
| const subagentAlias = state.subagentModel; | ||
| const subagentEffort = state.subagentThinkingEffort; | ||
| const subagentEntry = | ||
| subagentAlias !== undefined ? state.availableModels[subagentAlias] : undefined; | ||
| const subagentProvider = subagentEntry | ||
| ? effectiveModelAlias(subagentEntry).provider | ||
| : undefined; | ||
| const mainEntry = state.availableModels[state.model]; | ||
| const mainProvider = mainEntry ? effectiveModelAlias(mainEntry).provider : undefined; | ||
| const isDistinct = | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The footer Related: the "same alias, different provider" clause at line 343 is dead code, since an identical alias keys into the same |
||
| subagentAlias !== undefined && | ||
| subagentAlias !== state.model | ||
| ? true // different alias → always distinct | ||
| : subagentAlias !== undefined && | ||
| (subagentProvider !== mainProvider || // same alias, different provider | ||
| (subagentEffort !== undefined && subagentEffort !== state.thinkingEffort)); // same alias, different effort | ||
| const subagentLabel = | ||
| isExperimentalFlagEnabled('dual-model-routing') && isDistinct | ||
| ? subagentModelDisplayName(state) | ||
| : ''; | ||
| if (subagentLabel.length > 0) { | ||
| left.push(chalk.hex(colors.textDim)('subagents:') + ' ' + chalk.hex(colors.text)(subagentLabel)); | ||
| } | ||
|
|
||
| // Background-task badges sit immediately before cwd. `bash-*` tasks | ||
| // (shell processes) and `agent-*` tasks (background subagents) get | ||
| // separate badges so the user can distinguish them at a glance. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| /** | ||
| * ModelScopeSelectorComponent — a small picker that lets the user choose | ||
| * which model scope to configure when the `dual-model-routing` experimental | ||
| * feature is active: | ||
| * | ||
| * - "main" → configure the main agent's model | ||
| * - "subagent" → configure the subagent model (used by spawned subagents) | ||
| * | ||
| * It is a thin wrapper around ChoicePickerComponent, mirroring | ||
| * PermissionSelectorComponent. Mounted via `mountEditorReplacement`. | ||
| */ | ||
|
|
||
| import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; | ||
|
|
||
| import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; | ||
| import { modelDisplayName } from './model-selector'; | ||
|
|
||
| export type ModelScope = 'main' | 'subagent'; | ||
|
|
||
| export interface ModelScopeSelectorOptions { | ||
| /** Current main-agent model alias. */ | ||
| readonly currentModel: string; | ||
| /** Current subagent model alias, or undefined when unset. */ | ||
| readonly currentSubagentModel: string | undefined; | ||
| /** Current subagent thinking effort, or undefined when unset. */ | ||
| readonly currentSubagentThinkingEffort: string | undefined; | ||
| /** Catalog of available models (alias → definition) for display names. */ | ||
| readonly availableModels: Record<string, ModelAlias>; | ||
| readonly onSelect: (scope: ModelScope) => void; | ||
| readonly onCancel: () => void; | ||
| } | ||
|
|
||
| function isModelScope(value: string): value is ModelScope { | ||
| return value === 'main' || value === 'subagent'; | ||
| } | ||
|
|
||
| export class ModelScopeSelectorComponent extends ChoicePickerComponent { | ||
| constructor(opts: ModelScopeSelectorOptions) { | ||
| const mainName = modelDisplayName(opts.currentModel, opts.availableModels[opts.currentModel]); | ||
| const effort = opts.currentSubagentThinkingEffort; | ||
| const hasEffort = effort !== undefined && effort.length > 0; | ||
| let subagentName: string; | ||
| if (opts.currentSubagentModel !== undefined && opts.currentSubagentModel.length > 0) { | ||
| const model = modelDisplayName( | ||
| opts.currentSubagentModel, | ||
| opts.availableModels[opts.currentSubagentModel], | ||
| ); | ||
| subagentName = hasEffort ? `${model} (effort: ${effort})` : model; | ||
| } else { | ||
| // The model is inherited, but a configured subagent effort still | ||
| // applies — show it so the label reflects the effective settings. | ||
| subagentName = hasEffort ? `(inherits main model, effort: ${effort})` : '(inherits main model)'; | ||
| } | ||
|
|
||
| const options: readonly ChoiceOption[] = [ | ||
| { | ||
| value: 'main', | ||
| label: `Main agent — ${mainName}`, | ||
| description: 'The model that runs your conversation and owns the main turn.', | ||
| }, | ||
| { | ||
| value: 'subagent', | ||
| label: `Subagents — ${subagentName}`, | ||
| description: | ||
| 'The model used by delegated subagents (task, explore, swarm). When set, subagents run on this model instead of the main model.', | ||
| }, | ||
| ]; | ||
|
|
||
| super({ | ||
| title: 'Select model scope', | ||
| options, | ||
| onSelect: (value) => { | ||
| if (isModelScope(value)) opts.onSelect(value); | ||
| }, | ||
| onCancel: opts.onCancel, | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The no-op guard in
performSubagentModelSwitchreturns before the persist block, so a session-only choice can never be promoted to a saved default. Repro: pick a subagent model "session only", then re-open/model→ Subagents → same model/effort → "persist as default". The guard (alias === appState.subagentModel && effort === appState.subagentThinkingEffort) is true, so it prints "Already using … for subagents." andpersistSubagentModelSelectionis never reached; config.toml stays empty. The main-model flow handles exactly this case:performModelSwitchstill persists on a runtime no-op and reports "Saved … as default." Suggest only short-circuiting whenpersist === false.