Skip to content
Open
10 changes: 10 additions & 0 deletions .changeset/dual-model-routing.md
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.
171 changes: 170 additions & 1 deletion apps/kimi-code/src/tui/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 &&

Copy link
Copy Markdown

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 performSubagentModelSwitch returns 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." and persistSubagentModelSelection is never reached; config.toml stays empty. The main-model flow handles exactly this case: performModelSwitch still persists on a runtime no-op and reports "Saved … as default." Suggest only short-circuiting when persist === false.

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 } : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Strip env-only aliases before saving subagent defaults

When KIMI_MODEL_* is active, the picker can include the synthetic __kimi_env_model__ alias, and this new persistence path writes that alias directly into default_subagent_model. Unlike defaultModel, the write path does not restore/drop env-injected subagent defaults, so choosing the env model here commits default_subagent_model = "__kimi_env_model__" to config.toml; after restarting without those env vars, delegated subagents resolve an unconfigured alias and fail. Please handle the env alias the same way the main default is stripped/restored before saving.

Useful? React with 👍 / 👎.

@kassieclaire kassieclaire Jul 21, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in 142292e. stripEnvModelConfig now mirrors the defaultModel handling for defaultSubagentModel: when it points at the env alias, the value is restored from config.raw['default_subagent_model'] (or dropped if raw has none) instead of being persisted. Covered by three new tests in env-model.test.ts (restore-from-raw, drop-when-no-raw, keep-non-alias).

...(effortChanged ? { defaultSubagentThinkingEffort: effort } : {}),
});
return true;
}

function showEditorPicker(host: SlashCommandHost): void {
const currentValue = host.state.appState.editorCommand ?? '';
host.mountEditorReplacement(
Expand Down Expand Up @@ -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 });
Expand Down
66 changes: 66 additions & 0 deletions apps/kimi-code/src/tui/components/chrome/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'] ?? '';
Expand Down Expand Up @@ -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 =

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The footer subagents: badge can never render the "inherits main model, different effort" state. The badge's stated contract (f9473d4) is that it shows when any of alias, provider, or effort differs, but both branches of isDistinct require subagentAlias !== undefined, and subagentModelDisplayName returns '' for an undefined alias. This state is reachable purely from config: with default_subagent_thinking_effort = "high" and no default_subagent_model, Session.getSubagentThinkingEffort() falls back to the config default independently of the model (session/index.ts:638-640), so subagents genuinely run the main model at high, yet no badge ever renders. The scope picker already acknowledges this state with its "(inherits main model, effort: high)" label.

Related: the "same alias, different provider" clause at line 343 is dead code, since an identical alias keys into the same availableModels entry and the providers can't differ there; the provider prefix only ever fires on the different-alias branch.

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.
Expand Down
78 changes: 78 additions & 0 deletions apps/kimi-code/src/tui/components/dialogs/model-scope-selector.ts
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,
});
}
}
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from './dialogs/compaction';
export * from './dialogs/editor-selector';
export * from './dialogs/experiments-selector';
export * from './dialogs/help-panel';
export * from './dialogs/model-scope-selector';
export * from './dialogs/model-selector';
export * from './dialogs/permission-selector';
export * from './dialogs/question-dialog';
Expand Down
Loading