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 &&
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 =
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