From 3f473324e5d1942209ddc5743c88c740f129c39e Mon Sep 17 00:00:00 2001 From: Kassie Povinelli Date: Mon, 20 Jul 2026 19:50:17 -0500 Subject: [PATCH 1/7] feat(agent-core): dual-model-routing experimental feature for separate subagent models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the dual-model-routing experimental flag so the main agent and its subagents can run on different models (e.g. Kimi K3 for the main agent, GLM-5.2 for subagents). When enabled (default off), subagents use a dedicated subagent model instead of inheriting the parent agent model: - Config: new default_subagent_model field (config.toml + patch schema) - Runtime: SessionSubagentHost resolves the subagent model from the live session override or config default, gated by the flag; all four subagent creation paths (spawn, resume, retry, btw) honor it - RPC: Session-level setSubagentModel/getSubagentModel added to the SessionAPI surface; SessionStatus carries subagentModel - SDK: Session.setSubagentModel + SessionStatus.subagentModel exposed - TUI: /model opens a scope picker (Main agent / Subagents) when the flag is on; the footer shows both models; state is hydrated on login, resume, reload, and experiment toggles When disabled, all UI and runtime behavior is unchanged — subagents inherit the parent model, the footer shows one model, and /model keeps its singular behavior. --- .changeset/dual-model-routing.md | 10 ++ apps/kimi-code/src/tui/commands/config.ts | 143 +++++++++++++++++- .../src/tui/components/chrome/footer.ts | 25 +++ .../dialogs/model-scope-selector.ts | 69 +++++++++ apps/kimi-code/src/tui/components/index.ts | 1 + .../src/tui/controllers/auth-flow.ts | 4 + apps/kimi-code/src/tui/kimi-tui.ts | 2 + apps/kimi-code/src/tui/types.ts | 6 + .../test/tui/commands/experiments.test.ts | 3 + .../test/tui/components/chrome/footer.test.ts | 60 ++++++++ docs/en/configuration/config-files.md | 1 + docs/en/configuration/env-vars.md | 1 + packages/agent-core/src/config/schema.ts | 8 + packages/agent-core/src/config/toml.ts | 1 + packages/agent-core/src/flags/registry.ts | 9 ++ packages/agent-core/src/rpc/core-api.ts | 19 +++ packages/agent-core/src/rpc/core-impl.ts | 17 +++ packages/agent-core/src/session/index.ts | 30 ++++ packages/agent-core/src/session/rpc.ts | 13 ++ .../agent-core/src/session/subagent-host.ts | 29 +++- .../agent-core/test/config/configs.test.ts | 14 ++ .../agent-core/test/flags/resolver.test.ts | 11 ++ .../test/session/subagent-host.test.ts | 3 + packages/node-sdk/src/rpc.ts | 35 +++++ packages/node-sdk/src/session.ts | 10 ++ packages/node-sdk/src/types.ts | 1 + 26 files changed, 519 insertions(+), 6 deletions(-) create mode 100644 .changeset/dual-model-routing.md create mode 100644 apps/kimi-code/src/tui/components/dialogs/model-scope-selector.ts diff --git a/.changeset/dual-model-routing.md b/.changeset/dual-model-routing.md new file mode 100644 index 0000000000..f5a0e1c04e --- /dev/null +++ b/.changeset/dual-model-routing.md @@ -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. + +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. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 58f1c27acb..a02455413c 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -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 { 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,131 @@ 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, + 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 is applied via `performSubagentModelSwitch`. Thinking effort is + * carried through for parity with the main picker but is not separately + * persisted for subagents. + */ +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.thinkingEffort, + warning: hasConversationHistory(host) ? MODEL_SWITCH_CACHE_WARNING : undefined, + onSelect: ({ alias }) => { + host.restoreEditor(); + void performSubagentModelSwitch(host, alias, true); + }, + onSessionOnlySelect: ({ alias }) => { + host.restoreEditor(); + void performSubagentModelSwitch(host, alias, false); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +/** + * Apply a subagent model selection. Pushes the live session override and, + * when persisting, writes `defaultSubagentModel` to config.toml. + */ +async function performSubagentModelSwitch( + host: SlashCommandHost, + alias: string, + persist: boolean, +): Promise { + if (host.state.appState.streamingPhase !== 'idle') { + host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.'); + return; + } + + const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]); + const session = host.session; + try { + if (session !== undefined) { + await session.setSubagentModel(alias); + } + } catch (error) { + host.showError(`Failed to switch subagent model: ${formatErrorMessage(error)}`); + return; + } + + host.setAppState({ subagentModel: alias }); + + let persisted = false; + if (persist) { + try { + persisted = await persistSubagentModelSelection(host, alias); + } catch (error) { + host.showError(`Switched subagents to ${displayName}, but failed to save default: ${formatErrorMessage(error)}`); + return; + } + } + + const status = persist + ? persisted + ? `Saved ${displayName} as the default subagent model.` + : `Subagent model set to ${displayName}.` + : `Subagent model set to ${displayName} for this session only.`; + host.showStatus(status, 'success'); + host.track('subagent_model_switch', { model: alias, persist }); +} + +async function persistSubagentModelSelection( + host: SlashCommandHost, + alias: string, +): Promise { + const config = await host.harness.getConfig({ reload: true }); + if (config.defaultSubagentModel === alias) return false; + await host.harness.setConfig({ defaultSubagentModel: alias }); + return true; +} + function showEditorPicker(host: SlashCommandHost): void { const currentValue = host.state.appState.editorCommand ?? ''; host.mountEditorReplacement( @@ -673,6 +805,15 @@ export async function applyExperimentalFeatureChanges( setExperimentalFeatures(features); host.refreshSlashCommandAutocomplete(); host.restoreEditor(); + // Sync the subagent-model app state with the flag: load it from config + // when dual-model routing is on, clear it when off so the footer and + // /model scope picker reflect the feature's actual state. + const config = await host.harness.getConfig({ reload: true }); + host.setAppState({ + subagentModel: isExperimentalFlagEnabled('dual-model-routing') + ? (config.defaultSubagentModel ?? undefined) + : undefined, + }); if (host.session !== undefined) { await host.session.reloadSession(); await host.reloadCurrentSessionView( diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index a2e8d7adee..54c1898f38 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -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,19 @@ 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. + */ +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); + return effective?.displayName ?? effective?.model ?? alias; +} + function shortenCwd(path: string): string { if (!path) return path; const home = process.env['HOME'] ?? ''; @@ -284,6 +298,17 @@ export class FooterComponent implements Component { left.push(renderedModelLabel); } + // Subagent model badge — only shown when the `dual-model-routing` + // experimental feature is active and a distinct subagent model is set. + // 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 subagentLabel = + isExperimentalFlagEnabled('dual-model-routing') ? 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. diff --git a/apps/kimi-code/src/tui/components/dialogs/model-scope-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-scope-selector.ts new file mode 100644 index 0000000000..bc4e8a30b2 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/model-scope-selector.ts @@ -0,0 +1,69 @@ +/** + * 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; + /** Catalog of available models (alias → definition) for display names. */ + readonly availableModels: Record; + 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 subagentName = + opts.currentSubagentModel !== undefined && opts.currentSubagentModel.length > 0 + ? modelDisplayName( + opts.currentSubagentModel, + opts.availableModels[opts.currentSubagentModel], + ) + : '(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, + }); + } +} diff --git a/apps/kimi-code/src/tui/components/index.ts b/apps/kimi-code/src/tui/components/index.ts index 52f6c052e7..b4a5de44c3 100644 --- a/apps/kimi-code/src/tui/components/index.ts +++ b/apps/kimi-code/src/tui/components/index.ts @@ -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'; diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index a7acc77ce8..820c84a7c4 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -11,6 +11,7 @@ import { type RefreshResult, } from '../utils/refresh-providers'; import { thinkingEffortFromConfig } from '../utils/thinking-config'; +import { isExperimentalFlagEnabled } from '../commands/experimental-flags'; import type { SessionEventHandler } from './session-event-handler'; import type { AppState, KimiTUIOptions } from '../types'; import type { TUIState } from '../tui-state'; @@ -134,6 +135,9 @@ export class AuthFlowController { availableProviders, model: defaultModel, maxContextTokens: selected.maxContextSize, + subagentModel: isExperimentalFlagEnabled('dual-model-routing') + ? (config.defaultSubagentModel ?? undefined) + : undefined, }; host.setAppState(appStatePatch); } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 9d03f9395b..2ec720b85b 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -203,6 +203,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { : 'manual'; return { model: '', + subagentModel: undefined, workDir: input.workDir, additionalDirs: [...(input.additionalDirs ?? [])], sessionId: '', @@ -1536,6 +1537,7 @@ export class KimiTUI { this.setAppState({ sessionId: session.id, model: status.model ?? '', + subagentModel: status.subagentModel, thinkingEffort: status.thinkingEffort, permissionMode: status.permission, planMode: status.planMode, diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index d895b11e12..73c3f0ac4b 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -26,6 +26,12 @@ export interface BannerState { export interface AppState { model: string; + /** + * Live subagent model alias (`dual-model-routing` experimental feature). + * Undefined when the feature is off or no subagent model is set (subagents + * then inherit the main model). Keyed into `availableModels`. + */ + subagentModel?: string; workDir: string; additionalDirs: readonly string[]; sessionId: string; diff --git a/apps/kimi-code/test/tui/commands/experiments.test.ts b/apps/kimi-code/test/tui/commands/experiments.test.ts index 89bf62da6c..80939c0db9 100644 --- a/apps/kimi-code/test/tui/commands/experiments.test.ts +++ b/apps/kimi-code/test/tui/commands/experiments.test.ts @@ -39,6 +39,7 @@ function makeHost() { }, harness: { setConfig: vi.fn(async () => ({ providers: {} })), + getConfig: vi.fn(async () => ({ providers: {} })), getExperimentalFeatures: vi.fn(async () => [ feature({ enabled: false, source: 'config', configValue: false }), ]), @@ -50,10 +51,12 @@ function makeHost() { restoreEditor: vi.fn(), showStatus: vi.fn(), showError: vi.fn(), + setAppState: vi.fn(), track: vi.fn(), } as unknown as SlashCommandHost & { harness: { setConfig: ReturnType; + getConfig: ReturnType; getExperimentalFeatures: ReturnType; }; refreshSlashCommandAutocomplete: ReturnType; diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index 2fe6f3e52e..3e66db7cb6 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -2,6 +2,7 @@ import chalk from 'chalk'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; +import { setExperimentalFeatures } from '#/tui/commands/experimental-flags'; import { setRainbowDance, type RainbowDanceController } from '#/tui/easter-eggs/dance'; import { currentTheme, darkColors, lightColors } from '#/tui/theme'; import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; @@ -187,3 +188,62 @@ describe('FooterComponent displayName override', () => { expect(footer.render(120).join('\n')).not.toContain('Remote Name'); }); }); + +describe('FooterComponent subagent model badge', () => { + afterEach(() => { + setExperimentalFeatures([]); + }); + + it('hides the subagent badge when the dual-model-routing flag is off', () => { + setExperimentalFeatures([]); + const state: AppState = { + ...appState, + model: 'kimi-k3', + subagentModel: 'glm-5.2', + availableModels: { + 'kimi-k3': { provider: 'managed:kimi-code', model: 'kimi-k3', maxContextSize: 262144 }, + 'glm-5.2': { provider: 'zai', model: 'glm-5.2', maxContextSize: 131072, displayName: 'GLM-5.2' }, + }, + }; + const footer = new FooterComponent(state); + const rendered = footer.render(140).join('\n'); + + // Even with a subagentModel set, the badge is hidden when the flag is off. + expect(rendered).not.toContain('subagents:'); + }); + + it('hides the subagent badge when no subagent model is set', () => { + setExperimentalFeatures([{ id: 'dual-model-routing', enabled: true }]); + const footer = new FooterComponent(appState); + const rendered = footer.render(120).join('\n'); + + expect(rendered).not.toContain('subagents:'); + }); + + it('shows the subagent model when the flag is on and a distinct model is set', () => { + setExperimentalFeatures([{ id: 'dual-model-routing', enabled: true }]); + const state: AppState = { + ...appState, + model: 'kimi-k3', + subagentModel: 'glm-5.2', + availableModels: { + 'kimi-k3': { + provider: 'managed:kimi-code', + model: 'kimi-k3', + maxContextSize: 262144, + }, + 'glm-5.2': { + provider: 'zai', + model: 'glm-5.2', + maxContextSize: 131072, + displayName: 'GLM-5.2', + }, + }, + }; + const footer = new FooterComponent(state); + const rendered = footer.render(140).join('\n'); + + expect(rendered).toContain('subagents:'); + expect(rendered).toContain('GLM-5.2'); + }); +}); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 141d021fa0..ff0ad440b6 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -98,6 +98,7 @@ Fields in the config file fall into two categories: **top-level scalars** that d | Field | Type | Default | Description | | --- | --- | --- | --- | | `default_model` | `string` | — | Default model alias; must be defined in `models` | +| `default_subagent_model` | `string` | — | Default model alias for subagents (experimental `dual-model-routing` feature). When set and the feature is enabled, delegated subagents run on this model instead of inheriting `default_model` | | `default_permission_mode` | `string` | `manual` | Default permission mode for new sessions; one of `manual` (prompt each time), `yolo` (auto-approve tool actions, but the agent may still ask questions), or `auto` (fully autonomous — the agent decides everything without asking) | | `default_plan_mode` | `boolean` | `false` | Whether new sessions start in Plan mode (produce a plan before executing) by default | | `merge_all_available_skills` | `boolean` | `true` | Whether to merge Agent Skills from all available directories | diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index c8a9b12a54..aea23d726b 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -128,6 +128,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | Cap how many AgentSwarm subagents run concurrently during the initial ramp; leave unset for no cap | Positive integer; invalid values fail fast | | `KIMI_SUBAGENT_TIMEOUT_MS` | Maximum wall-clock time (ms) a single subagent (`Agent` / `AgentSwarm`) may run; takes higher priority than `[subagent] timeout_ms` in `config.toml` (default `7200000`, i.e. 2 hours) | Positive integer; invalid values fall back to the config or default | | `KIMI_CODE_EXPERIMENTAL_FLAG` | Enable all registered experimental features for this process | `1`, `true`, `yes`, `on` | +| `KIMI_CODE_EXPERIMENTAL_DUAL_MODEL_ROUTING` | Enable dual model routing (experimental): route the main agent and its subagents to different models. When on, subagents use the `default_subagent_model` (configurable via `/model`) instead of inheriting the main model | `1`, `true`, `yes`, `on` | | `KIMI_SHELL_PATH` | Override the Git Bash path on Windows (used when auto-detection fails) | Absolute path | | `KIMI_MODEL_MAX_COMPLETION_TOKENS` | Hard cap on `max_completion_tokens` per LLM step; applies to the `kimi` provider only | Positive integer; `0` or negative disables clamping | | `KIMI_MODEL_TEMPERATURE` | Sampling temperature for every request; applies to the `kimi` provider only (global — independent of `KIMI_MODEL_NAME`) | Number, e.g. `0.3` | diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 06e7c9d307..2b41084724 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -291,6 +291,13 @@ export const KimiConfigSchema = z.object({ providers: z.record(z.string(), ProviderConfigSchema).default({}), defaultProvider: z.string().optional(), defaultModel: z.string().optional(), + /** + * Default model alias used by subagents when the `dual-model-routing` + * experimental flag is enabled. When unset, subagents fall back to + * `defaultModel` (the main-agent model). Only takes effect while the + * flag is on; otherwise subagents inherit the parent's live model. + */ + defaultSubagentModel: z.string().optional(), models: z.record(z.string(), ModelAliasSchema).optional(), thinking: ThinkingConfigSchema.optional(), planMode: z.boolean().optional(), @@ -335,6 +342,7 @@ export const KimiConfigPatchSchema = z providers: z.record(z.string(), ProviderConfigPatchSchema).optional(), defaultProvider: z.string().optional(), defaultModel: z.string().optional(), + defaultSubagentModel: z.string().optional(), models: z.record(z.string(), ModelAliasPatchSchema).optional(), thinking: ThinkingConfigPatchSchema.optional(), planMode: z.boolean().optional(), diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index edee21a041..e330668e78 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -477,6 +477,7 @@ export function configToTomlData(config: KimiConfig): Record { const scalarFields: (keyof KimiConfig)[] = [ 'defaultProvider', 'defaultModel', + 'defaultSubagentModel', 'planMode', 'yolo', 'defaultPermissionMode', diff --git a/packages/agent-core/src/flags/registry.ts b/packages/agent-core/src/flags/registry.ts index dbec75b80b..78ff1bb102 100644 --- a/packages/agent-core/src/flags/registry.ts +++ b/packages/agent-core/src/flags/registry.ts @@ -32,6 +32,15 @@ export const FLAG_DEFINITIONS = [ default: false, surface: 'core', }, + { + id: 'dual-model-routing', + title: 'Dual model routing (separate subagent model)', + description: + 'Route the main agent and its subagents to different models. Subagents use a dedicated subagent model (configurable via /model) instead of inheriting the main agent model. When disabled, subagents inherit the main model as before.', + env: 'KIMI_CODE_EXPERIMENTAL_DUAL_MODEL_ROUTING', + default: false, + surface: 'both', + }, ] as const satisfies readonly FlagDefinitionInput[]; /** Literal union of registered flag ids. */ diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index ec6d825f54..1232fb0928 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -227,6 +227,23 @@ export interface SetModelResult { readonly model: string; readonly providerName?: string | undefined; } +/** + * Set the session-level subagent model override (`dual-model-routing` + * experimental feature). When `model` is an empty string the live override is + * cleared (subagents fall back to the config default, then the parent model). + */ +export interface SetSubagentModelPayload { + readonly model: string; +} +export interface SetSubagentModelResult { + /** The effective subagent model alias, or `undefined` when none is configured + * (subagents will inherit the parent model). */ + readonly subagentModel?: string | undefined; +} +export interface GetSubagentModelResult { + /** The effective subagent model alias, or `undefined` when none is configured. */ + readonly subagentModel?: string | undefined; +} export interface CancelPlanPayload { readonly id?: string; } @@ -491,6 +508,8 @@ export interface SessionAPI extends AgentAPIWithId { renameSession: (payload: RenameSessionPayload) => void; updateSessionMetadata: (payload: UpdateSessionMetadataPayload) => void; getSessionMetadata: (payload: EmptyPayload) => SessionMeta; + setSubagentModel: (payload: SetSubagentModelPayload) => SetSubagentModelResult; + getSubagentModel: (payload: EmptyPayload) => GetSubagentModelResult; listSkills: (payload: EmptyPayload) => readonly SkillSummary[]; listPluginCommands: (payload: EmptyPayload) => readonly PluginCommandDef[]; listMcpServers: (payload: EmptyPayload) => readonly McpServerInfo[]; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 09cf2965ca..8e2f5798bf 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -106,6 +106,7 @@ import type { GetCronTasksResult, GetKimiConfigPayload, GetPluginInfoPayload, + GetSubagentModelResult, InstallPluginPayload, ImportContextPayload, ListSessionsPayload, @@ -133,6 +134,8 @@ import type { SetPermissionPayload, SetPluginEnabledPayload, SetPluginMcpServerEnabledPayload, + SetSubagentModelPayload, + SetSubagentModelResult, SetThinkingPayload, SkillSummary, PluginCommandDef, @@ -828,6 +831,20 @@ export class KimiCore implements PromisableMethods { return this.sessionApi(sessionId).getModel(payload); } + setSubagentModel({ + sessionId, + ...payload + }: SessionScopedPayload): SetSubagentModelResult { + return this.sessionApi(sessionId).setSubagentModel(payload); + } + + getSubagentModel({ + sessionId, + ...payload + }: SessionScopedPayload): GetSubagentModelResult { + return this.sessionApi(sessionId).getSubagentModel(payload); + } + enterPlan({ sessionId, ...payload }: SessionAgentPayload) { return this.sessionApi(sessionId).enterPlan(payload); } diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 0f7ec8d235..06f0f7d875 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -198,6 +198,13 @@ export class Session { private agentsMdWarning: string | undefined; private printSteerDeadline: number | undefined; private printSteerTurns = 0; + /** + * Live session override for the subagent model alias (the + * `dual-model-routing` experimental feature). When set and the flag is + * enabled, subagents use this model instead of the parent's. When unset, + * falls back to `config.defaultSubagentModel`, then the parent's model. + */ + private subagentModelAlias: string | undefined; constructor(public readonly options: SessionOptions) { // Attach the per-session log sink up front so the constructor's @@ -578,6 +585,29 @@ export class Session { return count; } + /** + * The live subagent model alias override for this session + * (`dual-model-routing` experimental feature). Set via the + * `setSubagentModel` RPC; takes precedence over + * `config.defaultSubagentModel`. + * + * Returns `undefined` when the flag is OFF, so the status surface and any + * consumer that does not independently gate on the flag never observes a + * subagent model. The runtime resolver (`resolveSubagentModelAlias`) also + * checks the flag, but gating here is the single source of truth that keeps + * `getStatus`, the TUI footer, and the sync paths correct regardless of who + * calls this accessor. + */ + getSubagentModel(): string | undefined { + if (!this.experimentalFlags.enabled('dual-model-routing')) return undefined; + return this.subagentModelAlias ?? this.options.config?.defaultSubagentModel; + } + + /** Set or clear the live session subagent model override. */ + setSubagentModelAlias(alias: string | undefined): void { + this.subagentModelAlias = alias; + } + /** * Decide what the `kimi -p` driver should do after the main agent's turn ends * with `reason === 'completed'`. Returns `'finish'` when the run may exit, or diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts index a2e6f0c1e9..d3681d4162 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -16,6 +16,7 @@ import type { EnterSwarmPayload, GetBackgroundOutputPayload, GetBackgroundPayload, + GetSubagentModelResult, ImportContextPayload, McpServerInfo, McpStartupMetrics, @@ -28,6 +29,8 @@ import type { SetActiveToolsPayload, SetModelPayload, SetPermissionPayload, + SetSubagentModelPayload, + SetSubagentModelResult, SetThinkingPayload, SkillSummary, PluginCommandDef, @@ -163,6 +166,16 @@ export class SessionAPIImpl implements PromisableMethods { return (await this.getAgent(agentId)).getModel(payload); } + setSubagentModel(payload: SetSubagentModelPayload): SetSubagentModelResult { + const alias = payload.model.trim(); + this.session.setSubagentModelAlias(alias.length > 0 ? alias : undefined); + return { subagentModel: this.session.getSubagentModel() }; + } + + getSubagentModel(_payload: EmptyPayload): GetSubagentModelResult { + return { subagentModel: this.session.getSubagentModel() }; + } + async enterPlan({ agentId, ...payload }: AgentScopedPayload) { return (await this.getAgent(agentId)).enterPlan(payload); } diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 51a6a99f07..1df55e148d 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -182,7 +182,7 @@ export class SessionSubagentHost { const completion = this.runWithActiveChild(agentId, options, async (runOptions) => { this.emitSubagentSpawned(parent, agentId, profileName, runOptions); try { - child.config.update({ modelAlias: parent.config.modelAlias }); + child.config.update({ modelAlias: this.resolveSubagentModelAlias(parent) }); return await this.runPromptTurn(parent, agentId, child, profileName, runOptions); } catch (error) { this.emitSubagentFailed(parent, agentId, runOptions, error); @@ -198,7 +198,7 @@ export class SessionSubagentHost { const completion = this.runWithActiveChild(agentId, options, async (runOptions) => { try { runOptions.signal.throwIfAborted(); - child.config.update({ modelAlias: parent.config.modelAlias }); + child.config.update({ modelAlias: this.resolveSubagentModelAlias(parent) }); this.emitSubagentStarted(parent, agentId); const turnId = child.turn.retry('agent-host'); if (turnId === null) { @@ -260,7 +260,7 @@ export class SessionSubagentHost { ); child.config.update({ - modelAlias: parent.config.modelAlias, + modelAlias: this.resolveSubagentModelAlias(parent), thinkingEffort: parent.config.thinkingEffort, systemPrompt: parent.config.systemPrompt, }); @@ -317,6 +317,23 @@ export class SessionSubagentHost { return profile; } + /** + * Resolve the model alias a subagent should run on. When the + * `dual-model-routing` experimental flag is enabled, subagents use the + * session's live subagent-model override (set via `/model`) or fall back to + * `config.defaultSubagentModel`. When the flag is off, or no subagent model + * is configured, subagents inherit the parent agent's live model. + * + * `Session.getSubagentModel()` is the single flag-aware source of truth — it + * returns `undefined` when the flag is off, so this resolver falls through to + * the parent model automatically. + */ + private resolveSubagentModelAlias(parent: Agent): string | undefined { + const subagentModel = this.session.getSubagentModel(); + if (subagentModel !== undefined && subagentModel.length > 0) return subagentModel; + return parent.config.modelAlias; + } + private runWithActiveChild( childId: string, options: RunSubagentOptions, @@ -401,10 +418,12 @@ export class SessionSubagentHost { child: Agent, profile: ResolvedAgentProfile, ): Promise { - // A subagent always inherits the parent agent's model. + // When the `dual-model-routing` experimental flag is enabled, subagents use + // a dedicated model (the live session override or config default). When the + // flag is off, subagents inherit the parent agent's model as before. child.config.update({ cwd: parent.config.cwd, - modelAlias: parent.config.modelAlias, + modelAlias: this.resolveSubagentModelAlias(parent), thinkingEffort: parent.config.thinkingEffort, }); diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index d626f7d5fb..9ae86faf26 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -938,6 +938,20 @@ support_efforts = ["low", "high"] expect(overrides['support_efforts']).toEqual(['low', 'high']); }); + + it('round-trips default_subagent_model through parse and serialization', () => { + const config = parseConfigString('default_subagent_model = "glm-5.2"\n'); + expect(config.defaultSubagentModel).toBe('glm-5.2'); + + const data = configToTomlData(config); + expect(data['default_subagent_model']).toBe('glm-5.2'); + }); + + it('omits default_subagent_model from serialization when unset', () => { + const config = parseConfigString('default_model = "kimi-k3"\n'); + const data = configToTomlData(config); + expect(data['default_subagent_model']).toBeUndefined(); + }); }); describe('applyPrintModeConfigDefaults', () => { diff --git a/packages/agent-core/test/flags/resolver.test.ts b/packages/agent-core/test/flags/resolver.test.ts index 85cc6e1e53..e584cd4703 100644 --- a/packages/agent-core/test/flags/resolver.test.ts +++ b/packages/agent-core/test/flags/resolver.test.ts @@ -201,4 +201,15 @@ describe('FLAG_DEFINITIONS invariants', () => { seenId.add(def.id); } }); + + it('registers the dual-model-routing flag (default off, env-driven)', () => { + const def = FLAG_DEFINITIONS.find((d) => d.id === 'dual-model-routing'); + expect(def).toBeDefined(); + expect(def!.env).toBe('KIMI_CODE_EXPERIMENTAL_DUAL_MODEL_ROUTING'); + expect(def!.default).toBe(false); + + const resolver = new FlagResolver({ KIMI_CODE_EXPERIMENTAL_DUAL_MODEL_ROUTING: 'true' }); + expect(resolver.enabled('dual-model-routing')).toBe(true); + expect(new FlagResolver({}).enabled('dual-model-routing')).toBe(false); + }); }); diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 778bea661d..a6977561db 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -1596,6 +1596,9 @@ function fakeSession( writeMetadata: vi.fn(async () => {}), systemContextKaos: vi.fn((cwd: string) => parent.kaos.withCwd(cwd)), getReadyAgent: vi.fn((id: string) => agents.get(id)), + // dual-model-routing defaults to off; subagents inherit the parent model. + getSubagentModel: vi.fn(() => undefined), + experimentalFlags: { enabled: () => false }, ensureAgentResumed: vi.fn(async (id: string) => { const agent = agents.get(id); if (agent === undefined) { diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index df5a2825e2..454d9002d0 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -90,6 +90,18 @@ export interface SetSessionModelRpcResult { readonly providerName?: string | undefined; } +export interface SetSessionSubagentModelRpcInput extends SessionIdRpcInput { + readonly model: string; +} + +export interface SetSessionSubagentModelRpcResult { + readonly subagentModel?: string | undefined; +} + +export interface GetSessionSubagentModelRpcResult { + readonly subagentModel?: string | undefined; +} + export interface SetSessionThinkingRpcInput extends SessionIdRpcInput { readonly effort: string; } @@ -415,6 +427,25 @@ export abstract class SDKRpcClientBase { }); } + async setSubagentModel( + input: SetSessionSubagentModelRpcInput, + ): Promise { + const rpc = await this.getRpc(); + return rpc.setSubagentModel({ + sessionId: input.sessionId, + model: input.model, + }); + } + + async getSubagentModel( + input: SessionIdRpcInput, + ): Promise { + const rpc = await this.getRpc(); + return rpc.getSubagentModel({ + sessionId: input.sessionId, + }); + } + async setThinking(input: SetSessionThinkingRpcInput): Promise { const rpc = await this.getRpc(); return rpc.setThinking({ @@ -571,6 +602,9 @@ export abstract class SDKRpcClientBase { sessionId: input.sessionId, agentId, }); + const subagentModel = await rpc.getSubagentModel({ + sessionId: input.sessionId, + }); const maxContextTokens = config.modelCapabilities?.max_context_tokens ?? 0; const contextTokens = context.tokenCount; const contextUsage = maxContextTokens > 0 ? contextTokens / maxContextTokens : 0; @@ -578,6 +612,7 @@ export abstract class SDKRpcClientBase { usage.byModel !== undefined || usage.total !== undefined || usage.currentTurn !== undefined; return { model: config.modelAlias ?? config.provider?.model, + subagentModel: subagentModel.subagentModel, thinkingEffort: config.thinkingEffort, permission: permission.mode, planMode: plan !== null, diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 571c2355ff..2ce2d4d0a9 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -207,6 +207,16 @@ export class Session { await this.rpc.setThinking({ sessionId: this.id, effort: normalized }); } + /** + * Set the session-level subagent model override (the `dual-model-routing` + * experimental feature). Pass an empty string to clear the override so + * subagents fall back to the config default / parent model. + */ + async setSubagentModel(model: string): Promise { + this.ensureOpen(); + await this.rpc.setSubagentModel({ sessionId: this.id, model }); + } + async setPermission(mode: PermissionMode): Promise { this.ensureOpen(); if (!isPermissionMode(mode)) { diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index c72419e3f4..e89c4bc421 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -231,6 +231,7 @@ export interface SessionUsage { export interface SessionStatus { readonly model?: string; + readonly subagentModel?: string | undefined; readonly thinkingEffort: string; readonly permission: PermissionMode; readonly planMode: boolean; From 6a07fe8eae55979b760c2e789d4b87ddf43ee09d Mon Sep 17 00:00:00 2001 From: Kassie Povinelli Date: Mon, 20 Jul 2026 23:36:45 -0500 Subject: [PATCH 2/7] feat(agent-core): subagent thinking-effort routing + dual-model-routing hardening Extend the dual-model-routing experimental feature so subagents can run a dedicated thinking effort alongside the dedicated model, and harden the original routing based on review: - Thinking effort: new default_subagent_thinking_effort config field, live session override, setSubagentThinking/getSubagentThinking RPCs, SDK Session.setSubagentThinkingEffort + SessionStatus.subagentThinkingEffort. The /model subagent picker applies the selected effort (previously silently dropped), and the footer badge / scope picker display it. - btw exception: the /btw side-question agent always inherits the main agent's model and effort, even when the flag is on. It replays the main agent's prompt prefix and shares its warm prefix cache, so rerouting it would re-process the whole conversation on a cold cache for a lightweight text-only side channel. - Validation: setSubagentModel validates the alias eagerly via the provider manager (mirroring Agent.setModel) instead of failing deep inside a later subagent spawn; both subagent setters reject with CONFIG_INVALID when the flag is off rather than storing dormant values. - Persistence: live subagent overrides are written to session metadata (custom) and rehydrated on resume, so they survive /reload, resume, and fork like the main model does. - SDK: getStatus guards the new RPCs with typeof checks so it keeps working against older cores that lack them. - TUI: footer badge only renders when the subagent model is distinct from the main model; logout/provider-removal clears subagent state; the subagent picker drops the misleading main-conversation cache warning; no-op selections short-circuit. - Tests: flag-on routing across spawn/resume/retry, btw inheritance with a subagent model configured, Session RPC surface (round-trip, clear, invalid alias, flag-off), getStatus against older cores, scope selector and footer components, experiment-toggle hydration. Also fix the node-sdk experimental-feature list assertion that was failing at HEAD (missing dual-model-routing entry). --- .changeset/dual-model-routing.md | 2 +- apps/kimi-code/src/tui/commands/config.ts | 84 ++-- .../src/tui/components/chrome/footer.ts | 19 +- .../dialogs/model-scope-selector.ts | 23 +- .../src/tui/controllers/auth-flow.ts | 7 + apps/kimi-code/src/tui/kimi-tui.ts | 2 + apps/kimi-code/src/tui/types.ts | 6 + .../test/tui/commands/experiments.test.ts | 92 ++++ .../test/tui/components/chrome/footer.test.ts | 77 +++ .../dialogs/model-scope-selector.test.ts | 96 ++++ docs/en/configuration/config-files.md | 7 +- docs/en/configuration/env-vars.md | 2 +- packages/agent-core/src/config/schema.ts | 8 + packages/agent-core/src/config/toml.ts | 1 + packages/agent-core/src/rpc/core-api.ts | 19 + packages/agent-core/src/rpc/core-impl.ts | 17 + packages/agent-core/src/session/index.ts | 86 +++- packages/agent-core/src/session/rpc.ts | 42 ++ .../agent-core/src/session/subagent-host.ts | 59 ++- .../agent-core/test/config/configs.test.ts | 14 + packages/agent-core/test/session/init.test.ts | 134 ++++++ .../test/session/subagent-host.test.ts | 449 +++++++++++++++++- packages/node-sdk/src/rpc.ts | 57 ++- packages/node-sdk/src/session.ts | 10 + packages/node-sdk/src/types.ts | 11 + packages/node-sdk/test/config.test.ts | 11 + .../node-sdk/test/session-get-status.test.ts | 87 ++++ 27 files changed, 1362 insertions(+), 60 deletions(-) create mode 100644 apps/kimi-code/test/tui/components/dialogs/model-scope-selector.test.ts create mode 100644 packages/node-sdk/test/session-get-status.test.ts diff --git a/.changeset/dual-model-routing.md b/.changeset/dual-model-routing.md index f5a0e1c04e..ff5a1c1e41 100644 --- a/.changeset/dual-model-routing.md +++ b/.changeset/dual-model-routing.md @@ -5,6 +5,6 @@ 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. +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. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index a02455413c..8b3df3a970 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -331,6 +331,7 @@ function showModelScopePicker(host: SlashCommandHost): void { 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(); @@ -350,9 +351,7 @@ function showModelScopePicker(host: SlashCommandHost): void { /** * Show the model picker for the subagent scope (`dual-model-routing` * experimental feature). Reuses the tabbed model selector; the selected - * alias is applied via `performSubagentModelSwitch`. Thinking effort is - * carried through for parity with the main picker but is not separately - * persisted for subagents. + * alias and thinking effort are applied via `performSubagentModelSwitch`. */ function showSubagentModelPicker(host: SlashCommandHost): void { const models = Object.fromEntries( @@ -373,15 +372,18 @@ function showSubagentModelPicker(host: SlashCommandHost): void { new TabbedModelSelectorComponent({ models, currentValue: host.state.appState.subagentModel ?? host.state.appState.model, - currentThinkingEffort: host.state.appState.thinkingEffort, - warning: hasConversationHistory(host) ? MODEL_SWITCH_CACHE_WARNING : undefined, - onSelect: ({ alias }) => { + 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, true); + void performSubagentModelSwitch(host, alias, thinking, true); }, - onSessionOnlySelect: ({ alias }) => { + onSessionOnlySelect: ({ alias, thinking }) => { host.restoreEditor(); - void performSubagentModelSwitch(host, alias, false); + void performSubagentModelSwitch(host, alias, thinking, false); }, onCancel: () => { host.restoreEditor(); @@ -391,12 +393,14 @@ function showSubagentModelPicker(host: SlashCommandHost): void { } /** - * Apply a subagent model selection. Pushes the live session override and, - * when persisting, writes `defaultSubagentModel` to config.toml. + * 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 { if (host.state.appState.streamingPhase !== 'idle') { @@ -404,45 +408,65 @@ async function performSubagentModelSwitch( 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 }); + host.setAppState({ subagentModel: alias, subagentThinkingEffort: effort }); let persisted = false; if (persist) { try { - persisted = await persistSubagentModelSelection(host, alias); + 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} as the default subagent model.` - : `Subagent model set to ${displayName}.` - : `Subagent model set to ${displayName} for this session only.`; + ? `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, persist }); + host.track('subagent_model_switch', { model: alias, effort, persist }); } async function persistSubagentModelSelection( host: SlashCommandHost, alias: string, + effort: string, ): Promise { const config = await host.harness.getConfig({ reload: true }); - if (config.defaultSubagentModel === alias) return false; - await host.harness.setConfig({ defaultSubagentModel: alias }); + const modelChanged = config.defaultSubagentModel !== alias; + const effortChanged = config.defaultSubagentThinkingEffort !== effort; + if (!modelChanged && !effortChanged) return false; + await host.harness.setConfig({ + ...(modelChanged ? { defaultSubagentModel: alias } : {}), + ...(effortChanged ? { defaultSubagentThinkingEffort: effort } : {}), + }); return true; } @@ -805,22 +829,26 @@ export async function applyExperimentalFeatureChanges( setExperimentalFeatures(features); host.refreshSlashCommandAutocomplete(); host.restoreEditor(); - // Sync the subagent-model app state with the flag: load it from config - // when dual-model routing is on, clear it when off so the footer and - // /model scope picker reflect the feature's actual state. - const config = await host.harness.getConfig({ reload: true }); - host.setAppState({ - subagentModel: isExperimentalFlagEnabled('dual-model-routing') - ? (config.defaultSubagentModel ?? undefined) - : undefined, - }); 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 }); diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index 54c1898f38..c17a6f5a26 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -152,7 +152,12 @@ function subagentModelDisplayName(state: AppState): string { if (alias === undefined || alias.length === 0) return ''; const model = state.availableModels[alias]; const effective = model === undefined ? undefined : effectiveModelAlias(model); - return effective?.displayName ?? effective?.model ?? alias; + const base = effective?.displayName ?? effective?.model ?? alias; + // 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 `${base} · ${effort}`; + return base; } function shortenCwd(path: string): string { @@ -299,12 +304,20 @@ export class FooterComponent implements Component { } // Subagent model badge — only shown when the `dual-model-routing` - // experimental feature is active and a distinct subagent model is set. + // experimental feature is active and a DISTINCT subagent model is set + // (different alias from the main model). When the subagent alias equals + // the main model — e.g. auth-flow hydration sets it from config even when + // defaultSubagentModel === defaultModel — 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 subagentLabel = - isExperimentalFlagEnabled('dual-model-routing') ? subagentModelDisplayName(state) : ''; + isExperimentalFlagEnabled('dual-model-routing') && + subagentAlias !== undefined && + subagentAlias !== state.model + ? subagentModelDisplayName(state) + : ''; if (subagentLabel.length > 0) { left.push(chalk.hex(colors.textDim)('subagents:') + ' ' + chalk.hex(colors.text)(subagentLabel)); } diff --git a/apps/kimi-code/src/tui/components/dialogs/model-scope-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-scope-selector.ts index bc4e8a30b2..ee1912cb9a 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-scope-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-scope-selector.ts @@ -22,6 +22,8 @@ export interface ModelScopeSelectorOptions { 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; readonly onSelect: (scope: ModelScope) => void; @@ -35,13 +37,20 @@ function isModelScope(value: string): value is ModelScope { export class ModelScopeSelectorComponent extends ChoicePickerComponent { constructor(opts: ModelScopeSelectorOptions) { const mainName = modelDisplayName(opts.currentModel, opts.availableModels[opts.currentModel]); - const subagentName = - opts.currentSubagentModel !== undefined && opts.currentSubagentModel.length > 0 - ? modelDisplayName( - opts.currentSubagentModel, - opts.availableModels[opts.currentSubagentModel], - ) - : '(inherits main model)'; + let subagentName: string; + if (opts.currentSubagentModel !== undefined && opts.currentSubagentModel.length > 0) { + const model = modelDisplayName( + opts.currentSubagentModel, + opts.availableModels[opts.currentSubagentModel], + ); + const effortSuffix = + opts.currentSubagentThinkingEffort !== undefined && opts.currentSubagentThinkingEffort.length > 0 + ? ` (effort: ${opts.currentSubagentThinkingEffort})` + : ''; + subagentName = `${model}${effortSuffix}`; + } else { + subagentName = '(inherits main model)'; + } const options: readonly ChoiceOption[] = [ { diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index 820c84a7c4..e8730fdcb4 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -111,6 +111,8 @@ export class AuthFlowController { sessionId: '', model: '', sessionTitle: null, + subagentModel: undefined, + subagentThinkingEffort: undefined, }); await this.host.refreshSkillCommands(); await this.host.refreshPluginCommands(); @@ -138,6 +140,9 @@ export class AuthFlowController { subagentModel: isExperimentalFlagEnabled('dual-model-routing') ? (config.defaultSubagentModel ?? undefined) : undefined, + subagentThinkingEffort: isExperimentalFlagEnabled('dual-model-routing') + ? (config.defaultSubagentThinkingEffort ?? undefined) + : undefined, }; host.setAppState(appStatePatch); } @@ -152,6 +157,8 @@ export class AuthFlowController { maxContextTokens: 0, contextUsage: 0, contextTokens: 0, + subagentModel: undefined, + subagentThinkingEffort: undefined, }); } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 2ec720b85b..dd61e96eb3 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -204,6 +204,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { return { model: '', subagentModel: undefined, + subagentThinkingEffort: undefined, workDir: input.workDir, additionalDirs: [...(input.additionalDirs ?? [])], sessionId: '', @@ -1538,6 +1539,7 @@ export class KimiTUI { sessionId: session.id, model: status.model ?? '', subagentModel: status.subagentModel, + subagentThinkingEffort: status.subagentThinkingEffort, thinkingEffort: status.thinkingEffort, permissionMode: status.permission, planMode: status.planMode, diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 73c3f0ac4b..5d25757511 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -32,6 +32,12 @@ export interface AppState { * then inherit the main model). Keyed into `availableModels`. */ subagentModel?: string; + /** + * Live subagent thinking effort (`dual-model-routing` experimental feature). + * Undefined when the feature is off or no subagent effort is set (subagents + * then inherit the main agent's thinking effort). + */ + subagentThinkingEffort?: string; workDir: string; additionalDirs: readonly string[]; sessionId: string; diff --git a/apps/kimi-code/test/tui/commands/experiments.test.ts b/apps/kimi-code/test/tui/commands/experiments.test.ts index 80939c0db9..f724ac981b 100644 --- a/apps/kimi-code/test/tui/commands/experiments.test.ts +++ b/apps/kimi-code/test/tui/commands/experiments.test.ts @@ -27,6 +27,22 @@ function feature( }; } +function dualModelRoutingFeature( + overrides: Partial = {}, +): ExperimentalFeatureState { + return feature({ + id: 'dual-model-routing', + title: 'Dual-model routing', + description: 'Run subagents on a dedicated model.', + surface: 'core', + env: 'KIMI_CODE_EXPERIMENTAL_DUAL_MODEL_ROUTING', + defaultEnabled: false, + enabled: false, + source: 'default', + ...overrides, + }); +} + function makeHost() { const session = { id: 'ses-experiments', @@ -65,12 +81,20 @@ function makeHost() { restoreEditor: ReturnType; showStatus: ReturnType; showError: ReturnType; + setAppState: ReturnType; track: ReturnType; session: typeof session; }; return host; } +/** Make a host whose session is undefined (the no-session path exercises the + * manual subagent-model sync in applyExperimentalFeatureChanges). */ +function makeNoSessionHost() { + const host = makeHost(); + return { ...host, session: undefined }; +} + describe('experimental feature command handlers', () => { afterEach(() => { setExperimentalFeatures([]); @@ -117,3 +141,71 @@ describe('experimental feature command handlers', () => { ); }); }); + +describe('applyExperimentalFeatureChanges dual-model-routing sync', () => { + afterEach(() => { + setExperimentalFeatures([]); + }); + + it('populates subagentModel/subagentThinkingEffort from config when enabling (no session)', async () => { + const host = makeNoSessionHost(); + host.harness.getExperimentalFeatures.mockResolvedValueOnce([ + dualModelRoutingFeature({ enabled: true, source: 'config', configValue: true }), + ]); + host.harness.getConfig.mockResolvedValueOnce({ + defaultSubagentModel: 'glm-5.2', + defaultSubagentThinkingEffort: 'high', + }); + + await applyExperimentalFeatureChanges(host, [ + { id: 'dual-model-routing', enabled: true }, + ]); + + expect(host.setAppState).toHaveBeenCalledWith({ + subagentModel: 'glm-5.2', + subagentThinkingEffort: 'high', + }); + }); + + it('clears subagentModel/subagentThinkingEffort when disabling (no session)', async () => { + const host = makeNoSessionHost(); + // Seed the flag as enabled so the disabling change is observed. + setExperimentalFeatures([{ id: 'dual-model-routing', enabled: true }]); + host.harness.getExperimentalFeatures.mockResolvedValueOnce([ + dualModelRoutingFeature({ enabled: false, source: 'config', configValue: false }), + ]); + + await applyExperimentalFeatureChanges(host, [ + { id: 'dual-model-routing', enabled: false }, + ]); + + expect(host.setAppState).toHaveBeenCalledWith({ + subagentModel: undefined, + subagentThinkingEffort: undefined, + }); + }); + + it('skips the manual setAppState sync when a session is present (reload covers it)', async () => { + const host = makeHost(); + host.harness.getExperimentalFeatures.mockResolvedValueOnce([ + dualModelRoutingFeature({ enabled: true, source: 'config', configValue: true }), + ]); + + await applyExperimentalFeatureChanges(host, [ + { id: 'dual-model-routing', enabled: true }, + ]); + + // setAppState is never called with a subagentModel patch in the session + // branch — reloadSession + reloadCurrentSessionView own that sync. + const calls = host.setAppState.mock.calls as ReadonlyArray< + ReadonlyArray> + >; + expect( + calls.some( + (call) => + call[0]?.['subagentModel'] !== undefined || + call[0]?.['subagentThinkingEffort'] !== undefined, + ), + ).toBe(false); + }); +}); diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index 3e66db7cb6..b71ed48ea7 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -246,4 +246,81 @@ describe('FooterComponent subagent model badge', () => { expect(rendered).toContain('subagents:'); expect(rendered).toContain('GLM-5.2'); }); + + it('hides the subagent badge when subagentModel equals the main model', () => { + setExperimentalFeatures([{ id: 'dual-model-routing', enabled: true }]); + const state: AppState = { + ...appState, + model: 'kimi-k3', + subagentModel: 'kimi-k3', + availableModels: { + 'kimi-k3': { + provider: 'managed:kimi-code', + model: 'kimi-k3', + maxContextSize: 262144, + displayName: 'Kimi K3', + }, + }, + }; + const footer = new FooterComponent(state); + const rendered = footer.render(140).join('\n'); + + // Even with the flag on and a subagentModel set, the badge is hidden + // when the alias is identical to the main model (nothing distinct to show). + expect(rendered).not.toContain('subagents:'); + }); + + it('appends the thinking-effort suffix to the subagent badge when set', () => { + setExperimentalFeatures([{ id: 'dual-model-routing', enabled: true }]); + const state: AppState = { + ...appState, + model: 'kimi-k3', + subagentModel: 'glm-5.2', + subagentThinkingEffort: 'high', + availableModels: { + 'kimi-k3': { + provider: 'managed:kimi-code', + model: 'kimi-k3', + maxContextSize: 262144, + }, + 'glm-5.2': { + provider: 'zai', + model: 'glm-5.2', + maxContextSize: 131072, + displayName: 'GLM-5.2', + }, + }, + }; + const footer = new FooterComponent(state); + const rendered = footer.render(140).join('\n'); + + expect(rendered).toContain('GLM-5.2 · high'); + }); + + it('shows no effort suffix when subagentThinkingEffort is unset', () => { + setExperimentalFeatures([{ id: 'dual-model-routing', enabled: true }]); + const state: AppState = { + ...appState, + model: 'kimi-k3', + subagentModel: 'glm-5.2', + availableModels: { + 'kimi-k3': { + provider: 'managed:kimi-code', + model: 'kimi-k3', + maxContextSize: 262144, + }, + 'glm-5.2': { + provider: 'zai', + model: 'glm-5.2', + maxContextSize: 131072, + displayName: 'GLM-5.2', + }, + }, + }; + const footer = new FooterComponent(state); + const rendered = footer.render(140).join('\n'); + + expect(rendered).toContain('GLM-5.2'); + expect(rendered).not.toContain('GLM-5.2 ·'); + }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/model-scope-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-scope-selector.test.ts new file mode 100644 index 0000000000..e91a5ba305 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/model-scope-selector.test.ts @@ -0,0 +1,96 @@ +import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { ModelScopeSelectorComponent } from '#/tui/components/dialogs/model-scope-selector'; + +const ANSI_SGR = /\[[0-9;]*m/g; + +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +const availableModels: Record = { + 'kimi-k3': { + provider: 'managed:kimi-code', + model: 'kimi-k3', + maxContextSize: 262144, + displayName: 'Kimi K3', + }, + 'glm-5.2': { + provider: 'zai', + model: 'glm-5.2', + maxContextSize: 131072, + displayName: 'GLM-5.2', + }, +}; + +describe('ModelScopeSelectorComponent', () => { + it('shows resolved display names for both scopes', () => { + const onSelect = vi.fn(); + const onCancel = vi.fn(); + const picker = new ModelScopeSelectorComponent({ + currentModel: 'kimi-k3', + currentSubagentModel: 'glm-5.2', + currentSubagentThinkingEffort: undefined, + availableModels, + onSelect, + onCancel, + }); + + const out = picker.render(120).map(strip); + + expect(out.some((l) => l.includes('Main agent — Kimi K3'))).toBe(true); + expect(out.some((l) => l.includes('Subagents — GLM-5.2'))).toBe(true); + }); + + it('shows "(inherits main model)" when currentSubagentModel is undefined', () => { + const picker = new ModelScopeSelectorComponent({ + currentModel: 'kimi-k3', + currentSubagentModel: undefined, + currentSubagentThinkingEffort: undefined, + availableModels, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = picker.render(120).map(strip); + + expect(out.some((l) => l.includes('Subagents — (inherits main model)'))).toBe(true); + }); + + it('appends the effort suffix when currentSubagentThinkingEffort is set', () => { + const picker = new ModelScopeSelectorComponent({ + currentModel: 'kimi-k3', + currentSubagentModel: 'glm-5.2', + currentSubagentThinkingEffort: 'high', + availableModels, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = picker.render(120).map(strip); + + expect(out.some((l) => l.includes('Subagents — GLM-5.2 (effort: high)'))).toBe(true); + }); + + it('fires onSelect with "main" for the main option and "subagent" for the subagent option', () => { + const onSelect = vi.fn(); + const picker = new ModelScopeSelectorComponent({ + currentModel: 'kimi-k3', + currentSubagentModel: 'glm-5.2', + currentSubagentThinkingEffort: undefined, + availableModels, + onSelect, + onCancel: vi.fn(), + }); + + // First option is "main" and starts selected. + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith('main'); + + // Move down to the subagent option and select it. + picker.handleInput('\u001B[B'); // ↓ + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith('subagent'); + }); +}); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index ff0ad440b6..c6b7937f88 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -98,7 +98,8 @@ Fields in the config file fall into two categories: **top-level scalars** that d | Field | Type | Default | Description | | --- | --- | --- | --- | | `default_model` | `string` | — | Default model alias; must be defined in `models` | -| `default_subagent_model` | `string` | — | Default model alias for subagents (experimental `dual-model-routing` feature). When set and the feature is enabled, delegated subagents run on this model instead of inheriting `default_model` | +| `default_subagent_model` | `string` | — | Default model alias for subagents (experimental `dual-model-routing` feature). Must be defined in `models`. When set and the feature is enabled, delegated subagents run on this model instead of inheriting `default_model` | +| `default_subagent_thinking_effort` | `string` | — | Default thinking effort for subagents (experimental `dual-model-routing` feature). When set and the feature is enabled, delegated subagents use this thinking effort instead of inheriting the main agent's effort | | `default_permission_mode` | `string` | `manual` | Default permission mode for new sessions; one of `manual` (prompt each time), `yolo` (auto-approve tool actions, but the agent may still ask questions), or `auto` (fully autonomous — the agent decides everything without asking) | | `default_plan_mode` | `boolean` | `false` | Whether new sessions start in Plan mode (produce a plan before executing) by default | | `merge_all_available_skills` | `boolean` | `true` | Whether to merge Agent Skills from all available directories | @@ -114,6 +115,10 @@ Fields in the config file fall into two categories: **top-level scalars** that d | `permission` | `table` | — | Initial permission rules → [`permission`](#permission) | | `hooks` | `array` | — | Lifecycle hooks; see [Hooks](../customization/hooks.md) | +::: tip Subagent model and thinking-effort defaults +`default_subagent_model` and `default_subagent_thinking_effort` take effect only while the experimental `dual-model-routing` feature is enabled. Delegated subagents (`task` / `explore` / `AgentSwarm`) use the dedicated model and effort; the side-question (`/btw`) agent always inherits the main agent's model and effort regardless — 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. These defaults are read when a session is created or resumed; editing them mid-session does not affect a live session until it is reloaded or resumed. Use the `/model` scope picker for a mid-session override. +::: + The following sections cover each of the nested tables in turn: `providers`, `models`, `thinking`, `loop_control`, `background`, `image`, `services`, and `permission`. ## `providers` diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index aea23d726b..9eae80098c 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -128,7 +128,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | Cap how many AgentSwarm subagents run concurrently during the initial ramp; leave unset for no cap | Positive integer; invalid values fail fast | | `KIMI_SUBAGENT_TIMEOUT_MS` | Maximum wall-clock time (ms) a single subagent (`Agent` / `AgentSwarm`) may run; takes higher priority than `[subagent] timeout_ms` in `config.toml` (default `7200000`, i.e. 2 hours) | Positive integer; invalid values fall back to the config or default | | `KIMI_CODE_EXPERIMENTAL_FLAG` | Enable all registered experimental features for this process | `1`, `true`, `yes`, `on` | -| `KIMI_CODE_EXPERIMENTAL_DUAL_MODEL_ROUTING` | Enable dual model routing (experimental): route the main agent and its subagents to different models. When on, subagents use the `default_subagent_model` (configurable via `/model`) instead of inheriting the main model | `1`, `true`, `yes`, `on` | +| `KIMI_CODE_EXPERIMENTAL_DUAL_MODEL_ROUTING` | Enable dual model routing (experimental): route the main agent and its subagents to different models. The flag only enables the capability — delegated subagents use the dedicated `default_subagent_model` and `default_subagent_thinking_effort` when one is configured (or set live via `/model`); when neither is set they still inherit the main agent's model and effort | `1`, `true`, `yes`, `on` | | `KIMI_SHELL_PATH` | Override the Git Bash path on Windows (used when auto-detection fails) | Absolute path | | `KIMI_MODEL_MAX_COMPLETION_TOKENS` | Hard cap on `max_completion_tokens` per LLM step; applies to the `kimi` provider only | Positive integer; `0` or negative disables clamping | | `KIMI_MODEL_TEMPERATURE` | Sampling temperature for every request; applies to the `kimi` provider only (global — independent of `KIMI_MODEL_NAME`) | Number, e.g. `0.3` | diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 2b41084724..5168ed3989 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -298,6 +298,13 @@ export const KimiConfigSchema = z.object({ * flag is on; otherwise subagents inherit the parent's live model. */ defaultSubagentModel: z.string().optional(), + /** + * Default thinking effort used by subagents when the `dual-model-routing` + * experimental flag is enabled. When unset, subagents fall back to the + * parent's live thinking effort. Only takes effect while the flag is on; + * otherwise subagents inherit the parent's thinking effort. + */ + defaultSubagentThinkingEffort: z.string().optional(), models: z.record(z.string(), ModelAliasSchema).optional(), thinking: ThinkingConfigSchema.optional(), planMode: z.boolean().optional(), @@ -343,6 +350,7 @@ export const KimiConfigPatchSchema = z defaultProvider: z.string().optional(), defaultModel: z.string().optional(), defaultSubagentModel: z.string().optional(), + defaultSubagentThinkingEffort: z.string().optional(), models: z.record(z.string(), ModelAliasPatchSchema).optional(), thinking: ThinkingConfigPatchSchema.optional(), planMode: z.boolean().optional(), diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index e330668e78..c92bbcae57 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -478,6 +478,7 @@ export function configToTomlData(config: KimiConfig): Record { 'defaultProvider', 'defaultModel', 'defaultSubagentModel', + 'defaultSubagentThinkingEffort', 'planMode', 'yolo', 'defaultPermissionMode', diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 1232fb0928..487493cb40 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -244,6 +244,23 @@ export interface GetSubagentModelResult { /** The effective subagent model alias, or `undefined` when none is configured. */ readonly subagentModel?: string | undefined; } +/** + * Set the session-level subagent thinking-effort override (`dual-model-routing` + * experimental feature). When `effort` is an empty string the live override is + * cleared (subagents fall back to the config default, then the parent's effort). + */ +export interface SetSubagentThinkingPayload { + readonly effort: string; +} +export interface SetSubagentThinkingResult { + /** The effective subagent thinking effort, or `undefined` when none is + * configured (subagents will inherit the parent's effort). */ + readonly subagentThinkingEffort?: string | undefined; +} +export interface GetSubagentThinkingResult { + /** The effective subagent thinking effort, or `undefined` when none is configured. */ + readonly subagentThinkingEffort?: string | undefined; +} export interface CancelPlanPayload { readonly id?: string; } @@ -510,6 +527,8 @@ export interface SessionAPI extends AgentAPIWithId { getSessionMetadata: (payload: EmptyPayload) => SessionMeta; setSubagentModel: (payload: SetSubagentModelPayload) => SetSubagentModelResult; getSubagentModel: (payload: EmptyPayload) => GetSubagentModelResult; + setSubagentThinking: (payload: SetSubagentThinkingPayload) => SetSubagentThinkingResult; + getSubagentThinking: (payload: EmptyPayload) => GetSubagentThinkingResult; listSkills: (payload: EmptyPayload) => readonly SkillSummary[]; listPluginCommands: (payload: EmptyPayload) => readonly PluginCommandDef[]; listMcpServers: (payload: EmptyPayload) => readonly McpServerInfo[]; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 8e2f5798bf..0c5afd97e3 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -107,6 +107,7 @@ import type { GetKimiConfigPayload, GetPluginInfoPayload, GetSubagentModelResult, + GetSubagentThinkingResult, InstallPluginPayload, ImportContextPayload, ListSessionsPayload, @@ -136,6 +137,8 @@ import type { SetPluginMcpServerEnabledPayload, SetSubagentModelPayload, SetSubagentModelResult, + SetSubagentThinkingPayload, + SetSubagentThinkingResult, SetThinkingPayload, SkillSummary, PluginCommandDef, @@ -845,6 +848,20 @@ export class KimiCore implements PromisableMethods { return this.sessionApi(sessionId).getSubagentModel(payload); } + setSubagentThinking({ + sessionId, + ...payload + }: SessionScopedPayload): SetSubagentThinkingResult { + return this.sessionApi(sessionId).setSubagentThinking(payload); + } + + getSubagentThinking({ + sessionId, + ...payload + }: SessionScopedPayload): GetSubagentThinkingResult { + return this.sessionApi(sessionId).getSubagentThinking(payload); + } + enterPlan({ sessionId, ...payload }: SessionAgentPayload) { return this.sessionApi(sessionId).enterPlan(payload); } diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 06f0f7d875..f58393b06d 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -205,6 +205,14 @@ export class Session { * falls back to `config.defaultSubagentModel`, then the parent's model. */ private subagentModelAlias: string | undefined; + /** + * Live session override for the subagent thinking effort (the + * `dual-model-routing` experimental feature). When set and the flag is + * enabled, subagents use this effort instead of the parent's. When unset, + * falls back to `config.defaultSubagentThinkingEffort`, then the parent's + * thinking effort. + */ + private subagentThinkingEffort: string | undefined; constructor(public readonly options: SessionOptions) { // Attach the per-session log sink up front so the constructor's @@ -355,6 +363,7 @@ export class Session { await this.skillsReady; this.log.info('session resume', { app_version: this.options.appVersion }); const { agents, additionalDirs = [] } = await this.readMetadata(); + this.rehydrateSubagentOverrides(); const cwd = this.toolKaos.getcwd(); this.sessionAdditionalDirs = await resolveWorkspaceAdditionalDirs( this.systemContextKaos(cwd), @@ -603,9 +612,84 @@ export class Session { return this.subagentModelAlias ?? this.options.config?.defaultSubagentModel; } - /** Set or clear the live session subagent model override. */ + /** + * Set or clear the live session subagent model override. Persisted into + * session metadata (`custom.subagentModelAlias`) so it survives /reload, + * resume, and fork; rehydrated in `resume()`. + */ setSubagentModelAlias(alias: string | undefined): void { this.subagentModelAlias = alias; + this.persistSubagentOverrides(); + } + + /** + * The live subagent thinking-effort override for this session + * (`dual-model-routing` experimental feature). Set via the + * `setSubagentThinking` RPC; takes precedence over + * `config.defaultSubagentThinkingEffort`. + * + * Returns `undefined` when the flag is OFF, so the status surface and any + * consumer that does not independently gate on the flag never observes a + * subagent thinking effort. The runtime resolver + * (`resolveSubagentThinkingEffort`) also checks the flag, but gating here is + * the single source of truth that keeps `getStatus`, the TUI footer, and the + * sync paths correct regardless of who calls this accessor. + */ + getSubagentThinkingEffort(): string | undefined { + if (!this.experimentalFlags.enabled('dual-model-routing')) return undefined; + return this.subagentThinkingEffort ?? this.options.config?.defaultSubagentThinkingEffort; + } + + /** + * Set or clear the live session subagent thinking-effort override. Persisted + * into session metadata (`custom.subagentThinkingEffort`) so it survives + * /reload, resume, and fork; rehydrated in `resume()`. + */ + setSubagentThinkingEffort(effort: string | undefined): void { + this.subagentThinkingEffort = effort; + this.persistSubagentOverrides(); + } + + /** + * Fire-and-forget persistence of the live subagent overrides into + * `metadata.custom`, matching the pattern used elsewhere in Session (e.g. + * `createAgent`). Failures are non-fatal: a failed write only means the + * override will not survive a resume/reload, not a runtime failure. + */ + private persistSubagentOverrides(): void { + const custom: Record = { ...this.metadata.custom }; + if (this.subagentModelAlias !== undefined) { + custom['subagentModelAlias'] = this.subagentModelAlias; + } else { + delete custom['subagentModelAlias']; + } + if (this.subagentThinkingEffort !== undefined) { + custom['subagentThinkingEffort'] = this.subagentThinkingEffort; + } else { + delete custom['subagentThinkingEffort']; + } + this.metadata = { ...this.metadata, custom }; + void this.writeMetadata().catch((error: unknown) => { + this.log.warn('failed to persist subagent overrides', { error }); + }); + } + + /** + * Rehydrate the live subagent overrides from persisted session metadata. + * Called from `resume()` once metadata has been read. The accessors gate on + * the flag, so unconditional rehydration is safe. + */ + private rehydrateSubagentOverrides(): void { + const custom = this.metadata.custom; + if (custom === undefined || custom === null) return; + const modelAlias = custom['subagentModelAlias']; + if (typeof modelAlias === 'string' && modelAlias.length > 0) { + this.subagentModelAlias = modelAlias; + } + const effort = custom['subagentThinkingEffort']; + if (typeof effort === 'string' && effort.length > 0) { + this.subagentThinkingEffort = effort; + } } /** diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts index d3681d4162..3daec8d1e4 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -17,6 +17,7 @@ import type { GetBackgroundOutputPayload, GetBackgroundPayload, GetSubagentModelResult, + GetSubagentThinkingResult, ImportContextPayload, McpServerInfo, McpStartupMetrics, @@ -31,6 +32,8 @@ import type { SetPermissionPayload, SetSubagentModelPayload, SetSubagentModelResult, + SetSubagentThinkingPayload, + SetSubagentThinkingResult, SetThinkingPayload, SkillSummary, PluginCommandDef, @@ -167,7 +170,30 @@ export class SessionAPIImpl implements PromisableMethods { } setSubagentModel(payload: SetSubagentModelPayload): SetSubagentModelResult { + if (!this.session.experimentalFlags.enabled('dual-model-routing')) { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + 'Subagent model routing requires the "dual-model-routing" experimental flag to be enabled.', + ); + } const alias = payload.model.trim(); + if (alias.length > 0) { + // Validate the alias resolves before storing it so the next subagent + // spawn fails fast with a clear message instead of deep inside provider + // resolution. Mirrors `Agent.setModel` (src/agent/index.ts). + const providerManager = this.session.options.providerManager; + if (providerManager !== undefined) { + providerManager.resolveProviderConfig(alias); + } else { + const models = this.session.options.config?.models; + if (models === undefined || models[alias] === undefined) { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + `Model "${alias}" is not configured in config.toml. Add a [models."${alias}"] entry with max_context_size.`, + ); + } + } + } this.session.setSubagentModelAlias(alias.length > 0 ? alias : undefined); return { subagentModel: this.session.getSubagentModel() }; } @@ -176,6 +202,22 @@ export class SessionAPIImpl implements PromisableMethods { return { subagentModel: this.session.getSubagentModel() }; } + setSubagentThinking(payload: SetSubagentThinkingPayload): SetSubagentThinkingResult { + if (!this.session.experimentalFlags.enabled('dual-model-routing')) { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + 'Subagent thinking-effort routing requires the "dual-model-routing" experimental flag to be enabled.', + ); + } + const effort = payload.effort.trim(); + this.session.setSubagentThinkingEffort(effort.length > 0 ? effort : undefined); + return { subagentThinkingEffort: this.session.getSubagentThinkingEffort() }; + } + + getSubagentThinking(_payload: EmptyPayload): GetSubagentThinkingResult { + return { subagentThinkingEffort: this.session.getSubagentThinkingEffort() }; + } + async enterPlan({ agentId, ...payload }: AgentScopedPayload) { return (await this.getAgent(agentId)).enterPlan(payload); } diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 1df55e148d..030fc458fb 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -182,7 +182,7 @@ export class SessionSubagentHost { const completion = this.runWithActiveChild(agentId, options, async (runOptions) => { this.emitSubagentSpawned(parent, agentId, profileName, runOptions); try { - child.config.update({ modelAlias: this.resolveSubagentModelAlias(parent) }); + child.config.update({ modelAlias: this.resolveSubagentModelAlias(parent), thinkingEffort: this.resolveSubagentThinkingEffort(parent) }); return await this.runPromptTurn(parent, agentId, child, profileName, runOptions); } catch (error) { this.emitSubagentFailed(parent, agentId, runOptions, error); @@ -198,7 +198,7 @@ export class SessionSubagentHost { const completion = this.runWithActiveChild(agentId, options, async (runOptions) => { try { runOptions.signal.throwIfAborted(); - child.config.update({ modelAlias: this.resolveSubagentModelAlias(parent) }); + child.config.update({ modelAlias: this.resolveSubagentModelAlias(parent), thinkingEffort: this.resolveSubagentThinkingEffort(parent) }); this.emitSubagentStarted(parent, agentId); const turnId = child.turn.retry('agent-host'); if (turnId === null) { @@ -259,8 +259,15 @@ export class SessionSubagentHost { { parentAgentId: this.ownerAgentId, persistMetadata: false }, ); + // The btw side-question agent replays the main agent's projected history + // (see `useProjectedHistoryFrom` just below): it shares the main agent's + // warm prompt prefix plus a small system reminder. Routing it to a + // different provider/model would cold-start the prefix cache and re-process + // the entire conversation — more usage for a lightweight text-only side + // channel that gains nothing from a dedicated model. So it ALWAYS inherits + // the main agent's model and effort, even when `dual-model-routing` is on. child.config.update({ - modelAlias: this.resolveSubagentModelAlias(parent), + modelAlias: parent.config.modelAlias, thinkingEffort: parent.config.thinkingEffort, systemPrompt: parent.config.systemPrompt, }); @@ -318,15 +325,21 @@ export class SessionSubagentHost { } /** - * Resolve the model alias a subagent should run on. When the - * `dual-model-routing` experimental flag is enabled, subagents use the - * session's live subagent-model override (set via `/model`) or fall back to - * `config.defaultSubagentModel`. When the flag is off, or no subagent model - * is configured, subagents inherit the parent agent's live model. + * Resolve the model alias a delegated subagent (spawn/resume/retry) should + * run on. When the `dual-model-routing` experimental flag is enabled, + * subagents use the session's live subagent-model override (set via + * `/model`) or fall back to `config.defaultSubagentModel`. When the flag is + * off, or no subagent model is configured, subagents inherit the parent + * agent's live model. * * `Session.getSubagentModel()` is the single flag-aware source of truth — it * returns `undefined` when the flag is off, so this resolver falls through to * the parent model automatically. + * + * The btw (`startBtw`) side-question agent is the deliberate exception: it + * always inherits the parent agent's model regardless of the flag, because it + * shares the main agent's warm prompt-cache prefix. `startBtw` bypasses this + * resolver and reads `parent.config.modelAlias` directly. */ private resolveSubagentModelAlias(parent: Agent): string | undefined { const subagentModel = this.session.getSubagentModel(); @@ -334,6 +347,29 @@ export class SessionSubagentHost { return parent.config.modelAlias; } + /** + * Resolve the thinking effort a delegated subagent (spawn/resume/retry) + * should run with. When the `dual-model-routing` experimental flag is + * enabled, subagents use the session's live subagent-thinking-effort override + * (set via `/model`) or fall back to `config.defaultSubagentThinkingEffort`. + * When the flag is off, or no subagent thinking effort is configured, + * subagents inherit the parent agent's live thinking effort. + * + * `Session.getSubagentThinkingEffort()` is the single flag-aware source of + * truth — it returns `undefined` when the flag is off, so this resolver + * falls through to the parent effort automatically. + * + * The btw (`startBtw`) side-question agent is the deliberate exception: it + * always inherits the parent agent's effort regardless of the flag. + * `startBtw` bypasses this resolver and reads `parent.config.thinkingEffort` + * directly. + */ + private resolveSubagentThinkingEffort(parent: Agent): string { + const subagentEffort = this.session.getSubagentThinkingEffort(); + if (subagentEffort !== undefined && subagentEffort.length > 0) return subagentEffort; + return parent.config.thinkingEffort; + } + private runWithActiveChild( childId: string, options: RunSubagentOptions, @@ -419,12 +455,13 @@ export class SessionSubagentHost { profile: ResolvedAgentProfile, ): Promise { // When the `dual-model-routing` experimental flag is enabled, subagents use - // a dedicated model (the live session override or config default). When the - // flag is off, subagents inherit the parent agent's model as before. + // a dedicated model and thinking effort (the live session override or + // config default). When the flag is off, subagents inherit the parent + // agent's model and thinking effort as before. child.config.update({ cwd: parent.config.cwd, modelAlias: this.resolveSubagentModelAlias(parent), - thinkingEffort: parent.config.thinkingEffort, + thinkingEffort: this.resolveSubagentThinkingEffort(parent), }); const context = await prepareSystemPromptContext( diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index 9ae86faf26..c60e13c114 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -952,6 +952,20 @@ support_efforts = ["low", "high"] const data = configToTomlData(config); expect(data['default_subagent_model']).toBeUndefined(); }); + + it('round-trips default_subagent_thinking_effort through parse and serialization', () => { + const config = parseConfigString('default_subagent_thinking_effort = "high"\n'); + expect(config.defaultSubagentThinkingEffort).toBe('high'); + + const data = configToTomlData(config); + expect(data['default_subagent_thinking_effort']).toBe('high'); + }); + + it('omits default_subagent_thinking_effort from serialization when unset', () => { + const config = parseConfigString('default_model = "kimi-k3"\n'); + const data = configToTomlData(config); + expect(data['default_subagent_thinking_effort']).toBeUndefined(); + }); }); describe('applyPrintModeConfigDefaults', () => { diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index 1a5ab4b4d4..857e815c3a 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -14,6 +14,8 @@ import type { ResolvedAgentProfile } from '../../src/profile'; import type { SDKSessionRPC } from '../../src/rpc'; import { Session } from '../../src/session'; import { SessionAPIImpl } from '../../src/session/rpc'; +import { ErrorCodes, isKimiError } from '../../src/errors'; +import { FlagResolver } from '../../src/flags/resolver'; import { estimateTokensForMessages } from '../../src/utils/tokens'; import { createScriptedGenerate } from '../agent/harness/scripted-generate'; import { recordingTelemetry, type TelemetryRecord } from '../fixtures/telemetry'; @@ -819,3 +821,135 @@ function wrapReadTextWithError(inner: Kaos, cause: Error): Kaos { }, }); } + +describe('SessionAPIImpl subagent model/thinking RPCs (dual-model-routing)', () => { + // These tests exercise the eager validation, flag gating, and round-trip + // behavior of setSubagentModel/setSubagentThinking without spinning up a + // full agent. The Session is constructed directly so we can inject a flag + // resolver and a minimal provider manager. + + function makeSession(options: { + readonly flagOn: boolean; + readonly models?: Record; + }): Session { + const events: Array> = []; + const flagEnv: Record = options.flagOn + ? { KIMI_CODE_EXPERIMENTAL_DUAL_MODEL_ROUTING: '1' } + : {}; + const extraModels = options.models ?? {}; + return new Session({ + id: 'test-subagent-rpc', + kaos: testKaos, + homedir: '/tmp/kimi-subagent-rpc', + rpc: createSessionRpc(events), + experimentalFlags: new FlagResolver(flagEnv), + providerManager: new ProviderManager({ + config: { + providers: { + test: { type: MOCK_PROVIDER.type, apiKey: MOCK_PROVIDER.apiKey }, + }, + models: { + [MOCK_PROVIDER.model]: { + provider: 'test', + model: MOCK_PROVIDER.model, + maxContextSize: 1_000_000, + }, + ...extraModels, + }, + }, + }), + initializeMainAgent: false, + }); + } + + it('setSubagentModel round-trips a valid alias when the flag is on', () => { + const session = makeSession({ + flagOn: true, + models: { + 'glm-5.2': { provider: 'test', model: 'glm-5.2', maxContextSize: 1_000_000 }, + }, + }); + const api = new SessionAPIImpl(session); + const result = api.setSubagentModel({ model: 'glm-5.2' }); + expect(result).toEqual({ subagentModel: 'glm-5.2' }); + expect(api.getSubagentModel({})).toEqual({ subagentModel: 'glm-5.2' }); + }); + + it('setSubagentModel clears the override when given an empty string', () => { + const session = makeSession({ + flagOn: true, + models: { + 'glm-5.2': { provider: 'test', model: 'glm-5.2', maxContextSize: 1_000_000 }, + }, + }); + const api = new SessionAPIImpl(session); + api.setSubagentModel({ model: 'glm-5.2' }); + expect(api.getSubagentModel({}).subagentModel).toBe('glm-5.2'); + + const result = api.setSubagentModel({ model: '' }); + expect(result.subagentModel).toBeUndefined(); + }); + + it('setSubagentModel throws CONFIG_INVALID for an unknown alias', () => { + const session = makeSession({ flagOn: true }); + const api = new SessionAPIImpl(session); + try { + api.setSubagentModel({ model: 'nonexistent-model' }); + throw new Error('expected setSubagentModel to throw'); + } catch (error: unknown) { + expect(isKimiError(error)).toBe(true); + if (isKimiError(error)) { + expect(error.code).toBe(ErrorCodes.CONFIG_INVALID); + expect(error.message).toContain('nonexistent-model'); + } + } + }); + + it('setSubagentModel throws when the flag is off', () => { + const session = makeSession({ flagOn: false }); + const api = new SessionAPIImpl(session); + try { + api.setSubagentModel({ model: 'mock-model' }); + throw new Error('expected setSubagentModel to throw'); + } catch (error: unknown) { + expect(isKimiError(error)).toBe(true); + if (isKimiError(error)) { + expect(error.code).toBe(ErrorCodes.CONFIG_INVALID); + expect(error.message).toContain('dual-model-routing'); + } + } + }); + + it('setSubagentThinking round-trips and clears when the flag is on', () => { + const session = makeSession({ flagOn: true }); + const api = new SessionAPIImpl(session); + const result = api.setSubagentThinking({ effort: 'high' }); + expect(result).toEqual({ subagentThinkingEffort: 'high' }); + expect(api.getSubagentThinking({})).toEqual({ subagentThinkingEffort: 'high' }); + + const cleared = api.setSubagentThinking({ effort: '' }); + expect(cleared.subagentThinkingEffort).toBeUndefined(); + }); + + it('setSubagentThinking throws when the flag is off', () => { + const session = makeSession({ flagOn: false }); + const api = new SessionAPIImpl(session); + try { + api.setSubagentThinking({ effort: 'high' }); + throw new Error('expected setSubagentThinking to throw'); + } catch (error: unknown) { + expect(isKimiError(error)).toBe(true); + if (isKimiError(error)) { + expect(error.code).toBe(ErrorCodes.CONFIG_INVALID); + expect(error.message).toContain('dual-model-routing'); + } + } + }); + + it('getSubagentModel/getSubagentThinking return undefined when the flag is off', () => { + const session = makeSession({ flagOn: false }); + const api = new SessionAPIImpl(session); + expect(api.getSubagentModel({}).subagentModel).toBeUndefined(); + expect(api.getSubagentThinking({}).subagentThinkingEffort).toBeUndefined(); + }); +}); diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index a6977561db..056ff644b7 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -8,8 +8,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Agent, AgentOptions } from '../../src/agent'; import { AGENT_WIRE_PROTOCOL_VERSION } from '../../src/agent/records'; +import type { KimiConfig, SDKSessionRPC } from '../../src/rpc'; import type { ResolvedAgentProfile } from '../../src/profile'; -import type { SDKSessionRPC } from '../../src/rpc'; import { Session } from '../../src/session'; import { collectGitContext } from '../../src/session/git-context'; import { @@ -1573,10 +1573,450 @@ describe('Session.createAgent', () => { }); }); +describe('SessionSubagentHost thinking-effort separation (dual-model-routing)', () => { + it('passes the session thinking-effort override to child config when the flag is on', async () => { + const parent = testAgent(); + parent.configure(); + await parent.rpc.setPermission({ mode: 'yolo' }); + parent.agent.permission.rules.splice(0, parent.agent.permission.rules.length, { + decision: 'allow', + scope: 'session-runtime', + pattern: 'Read', + }); + + const child = testAgent({ + type: 'sub', + permission: { parent: parent.agent.permission }, + }); + child.mockNextResponse({ + type: 'text', + text: 'Investigated the request and completed the child task end to end. The relevant module was located, its behavior traced through every call site, and the requested change applied and verified against the existing test suite.', + }); + const updateSpy = vi.spyOn(child.agent.config, 'update'); + + const session = fakeSession(parent.agent, child.agent); + // Enable the flag and provide a live thinking-effort override. + const sessionMock = session as unknown as { + experimentalFlags: { enabled: (id: string) => boolean }; + getSubagentThinkingEffort: ReturnType; + }; + sessionMock.experimentalFlags = { enabled: (id: string) => id === 'dual-model-routing' }; + sessionMock.getSubagentThinkingEffort.mockReturnValue('high'); + + const host = new SessionSubagentHost(session, 'main'); + const handle = await host.spawn({ + profileName: 'explore', + parentToolCallId: 'call_agent', + prompt: 'Find the cause', + description: 'Find cause', + runInBackground: false, + signal, + }); + await handle.completion; + + // configureChild's update call must carry the resolved override effort. + // (The mock model normalizes the stored value, so we assert the argument + // passed to update(), not the post-normalization getter.) + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ thinkingEffort: 'high' }), + ); + }); + + it('passes the parent thinking effort to child config when the flag is off', async () => { + const parent = testAgent(); + parent.configure(); + await parent.rpc.setPermission({ mode: 'yolo' }); + parent.agent.permission.rules.splice(0, parent.agent.permission.rules.length, { + decision: 'allow', + scope: 'session-runtime', + pattern: 'Read', + }); + + const child = testAgent({ + type: 'sub', + permission: { parent: parent.agent.permission }, + }); + child.mockNextResponse({ + type: 'text', + text: 'Investigated the request and completed the child task end to end. The relevant module was located, its behavior traced through every call site, and the requested change applied and verified against the existing test suite.', + }); + const updateSpy = vi.spyOn(child.agent.config, 'update'); + + // fakeSession defaults to flag OFF. + const session = fakeSession(parent.agent, child.agent); + const host = new SessionSubagentHost(session, 'main'); + const handle = await host.spawn({ + profileName: 'explore', + parentToolCallId: 'call_agent', + prompt: 'Find the cause', + description: 'Find cause', + runInBackground: false, + signal, + }); + await handle.completion; + + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ thinkingEffort: parent.agent.config.thinkingEffort }), + ); + }); + + it('reapplies the resolved thinking effort when resuming a subagent', async () => { + const parent = testAgent(); + parent.configure(); + parent.agent.permission.setMode('yolo'); + + const child = testAgent(); + child.configure({ tools: ['Read'] }); + child.agent.config.update({ modelAlias: 'stale-model-from-initial-spawn' }); + child.agent.useProfile( + profile({ name: 'explore', tools: ['Read'], systemPrompt: 'explore prompt' }), + ); + child.agent.context.appendUserMessage([{ type: 'text', text: 'Earlier context' }]); + child.mockNextResponse({ + type: 'text', + text: 'Resumed the subagent from its earlier context and carried the task through to completion, then reported a full and detailed technical summary so the parent agent can continue without repeating prior work.', + }); + const updateSpy = vi.spyOn(child.agent.config, 'update'); + + const session = fakeSession(parent.agent, child.agent, { + 'agent-0': { homedir: '/tmp/kimi-session/agents/agent-0', type: 'sub', parentAgentId: 'main' }, + }); + const sessionMock = session as unknown as { + experimentalFlags: { enabled: (id: string) => boolean }; + getSubagentThinkingEffort: ReturnType; + }; + sessionMock.experimentalFlags = { enabled: (id: string) => id === 'dual-model-routing' }; + sessionMock.getSubagentThinkingEffort.mockReturnValue('high'); + + const host = new SessionSubagentHost(session, 'main'); + const handle = await host.resume('agent-0', { + parentToolCallId: 'call_agent', + prompt: 'Continue from context', + description: 'Continue work', + runInBackground: false, + signal, + }); + await handle.completion; + + // resume's realignment update must carry the resolved override effort. + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ thinkingEffort: 'high' }), + ); + }); +}); + +describe('SessionSubagentHost dual-model-routing (model + btw exception)', () => { + it('passes the session subagent model override to child config on spawn when the flag is on', async () => { + const parent = testAgent(); + parent.configure(); + await parent.rpc.setPermission({ mode: 'yolo' }); + parent.agent.permission.rules.splice(0, parent.agent.permission.rules.length, { + decision: 'allow', + scope: 'session-runtime', + pattern: 'Read', + }); + + const child = testAgent({ + type: 'sub', + permission: { parent: parent.agent.permission }, + }); + registerAlias(child.agent.kimiConfig, 'glm-5.2'); + child.mockNextResponse({ + type: 'text', + text: 'Investigated the request and completed the child task end to end. The relevant module was located, its behavior traced through every call site, and the requested change applied and verified against the existing test suite.', + }); + const updateSpy = vi.spyOn(child.agent.config, 'update'); + + const session = fakeSession(parent.agent, child.agent, {}, { + dualModelRouting: true, + subagentModel: 'glm-5.2', + }); + const host = new SessionSubagentHost(session, 'main'); + const handle = await host.spawn({ + profileName: 'explore', + parentToolCallId: 'call_agent', + prompt: 'Find the cause', + description: 'Find cause', + runInBackground: false, + signal, + }); + await handle.completion; + + // configureChild's update call must carry the subagent override model, + // not the parent model. (Assert the update() argument — the mock model + // normalizes the stored value.) + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ modelAlias: 'glm-5.2' }), + ); + expect(updateSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ modelAlias: parent.agent.config.modelAlias }), + ); + }); + + it('passes the session subagent model override to child config on resume when the flag is on', async () => { + const parent = testAgent(); + parent.configure(); + parent.agent.permission.setMode('yolo'); + + const child = testAgent(); + registerAlias(child.agent.kimiConfig, 'glm-5.2'); + child.configure({ tools: ['Read'] }); + child.agent.config.update({ modelAlias: 'stale-model-from-initial-spawn' }); + child.agent.useProfile( + profile({ name: 'explore', tools: ['Read'], systemPrompt: 'explore prompt' }), + ); + child.agent.context.appendUserMessage([{ type: 'text', text: 'Earlier context' }]); + child.mockNextResponse({ + type: 'text', + text: 'Resumed the subagent from its earlier context and carried the task through to completion, then reported a full and detailed technical summary so the parent agent can continue without repeating prior work.', + }); + const updateSpy = vi.spyOn(child.agent.config, 'update'); + + const session = fakeSession(parent.agent, child.agent, { + 'agent-0': { homedir: '/tmp/kimi-session/agents/agent-0', type: 'sub', parentAgentId: 'main' }, + }, { + dualModelRouting: true, + subagentModel: 'glm-5.2', + }); + const host = new SessionSubagentHost(session, 'main'); + const handle = await host.resume('agent-0', { + parentToolCallId: 'call_agent', + prompt: 'Continue from context', + description: 'Continue work', + runInBackground: false, + signal, + }); + await handle.completion; + + // resume's realignment update must carry the subagent override model. + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ modelAlias: 'glm-5.2' }), + ); + }); + + it('passes the session subagent model override to child config on retry when the flag is on', async () => { + const parent = testAgent(); + parent.configure(); + parent.newEvents(); + + const summary = + 'Recovered from a transient failure by retrying the latest subagent step with the original context intact, then completed the delegated work with a detailed enough summary for the parent to continue confidently. '.repeat( + 2, + ); + let generateCalls = 0; + const generate: GenerateFn = async ( + _provider, + _systemPrompt, + _tools, + _history, + callbacks, + ) => { + generateCalls += 1; + if (generateCalls === 1) throw new APIStatusError(429, 'Rate limited', 'req-429'); + await callbacks?.onMessagePart?.({ type: 'text', text: summary }); + return textResult(summary); + }; + const child = testAgent({ + generate, + initialConfig: { providers: {}, loopControl: { maxRetriesPerStep: 1 } }, + }); + registerAlias(child.agent.kimiConfig, 'glm-5.2'); + child.configure(); + const updateSpy = vi.spyOn(child.agent.config, 'update'); + + const session = fakeSession(parent.agent, child.agent, {}, { + dualModelRouting: true, + subagentModel: 'glm-5.2', + }); + const host = new SessionSubagentHost(session, 'main'); + const handle = await host.spawn({ + profileName: 'coder', + parentToolCallId: 'call_agent', + prompt: 'Implement the retry-safe change', + description: 'Fix rate-limit retry', + runInBackground: false, + signal, + }); + await expect(handle.completion).rejects.toThrow('Rate limited'); + + const retryHandle = await host.retry(handle.agentId, { + parentToolCallId: 'call_agent', + prompt: 'Implement the retry-safe change', + description: 'Fix rate-limit retry', + runInBackground: false, + signal, + }); + await retryHandle.completion; + + // retry's realignment update must carry the subagent override model. + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ modelAlias: 'glm-5.2' }), + ); + }); + + it('passes the parent model to child config on spawn when the flag is off', async () => { + const parent = testAgent(); + parent.configure(); + await parent.rpc.setPermission({ mode: 'yolo' }); + parent.agent.permission.rules.splice(0, parent.agent.permission.rules.length, { + decision: 'allow', + scope: 'session-runtime', + pattern: 'Read', + }); + + const child = testAgent({ + type: 'sub', + permission: { parent: parent.agent.permission }, + }); + child.mockNextResponse({ + type: 'text', + text: 'Investigated the request and completed the child task end to end. The relevant module was located, its behavior traced through every call site, and the requested change applied and verified against the existing test suite.', + }); + const updateSpy = vi.spyOn(child.agent.config, 'update'); + + // flag off (default) — child inherits the parent model. + const session = fakeSession(parent.agent, child.agent); + const host = new SessionSubagentHost(session, 'main'); + const handle = await host.spawn({ + profileName: 'explore', + parentToolCallId: 'call_agent', + prompt: 'Find the cause', + description: 'Find cause', + runInBackground: false, + signal, + }); + await handle.completion; + + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ modelAlias: parent.agent.config.modelAlias }), + ); + }); + + it('reapplies the resolved thinking effort on retry when the flag is on', async () => { + const parent = testAgent(); + parent.configure(); + parent.newEvents(); + + const summary = + 'Recovered from a transient failure by retrying the latest subagent step with the original context intact, then completed the delegated work with a detailed enough summary for the parent to continue confidently. '.repeat( + 2, + ); + let generateCalls = 0; + const generate: GenerateFn = async ( + _provider, + _systemPrompt, + _tools, + _history, + callbacks, + ) => { + generateCalls += 1; + if (generateCalls === 1) throw new APIStatusError(429, 'Rate limited', 'req-429'); + await callbacks?.onMessagePart?.({ type: 'text', text: summary }); + return textResult(summary); + }; + const child = testAgent({ + generate, + initialConfig: { providers: {}, loopControl: { maxRetriesPerStep: 1 } }, + }); + child.configure(); + const updateSpy = vi.spyOn(child.agent.config, 'update'); + + const session = fakeSession(parent.agent, child.agent, {}, { + dualModelRouting: true, + subagentThinkingEffort: 'high', + }); + const host = new SessionSubagentHost(session, 'main'); + const handle = await host.spawn({ + profileName: 'coder', + parentToolCallId: 'call_agent', + prompt: 'Implement the retry-safe change', + description: 'Fix rate-limit retry', + runInBackground: false, + signal, + }); + await expect(handle.completion).rejects.toThrow('Rate limited'); + + const retryHandle = await host.retry(handle.agentId, { + parentToolCallId: 'call_agent', + prompt: 'Implement the retry-safe change', + description: 'Fix rate-limit retry', + runInBackground: false, + signal, + }); + await retryHandle.completion; + + // retry's realignment update must carry the resolved override effort. + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ thinkingEffort: 'high' }), + ); + }); + + it('startBtw always inherits the parent model and effort even when dual-model-routing is on', async () => { + const parent = testAgent(); + parent.configure(); + + const child = testAgent({ type: 'sub' }); + child.configure(); + const updateSpy = vi.spyOn(child.agent.config, 'update'); + + // Flag ON with subagent overrides active — startBtw must STILL use the + // parent's model and effort (cache-affinity rationale in subagent-host.ts). + const session = fakeSession(parent.agent, child.agent, {}, { + dualModelRouting: true, + subagentModel: 'glm-5.2', + subagentThinkingEffort: 'high', + }); + const host = new SessionSubagentHost(session, 'main'); + await host.startBtw(); + + // The btw child's config.update must carry the PARENT's modelAlias and + // thinkingEffort — never the subagent override. + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + modelAlias: parent.agent.config.modelAlias, + thinkingEffort: parent.agent.config.thinkingEffort, + }), + ); + expect(updateSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ modelAlias: 'glm-5.2' }), + ); + expect(updateSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ thinkingEffort: 'high' }), + ); + }); +}); + +/** Register an extra model alias in a test agent's config so provider + * resolution succeeds when a subagent is routed to it. */ +function registerAlias(config: KimiConfig | undefined, alias: string): void { + if (config === undefined) return; + if (config.models === undefined) config.models = {}; + config.models[alias] = { + provider: 'test-provider', + model: 'mock-model', + maxContextSize: 1_000_000, + }; + if (config.providers === undefined) config.providers = {}; + if (config.providers['test-provider'] === undefined) { + config.providers['test-provider'] = { + type: 'kimi', + apiKey: 'test-key', + }; + } +} + function fakeSession( parent: Agent, child: Agent, metadataAgents: Session['metadata']['agents'] = {}, + overrides: { + /** Return value for `session.getSubagentModel()`. */ + readonly subagentModel?: string; + /** Return value for `session.getSubagentThinkingEffort()`. */ + readonly subagentThinkingEffort?: string; + /** Whether `dual-model-routing` is enabled. Defaults to off. */ + readonly dualModelRouting?: boolean; + } = {}, ) { const agents = new Map([['main', parent]]); if (metadataAgents['agent-0'] !== undefined) { @@ -1597,8 +2037,11 @@ function fakeSession( systemContextKaos: vi.fn((cwd: string) => parent.kaos.withCwd(cwd)), getReadyAgent: vi.fn((id: string) => agents.get(id)), // dual-model-routing defaults to off; subagents inherit the parent model. - getSubagentModel: vi.fn(() => undefined), - experimentalFlags: { enabled: () => false }, + getSubagentModel: vi.fn(() => overrides.subagentModel ?? undefined), + getSubagentThinkingEffort: vi.fn(() => overrides.subagentThinkingEffort ?? undefined), + experimentalFlags: { + enabled: (id: string) => id === 'dual-model-routing' && overrides.dualModelRouting === true, + }, ensureAgentResumed: vi.fn(async (id: string) => { const agent = agents.get(id); if (agent === undefined) { diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 454d9002d0..193eee8254 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -91,6 +91,10 @@ export interface SetSessionModelRpcResult { } export interface SetSessionSubagentModelRpcInput extends SessionIdRpcInput { + /** + * The subagent model override. Pass an empty string to clear the override so + * subagents fall back to the config default / parent model. + */ readonly model: string; } @@ -102,6 +106,22 @@ export interface GetSessionSubagentModelRpcResult { readonly subagentModel?: string | undefined; } +export interface SetSessionSubagentThinkingRpcInput extends SessionIdRpcInput { + /** + * The subagent thinking-effort override. Pass an empty string to clear the + * override so subagents fall back to the config default / parent effort. + */ + readonly effort: string; +} + +export interface SetSessionSubagentThinkingRpcResult { + readonly subagentThinkingEffort?: string | undefined; +} + +export interface GetSessionSubagentThinkingRpcResult { + readonly subagentThinkingEffort?: string | undefined; +} + export interface SetSessionThinkingRpcInput extends SessionIdRpcInput { readonly effort: string; } @@ -446,6 +466,25 @@ export abstract class SDKRpcClientBase { }); } + async setSubagentThinking( + input: SetSessionSubagentThinkingRpcInput, + ): Promise { + const rpc = await this.getRpc(); + return rpc.setSubagentThinking({ + sessionId: input.sessionId, + effort: input.effort, + }); + } + + async getSubagentThinking( + input: SessionIdRpcInput, + ): Promise { + const rpc = await this.getRpc(); + return rpc.getSubagentThinking({ + sessionId: input.sessionId, + }); + } + async setThinking(input: SetSessionThinkingRpcInput): Promise { const rpc = await this.getRpc(); return rpc.setThinking({ @@ -602,9 +641,18 @@ export abstract class SDKRpcClientBase { sessionId: input.sessionId, agentId, }); - const subagentModel = await rpc.getSubagentModel({ - sessionId: input.sessionId, - }); + // These RPCs are part of the `dual-model-routing` experimental feature. + // Against an older core that has not registered them, the local RPC proxy + // has no such key, so guard with a typeof check to avoid throwing. + const subagentModel = + typeof rpc.getSubagentModel === 'function' + ? (await rpc.getSubagentModel({ sessionId: input.sessionId })).subagentModel + : undefined; + const subagentThinking = + typeof rpc.getSubagentThinking === 'function' + ? (await rpc.getSubagentThinking({ sessionId: input.sessionId })) + .subagentThinkingEffort + : undefined; const maxContextTokens = config.modelCapabilities?.max_context_tokens ?? 0; const contextTokens = context.tokenCount; const contextUsage = maxContextTokens > 0 ? contextTokens / maxContextTokens : 0; @@ -612,7 +660,8 @@ export abstract class SDKRpcClientBase { usage.byModel !== undefined || usage.total !== undefined || usage.currentTurn !== undefined; return { model: config.modelAlias ?? config.provider?.model, - subagentModel: subagentModel.subagentModel, + subagentModel, + subagentThinkingEffort: subagentThinking, thinkingEffort: config.thinkingEffort, permission: permission.mode, planMode: plan !== null, diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 2ce2d4d0a9..116c563eaf 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -217,6 +217,16 @@ export class Session { await this.rpc.setSubagentModel({ sessionId: this.id, model }); } + /** + * Set the session-level subagent thinking-effort override (the + * `dual-model-routing` experimental feature). Pass an empty string to clear + * the override so subagents fall back to the config default / parent effort. + */ + async setSubagentThinkingEffort(effort: string): Promise { + this.ensureOpen(); + await this.rpc.setSubagentThinking({ sessionId: this.id, effort }); + } + async setPermission(mode: PermissionMode): Promise { this.ensureOpen(); if (!isPermissionMode(mode)) { diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index e89c4bc421..b4a8dea583 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -231,7 +231,18 @@ export interface SessionUsage { export interface SessionStatus { readonly model?: string; + /** + * Effective subagent model override (the `dual-model-routing` experimental + * feature). Undefined when the flag is off or nothing is configured, meaning + * subagents inherit the main agent's model. + */ readonly subagentModel?: string | undefined; + /** + * Effective subagent thinking-effort override (the `dual-model-routing` + * experimental feature). Undefined when the flag is off or nothing is + * configured, meaning subagents inherit the main agent's thinking effort. + */ + readonly subagentThinkingEffort?: string | undefined; readonly thinkingEffort: string; readonly permission: PermissionMode; readonly planMode: boolean; diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index 9484facf1f..7ed32c6738 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -345,6 +345,17 @@ describe('KimiHarness config API', () => { enabled: false, source: 'default', }, + { + id: 'dual-model-routing', + title: 'Dual model routing (separate subagent model)', + description: + 'Route the main agent and its subagents to different models. Subagents use a dedicated subagent model (configurable via /model) instead of inheriting the main agent model. When disabled, subagents inherit the main model as before.', + surface: 'both', + env: 'KIMI_CODE_EXPERIMENTAL_DUAL_MODEL_ROUTING', + defaultEnabled: false, + enabled: false, + source: 'default', + }, ]); }); diff --git a/packages/node-sdk/test/session-get-status.test.ts b/packages/node-sdk/test/session-get-status.test.ts new file mode 100644 index 0000000000..e2b73d58f4 --- /dev/null +++ b/packages/node-sdk/test/session-get-status.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; + +import { SDKRpcClientBase } from '#/rpc'; + +/** + * A minimal `SDKRpcClientBase` whose `getRpc()` resolves to an injected mock. + * Used to exercise `getStatus` against a controllable RPC surface without + * spinning up a real in-process core. + */ +class StubRpc extends SDKRpcClientBase { + constructor( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly rpc: any, + ) { + super(); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + protected async getRpc(): Promise { + return this.rpc; + } +} + +const SESSION_ID = 'ses_status'; + +/** The non-subagent RPC surface `getStatus` always touches. */ +function baseRpc() { + return { + getConfig: async () => ({ + modelAlias: 'main-model', + thinkingEffort: 'medium', + modelCapabilities: { max_context_tokens: 100_000 }, + }), + getContext: async () => ({ tokenCount: 1234 }), + getPermission: async () => ({ mode: 'auto' }), + getPlan: async () => null, + getSwarmMode: async () => false, + getUsage: async () => ({}), + }; +} + +describe('SDKRpcClientBase.getStatus — subagent overrides', () => { + it('populates subagentModel / subagentThinkingEffort when the RPCs return values', async () => { + const rpc = new StubRpc({ + ...baseRpc(), + getSubagentModel: async () => ({ subagentModel: 'sub-model' }), + getSubagentThinking: async () => ({ subagentThinkingEffort: 'high' }), + }); + + await expect(rpc.getStatus({ sessionId: SESSION_ID })).resolves.toMatchObject({ + model: 'main-model', + subagentModel: 'sub-model', + subagentThinkingEffort: 'high', + thinkingEffort: 'medium', + }); + }); + + it('returns undefined subagent fields when the RPCs report nothing configured', async () => { + const rpc = new StubRpc({ + ...baseRpc(), + getSubagentModel: async () => ({ subagentModel: undefined }), + getSubagentThinking: async () => ({ subagentThinkingEffort: undefined }), + }); + + await expect(rpc.getStatus({ sessionId: SESSION_ID })).resolves.toMatchObject({ + subagentModel: undefined, + subagentThinkingEffort: undefined, + }); + }); + + it('does not throw and leaves subagent fields undefined against an older core lacking the RPCs', async () => { + // Simulate an older core: the subagent methods are not registered on the + // proxy, so the keys are absent entirely (not just undefined). + const rpc = new StubRpc(baseRpc()); + + const status = await rpc.getStatus({ sessionId: SESSION_ID }); + expect(status.subagentModel).toBeUndefined(); + expect(status.subagentThinkingEffort).toBeUndefined(); + // The rest of the status surface is still populated normally. + expect(status).toMatchObject({ + model: 'main-model', + thinkingEffort: 'medium', + contextTokens: 1234, + maxContextTokens: 100_000, + }); + }); +}); From 86e052d17ebdd591f5fd96783ae7a6c523ec9d9c Mon Sep 17 00:00:00 2001 From: Kassie Povinelli Date: Tue, 21 Jul 2026 01:22:53 -0400 Subject: [PATCH 3/7] docs(zh): document dual-model-routing fields in zh configuration docs Mirror the English docs for the dual-model-routing experimental feature: default_subagent_model / default_subagent_thinking_effort rows plus the tip block (btw exception, session create/resume semantics) in config-files.md, and the KIMI_CODE_EXPERIMENTAL_DUAL_MODEL_ROUTING row in env-vars.md. --- docs/zh/configuration/config-files.md | 6 ++++++ docs/zh/configuration/env-vars.md | 1 + 2 files changed, 7 insertions(+) diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 496c9c571d..46e5f8969e 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -98,6 +98,8 @@ timeout = 5 | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `default_model` | `string` | — | 默认模型别名,必须在 `models` 中定义 | +| `default_subagent_model` | `string` | — | 子 Agent 的默认模型别名(实验性 `dual-model-routing` 功能),必须在 `models` 中定义。功能启用且设置该字段时,委派子 Agent 使用此模型,而不是继承 `default_model` | +| `default_subagent_thinking_effort` | `string` | — | 子 Agent 的默认 thinking effort(实验性 `dual-model-routing` 功能)。功能启用且设置该字段时,委派子 Agent 使用此 effort,而不是继承主 Agent 的 effort | | `default_permission_mode` | `string` | `manual` | 新会话的默认权限模式,可选 `manual`(逐次询问)、`yolo`(自动批准工具操作,Agent 仍可能提问)、`auto`(完全自主,Agent 自己做决定,不再提问) | | `default_plan_mode` | `boolean` | `false` | 新会话是否默认以 Plan 模式(先出计划再执行)启动 | | `merge_all_available_skills` | `boolean` | `true` | 是否合并所有目录中的 Agent Skills | @@ -113,6 +115,10 @@ timeout = 5 | `permission` | `table` | — | 初始权限规则 → [`permission`](#permission) | | `hooks` | `array
` | — | 生命周期 hook,详见 [Hooks](../customization/hooks.md) | +::: tip 子 Agent 模型与 thinking effort 默认值 +`default_subagent_model` 和 `default_subagent_thinking_effort` 仅在启用实验性 `dual-model-routing` 功能时生效。委派子 Agent(`task` / `explore` / `AgentSwarm`)使用专用的模型和 effort;旁问(`/btw`)Agent 则始终继承主 Agent 的模型和 effort——它会重放主 Agent 的 prompt 前缀并共享其前缀缓存,把它路由到别处会在冷缓存上重新处理整个对话。这些默认值在会话创建或恢复时读取;会话进行中修改不会影响当前会话,直到重新加载或恢复。会话内的临时覆盖请使用 `/model` 的范围选择器。 +::: + 以下各节对 `providers`、`models`、`thinking`、`loop_control`、`background`、`image`、`services`、`permission` 等嵌套表逐一展开。 ## `providers` diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 87fa45ed6d..6e61d83caa 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -128,6 +128,7 @@ kimi | `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | 限制 AgentSwarm 初始提升并发阶段可同时运行的子 Agent 数量;不设置表示不限制 | 正整数;非法值会立即失败 | | `KIMI_SUBAGENT_TIMEOUT_MS` | 单个子 Agent(`Agent` / `AgentSwarm`)可运行的最长时间(毫秒);优先级高于 `config.toml` 的 `[subagent] timeout_ms`(默认 `7200000`,即 2 小时) | 正整数;非法值回退到配置或默认值 | | `KIMI_CODE_EXPERIMENTAL_FLAG` | 在当前进程启用所有已注册的实验功能 | `1`、`true`、`yes`、`on` | +| `KIMI_CODE_EXPERIMENTAL_DUAL_MODEL_ROUTING` | 启用双模型路由(实验性):让主 Agent 与其子 Agent 使用不同模型。该开关仅启用能力——委派子 Agent 在配置了 `default_subagent_model` 和 `default_subagent_thinking_effort`(或通过 `/model` 实时设置)时使用专用模型和 effort;两者都未设置时仍继承主 Agent 的模型和 effort | `1`、`true`、`yes`、`on` | | `KIMI_SHELL_PATH` | Windows 上覆盖 Git Bash 路径(自动探测失败时使用) | 绝对路径 | | `KIMI_MODEL_MAX_COMPLETION_TOKENS` | 单步 LLM 请求的 `max_completion_tokens` 硬上限,仅对 `kimi` 供应商生效 | 正整数;`0` 或负数禁用 clamp | | `KIMI_MODEL_TEMPERATURE` | 每次请求的采样温度,仅对 `kimi` 供应商生效(全局生效,不依赖 `KIMI_MODEL_NAME`) | 数字,如 `0.3` | From 142292e595cff7bfec85248340109fc507d73ac7 Mon Sep 17 00:00:00 2001 From: Kassie Povinelli Date: Tue, 21 Jul 2026 01:44:40 -0400 Subject: [PATCH 4/7] fix(agent-core): address dual-model-routing review findings - Scope picker: show the configured subagent thinking effort even when the model is inherited, instead of a plain "(inherits main model)" label that dropped the effort suffix. - stripEnvModelConfig: restore default_subagent_model from raw when it points at the runtime-only env alias (mirroring default_model), so saving via /model while KIMI_MODEL_* is active cannot persist __kimi_env_model__ to config.toml. - removeKimiProvider: clear default_subagent_model when the removal deletes its model alias (mirroring default_model), so a stale alias cannot fail provider resolution on the next delegated spawn. Tests: scope-selector inherited-effort label, env-alias restore/drop/keep for the subagent default, provider removal clearing the subagent default. --- .../dialogs/model-scope-selector.ts | 12 ++--- .../dialogs/model-scope-selector.test.ts | 17 +++++++ packages/agent-core/src/config/env-model.ts | 12 ++++- packages/agent-core/src/rpc/core-impl.ts | 8 ++++ .../agent-core/test/config/env-model.test.ts | 24 ++++++++++ .../agent-core/test/harness/runtime.test.ts | 46 +++++++++++++++++++ packages/agent-core/test/session/init.test.ts | 14 ++++++ 7 files changed, 126 insertions(+), 7 deletions(-) diff --git a/apps/kimi-code/src/tui/components/dialogs/model-scope-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-scope-selector.ts index ee1912cb9a..0a0cbe99e7 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-scope-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-scope-selector.ts @@ -37,19 +37,19 @@ function isModelScope(value: string): value is ModelScope { 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], ); - const effortSuffix = - opts.currentSubagentThinkingEffort !== undefined && opts.currentSubagentThinkingEffort.length > 0 - ? ` (effort: ${opts.currentSubagentThinkingEffort})` - : ''; - subagentName = `${model}${effortSuffix}`; + subagentName = hasEffort ? `${model} (effort: ${effort})` : model; } else { - subagentName = '(inherits main model)'; + // 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[] = [ diff --git a/apps/kimi-code/test/tui/components/dialogs/model-scope-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-scope-selector.test.ts index e91a5ba305..eb5e8864db 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-scope-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-scope-selector.test.ts @@ -73,6 +73,23 @@ describe('ModelScopeSelectorComponent', () => { expect(out.some((l) => l.includes('Subagents — GLM-5.2 (effort: high)'))).toBe(true); }); + it('shows the effort when the model is inherited but an effort is configured', () => { + const picker = new ModelScopeSelectorComponent({ + currentModel: 'kimi-k3', + currentSubagentModel: undefined, + currentSubagentThinkingEffort: 'high', + availableModels, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = picker.render(120).map(strip); + + expect(out.some((l) => l.includes('Subagents — (inherits main model, effort: high)'))).toBe( + true, + ); + }); + it('fires onSelect with "main" for the main option and "subagent" for the subagent option', () => { const onSelect = vi.fn(); const picker = new ModelScopeSelectorComponent({ diff --git a/packages/agent-core/src/config/env-model.ts b/packages/agent-core/src/config/env-model.ts index 2933da52c6..519d61a8c7 100644 --- a/packages/agent-core/src/config/env-model.ts +++ b/packages/agent-core/src/config/env-model.ts @@ -171,7 +171,8 @@ export function stripEnvModelConfig(config: KimiConfig): KimiConfig { const hasProvider = ENV_MODEL_PROVIDER_KEY in config.providers; const hasModel = config.models !== undefined && ENV_MODEL_ALIAS_KEY in config.models; const defaultIsEnv = config.defaultModel === ENV_MODEL_ALIAS_KEY; - if (!hasProvider && !hasModel && !defaultIsEnv) return config; + const subagentDefaultIsEnv = config.defaultSubagentModel === ENV_MODEL_ALIAS_KEY; + if (!hasProvider && !hasModel && !defaultIsEnv && !subagentDefaultIsEnv) return config; const providers = { ...config.providers }; delete providers[ENV_MODEL_PROVIDER_KEY]; @@ -192,6 +193,10 @@ export function stripEnvModelConfig(config: KimiConfig): KimiConfig { // synthetic provider/model exist), so these may be env values; an unset raw // field restores to undefined (i.e. drops it). ...(defaultIsEnv ? { defaultModel: rawDefaultModel(config) } : {}), + // Same guard for the subagent default: the env alias is runtime-only, so a + // `default_subagent_model` pointing at it (e.g. saved via /model while + // KIMI_MODEL_* is active) is restored from raw rather than persisted. + ...(subagentDefaultIsEnv ? { defaultSubagentModel: rawDefaultSubagentModel(config) } : {}), thinking: rawThinking(config), }; } @@ -201,6 +206,11 @@ function rawDefaultModel(config: KimiConfig): string | undefined { return typeof raw === 'string' ? raw : undefined; } +function rawDefaultSubagentModel(config: KimiConfig): string | undefined { + const raw = config.raw?.['default_subagent_model']; + return typeof raw === 'string' ? raw : undefined; +} + function rawThinking(config: KimiConfig): ThinkingConfig | undefined { const raw = config.raw?.['thinking']; return typeof raw === 'object' && raw !== null && !Array.isArray(raw) diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 0c5afd97e3..486de43644 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -670,6 +670,7 @@ export class KimiCore implements PromisableMethods { delete config.providers[input.providerId]; let removedDefault = false; + let removedSubagentDefault = false; const existingModels = config.models ?? {}; for (const [key, model] of Object.entries(existingModels)) { if ( @@ -680,6 +681,7 @@ export class KimiCore implements PromisableMethods { ) { delete existingModels[key]; if (config.defaultModel === key) removedDefault = true; + if (config.defaultSubagentModel === key) removedSubagentDefault = true; } } config.models = existingModels; @@ -688,6 +690,12 @@ export class KimiCore implements PromisableMethods { config.defaultModel = undefined; } + // The subagent default (`dual-model-routing`) must not outlive its model + // either — a stale alias would fail provider resolution on the next spawn. + if (removedSubagentDefault) { + config.defaultSubagentModel = undefined; + } + if (config.defaultProvider === input.providerId) { config.defaultProvider = undefined; } diff --git a/packages/agent-core/test/config/env-model.test.ts b/packages/agent-core/test/config/env-model.test.ts index b9ac80e127..670fb26425 100644 --- a/packages/agent-core/test/config/env-model.test.ts +++ b/packages/agent-core/test/config/env-model.test.ts @@ -220,6 +220,30 @@ describe('stripEnvModelConfig (write-back guard)', () => { expect(stripped.providers[ENV_MODEL_PROVIDER_KEY]).toBeUndefined(); expect(stripped.models?.[ENV_MODEL_ALIAS_KEY]).toBeUndefined(); }); + + it('restores an env-alias default_subagent_model from raw instead of persisting it', () => { + // Simulates /model saving the env alias as the subagent default while + // KIMI_MODEL_* is active; raw carries the on-disk value. + const runtime = applyEnvModelConfig(getDefaultConfig(), { ...MIN }); + runtime.defaultSubagentModel = ENV_MODEL_ALIAS_KEY; + runtime.raw = { default_subagent_model: 'glm-5.2' }; + const stripped = stripEnvModelConfig(runtime); + expect(stripped.defaultSubagentModel).toBe('glm-5.2'); + }); + + it('drops an env-alias default_subagent_model when raw has none', () => { + const runtime = applyEnvModelConfig(getDefaultConfig(), { ...MIN }); + runtime.defaultSubagentModel = ENV_MODEL_ALIAS_KEY; + const stripped = stripEnvModelConfig(runtime); + expect(stripped.defaultSubagentModel).toBeUndefined(); + }); + + it('keeps a non-env default_subagent_model', () => { + const runtime = applyEnvModelConfig(getDefaultConfig(), { ...MIN }); + runtime.defaultSubagentModel = 'glm-5.2'; + const stripped = stripEnvModelConfig(runtime); + expect(stripped.defaultSubagentModel).toBe('glm-5.2'); + }); }); describe('writeConfigFile never persists the env model', () => { diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts index 227f498258..96c83ef726 100644 --- a/packages/agent-core/test/harness/runtime.test.ts +++ b/packages/agent-core/test/harness/runtime.test.ts @@ -267,6 +267,52 @@ micro_compaction = false expect(session?.getAdditionalDirs()).toContain(normalize(sharedDir)); }); + it('clears defaultSubagentModel when removeKimiProvider removes its model', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + await mkdir(homeDir, { recursive: true }); + await writeFile( + join(homeDir, 'config.toml'), + `default_model = "default-mock" +default_subagent_model = "sub-glm" + +[providers.test] +type = "kimi" +api_key = "test-key" + +[providers.zai] +type = "openai" +api_key = "zai-key" + +[models."default-mock"] +provider = "test" +model = "default-mock" +max_context_size = 100000 + +[models."sub-glm"] +provider = "zai" +model = "glm-5.2" +max_context_size = 100000 +`, + ); + + const core = new KimiCore(async () => ({}) as never, { homeDir }); + + const updated = await core.removeKimiProvider({ providerId: 'zai' }); + + // The removed provider's model alias is gone, and the subagent default + // that pointed at it is cleared (a stale alias would fail provider + // resolution on the next delegated spawn). + expect(updated.models?.['sub-glm']).toBeUndefined(); + expect(updated.defaultSubagentModel).toBeUndefined(); + // The main default belongs to a different provider and survives. + expect(updated.models?.['default-mock']).toBeDefined(); + expect(updated.defaultModel).toBe('default-mock'); + + const onDisk = await readFile(join(homeDir, 'config.toml'), 'utf-8'); + expect(onDisk).not.toContain('default_subagent_model'); + }); + it('uses the shared OAuth resolver for Moonshot service tokens', async () => { tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); const homeDir = join(tmp, 'home'); diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index 857e815c3a..8093e602aa 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -952,4 +952,18 @@ describe('SessionAPIImpl subagent model/thinking RPCs (dual-model-routing)', () expect(api.getSubagentModel({}).subagentModel).toBeUndefined(); expect(api.getSubagentThinking({}).subagentThinkingEffort).toBeUndefined(); }); + + it('setSubagentModel persists when session metadata has no custom map', () => { + const session = makeSession({ flagOn: true }); + // Legacy state.json may lack the `custom` key; spreading undefined is a + // no-op, so persisting the override must rebuild the map, not throw. + session.metadata = { + ...session.metadata, + custom: undefined as unknown as Record, + }; + const api = new SessionAPIImpl(session); + const result = api.setSubagentModel({ model: 'mock-model' }); + expect(result).toEqual({ subagentModel: 'mock-model' }); + expect(session.metadata.custom['subagentModelAlias']).toBe('mock-model'); + }); }); From 36c6f972c6d0ad6f46943a28ecded923ab5645f5 Mon Sep 17 00:00:00 2001 From: Kassie Povinelli Date: Tue, 21 Jul 2026 02:07:30 -0400 Subject: [PATCH 5/7] test(tui): use real context sizes for kimi-k3 and glm-5.2 Replace placeholder values (262144 / 131072) with the real context window sizes from models.dev: kimi-k3 = 1,048,576, glm-5.2 = 1,000,000. Both are 1M-class models; the old numbers reflected stale data. --- .../test/tui/components/chrome/footer.test.ts | 18 +++++++++--------- .../dialogs/model-scope-selector.test.ts | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index b71ed48ea7..ce86cb130a 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -201,8 +201,8 @@ describe('FooterComponent subagent model badge', () => { model: 'kimi-k3', subagentModel: 'glm-5.2', availableModels: { - 'kimi-k3': { provider: 'managed:kimi-code', model: 'kimi-k3', maxContextSize: 262144 }, - 'glm-5.2': { provider: 'zai', model: 'glm-5.2', maxContextSize: 131072, displayName: 'GLM-5.2' }, + 'kimi-k3': { provider: 'managed:kimi-code', model: 'kimi-k3', maxContextSize: 1_048_576 }, + 'glm-5.2': { provider: 'zai', model: 'glm-5.2', maxContextSize: 1_000_000, displayName: 'GLM-5.2' }, }, }; const footer = new FooterComponent(state); @@ -230,12 +230,12 @@ describe('FooterComponent subagent model badge', () => { 'kimi-k3': { provider: 'managed:kimi-code', model: 'kimi-k3', - maxContextSize: 262144, + maxContextSize: 1_048_576, }, 'glm-5.2': { provider: 'zai', model: 'glm-5.2', - maxContextSize: 131072, + maxContextSize: 1_000_000, displayName: 'GLM-5.2', }, }, @@ -257,7 +257,7 @@ describe('FooterComponent subagent model badge', () => { 'kimi-k3': { provider: 'managed:kimi-code', model: 'kimi-k3', - maxContextSize: 262144, + maxContextSize: 1_048_576, displayName: 'Kimi K3', }, }, @@ -281,12 +281,12 @@ describe('FooterComponent subagent model badge', () => { 'kimi-k3': { provider: 'managed:kimi-code', model: 'kimi-k3', - maxContextSize: 262144, + maxContextSize: 1_048_576, }, 'glm-5.2': { provider: 'zai', model: 'glm-5.2', - maxContextSize: 131072, + maxContextSize: 1_000_000, displayName: 'GLM-5.2', }, }, @@ -307,12 +307,12 @@ describe('FooterComponent subagent model badge', () => { 'kimi-k3': { provider: 'managed:kimi-code', model: 'kimi-k3', - maxContextSize: 262144, + maxContextSize: 1_048_576, }, 'glm-5.2': { provider: 'zai', model: 'glm-5.2', - maxContextSize: 131072, + maxContextSize: 1_000_000, displayName: 'GLM-5.2', }, }, diff --git a/apps/kimi-code/test/tui/components/dialogs/model-scope-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-scope-selector.test.ts index eb5e8864db..366a72b699 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-scope-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-scope-selector.test.ts @@ -13,13 +13,13 @@ const availableModels: Record = { 'kimi-k3': { provider: 'managed:kimi-code', model: 'kimi-k3', - maxContextSize: 262144, + maxContextSize: 1_048_576, displayName: 'Kimi K3', }, 'glm-5.2': { provider: 'zai', model: 'glm-5.2', - maxContextSize: 131072, + maxContextSize: 1_000_000, displayName: 'GLM-5.2', }, }; From f9473d4cfea5b360ddade1baf4e8bacefb3277cc Mon Sep 17 00:00:00 2001 From: Kassie Povinelli Date: Tue, 21 Jul 2026 02:18:08 -0400 Subject: [PATCH 6/7] fix(tui): show subagent badge when effort or provider differs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The footer badge was gated on model-alias distinctness only, hiding two real routing differences: - Same alias, different thinking effort: subagents run at a different effort than the main agent — now surfaced via the existing effort suffix ("Kimi K3 · high"). - Same underlying model via a different provider (e.g. kimi-k3 on managed:kimi-code vs opencode-go): the badge now shows a provider prefix ("opencode-go/Kimi K3") so the routes are distinguishable. The distinctness check now compares three dimensions: alias, provider, and thinking effort. When all three match the main agent the badge stays hidden as before. --- .../src/tui/components/chrome/footer.ts | 52 +++++++++++++---- .../test/tui/components/chrome/footer.test.ts | 57 +++++++++++++++++++ 2 files changed, 97 insertions(+), 12 deletions(-) diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index c17a6f5a26..a205cc5d6f 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -146,6 +146,10 @@ function modelDisplayName(state: AppState): string { * 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; @@ -153,11 +157,21 @@ function subagentModelDisplayName(state: AppState): string { 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 `${base} · ${effort}`; - return base; + if (effort !== undefined && effort.length > 0) return `${providerPrefix}${base} · ${effort}`; + return `${providerPrefix}${base}`; } function shortenCwd(path: string): string { @@ -303,19 +317,33 @@ export class FooterComponent implements Component { left.push(renderedModelLabel); } - // Subagent model badge — only shown when the `dual-model-routing` - // experimental feature is active and a DISTINCT subagent model is set - // (different alias from the main model). When the subagent alias equals - // the main model — e.g. auth-flow hydration sets it from config even when - // defaultSubagentModel === defaultModel — 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. + // 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 subagentLabel = - isExperimentalFlagEnabled('dual-model-routing') && + 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) { diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index ce86cb130a..471b1d7542 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -270,6 +270,63 @@ describe('FooterComponent subagent model badge', () => { expect(rendered).not.toContain('subagents:'); }); + it('shows the badge when the model is the same but the thinking effort differs', () => { + setExperimentalFeatures([{ id: 'dual-model-routing', enabled: true }]); + const state: AppState = { + ...appState, + model: 'kimi-k3', + thinkingEffort: 'low', + subagentModel: 'kimi-k3', + subagentThinkingEffort: 'high', + availableModels: { + 'kimi-k3': { + provider: 'managed:kimi-code', + model: 'kimi-k3', + maxContextSize: 1_048_576, + displayName: 'Kimi K3', + }, + }, + }; + const footer = new FooterComponent(state); + const rendered = footer.render(140).join('\n'); + + expect(rendered).toContain('subagents:'); + expect(rendered).toContain('Kimi K3 · high'); + }); + + it('shows the badge with provider prefix when the model name is the same but the provider differs', () => { + setExperimentalFeatures([{ id: 'dual-model-routing', enabled: true }]); + // Two aliases serve the same underlying model (kimi-k3) via different + // providers: the main agent uses managed:kimi-code, the subagent uses + // opencode-go. The badge shows the provider prefix on the subagent side + // so the user can tell the routes apart. + const state: AppState = { + ...appState, + model: 'kimi-k3', + subagentModel: 'kimi-k3-opencode', + availableModels: { + 'kimi-k3': { + provider: 'managed:kimi-code', + model: 'kimi-k3', + maxContextSize: 1_048_576, + displayName: 'Kimi K3', + }, + 'kimi-k3-opencode': { + provider: 'opencode-go', + model: 'kimi-k3', + maxContextSize: 1_048_576, + displayName: 'Kimi K3', + }, + }, + }; + const footer = new FooterComponent(state); + const rendered = footer.render(160).join('\n'); + + expect(rendered).toContain('subagents:'); + // The provider prefix is shown so the user can tell the routes apart. + expect(rendered).toContain('opencode-go/Kimi K3'); + }); + it('appends the thinking-effort suffix to the subagent badge when set', () => { setExperimentalFeatures([{ id: 'dual-model-routing', enabled: true }]); const state: AppState = { From 30f7418c1402c9520af0dd10bb9f78d038ea44f1 Mon Sep 17 00:00:00 2001 From: Kassie Povinelli Date: Tue, 21 Jul 2026 14:02:39 -0400 Subject: [PATCH 7/7] =?UTF-8?q?feat(agent-core-v2):=20dual-model-routing?= =?UTF-8?q?=20=E2=80=94=20separate=20subagent=20model=20and=20thinking=20e?= =?UTF-8?q?ffort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the dual-model-routing experimental feature to the v2 agent engine. When the dual-model-routing flag is enabled, delegated subagents (Agent tool spawn/resume, AgentSwarm spawn/resume) run on a dedicated model and thinking effort instead of inheriting the parent's. The /btw side-question agent always inherits (cache affinity). Resolution chain: session metadata override -> [subagent] config default -> parent inheritance. Inert when the flag is off. v2 engine (packages/agent-core-v2): - Flag: register dual-model-routing via registerFlagDefinition (same id/env/default/surface as v1) - Config: extend [subagent] section with defaultSubagentModel and defaultSubagentThinkingEffort - Service: new Session-scoped ISubagentRoutingService that resolves the effective model/effort, persists live overrides to session metadata (custom.subagentModelAlias / custom.subagentThinkingEffort), and loads them on construction - Hooks: Agent tool and Swarm service route model+thinking through the service on spawn and resume (realignChildModel now syncs both fields) - Tests: 16 new tests covering flag off/on, override set/clear, config default priority, metadata persistence, and load-on-construction Protocol (packages/protocol): - Add subagent_model and subagent_thinking_effort to sessionAgentConfigSchema and sessionStatusResponseSchema Server (packages/agent-core-v2/app/sessionLegacy): - Wire subagent_model / subagent_thinking_effort through applyAgentConfig and assembleStatus via ISubagentRoutingService Web UI (apps/kimi-web): - Types, wire mapper, and state composable for the subagent model - Composer subagent model pill (shown only when dual-model-routing flag is enabled), with inline model picker and clear button - i18n strings (en + zh) --- apps/kimi-web/src/App.vue | 47 +++ apps/kimi-web/src/api/daemon/client.ts | 13 + apps/kimi-web/src/api/daemon/wire.ts | 10 + apps/kimi-web/src/api/types.ts | 7 +- .../kimi-web/src/components/chat/ChatDock.vue | 8 + .../kimi-web/src/components/chat/Composer.vue | 100 +++++ .../src/components/chat/ConversationPane.vue | 14 + .../client/useModelProviderState.ts | 36 ++ .../src/composables/useKimiWebClient.ts | 23 ++ apps/kimi-web/src/i18n/locales/en/status.ts | 5 + apps/kimi-web/src/i18n/locales/zh/status.ts | 5 + apps/kimi-web/src/types.ts | 3 + .../app/sessionLegacy/sessionLegacyService.ts | 19 + .../src/app/sessionLegacy/sessionProtocol.ts | 4 + packages/agent-core-v2/src/index.ts | 3 + .../src/session/subagent/configSection.ts | 19 +- .../src/session/subagent/flag.ts | 30 ++ .../src/session/subagent/subagentRouting.ts | 33 ++ .../subagent/subagentRoutingService.ts | 107 ++++++ .../src/session/subagent/tools/agent.ts | 22 +- .../src/session/swarm/sessionSwarmService.ts | 23 +- .../app/sessionLegacy/sessionLegacy.test.ts | 10 + .../session/subagent/subagentRouting.test.ts | 344 ++++++++++++++++++ .../test/session/swarm/sessionSwarm.test.ts | 11 + packages/agent-core-v2/test/tool/tool.test.ts | 13 +- packages/protocol/src/rest/session.ts | 2 + packages/protocol/src/session.ts | 2 + 27 files changed, 898 insertions(+), 15 deletions(-) create mode 100644 packages/agent-core-v2/src/session/subagent/flag.ts create mode 100644 packages/agent-core-v2/src/session/subagent/subagentRouting.ts create mode 100644 packages/agent-core-v2/src/session/subagent/subagentRoutingService.ts create mode 100644 packages/agent-core-v2/test/session/subagent/subagentRouting.test.ts diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 5708bcc9bf..16d4213629 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -313,6 +313,9 @@ const conversationPaneRef = ref | null>(nu // Dialog visibility refs const showModelPicker = ref(false); +/** Model picker opened in "subagent" mode (dual-model-routing). Selection + * routes to setSubagentModel instead of setModel. */ +const subagentPickerOpen = ref(false); const showProviders = ref(false); const showLogin = ref(false); @@ -338,6 +341,7 @@ const anyOverlayOpen = computed( () => openDialogCount.value > 0 || showModelPicker.value || + subagentPickerOpen.value || showProviders.value || showLogin.value || showAddWorkspace.value || @@ -413,6 +417,32 @@ async function handleComposerSelectModel(modelId: string): Promise { } } +/** Open the model picker overlay in subagent mode (dual-model-routing). + * Same ModelPicker component; selection is routed to setSubagentModel. */ +async function openSubagentModelPicker(): Promise { + modelsLoading.value = true; + modelsUnavailable.value = false; + subagentPickerOpen.value = true; + try { + await client.refreshAllProviders(); + } catch { + modelsUnavailable.value = true; + } finally { + modelsLoading.value = false; + } +} + +/** Clear the active session's subagent model so subagents fall back to main. */ +function handleClearSubagentModel(): void { + void client.setSubagentModel(undefined); +} + +/** Handle a selection from the model picker when it is in subagent mode. */ +async function handleSelectSubagentModel(modelId: string): Promise { + subagentPickerOpen.value = false; + await client.setSubagentModel(modelId); +} + async function handleAddProvider(input: { type: string; apiKey?: string; baseUrl?: string; defaultModel?: string }): Promise { await client.addProvider(input); } @@ -823,6 +853,7 @@ function openPr(url: string): void { :session-title="activeSessionTitle" :pr="client.activePullRequest.value" :conversation-toc="client.conversationToc.value" + :dual-model-routing="client.dualModelRoutingEnabled.value" @open-changes="openDiffDetail()" @select-workspace="handleCreateSessionInWorkspace($event)" @add-workspace="showAddWorkspace = true" @@ -853,6 +884,8 @@ function openPr(url: string): void { @compact="client.compact()" @pick-model="openModelPicker()" @select-model="handleComposerSelectModel($event)" + @pick-subagent-model="openSubagentModelPicker()" + @clear-subagent-model="handleClearSubagentModel()" @open-file="openFilePreview($event)" @open-media="openMediaPreview($event)" @open-thinking="openThinkingPanel($event)" @@ -984,6 +1017,20 @@ function openPr(url: string): void { @close="showModelPicker = false" /> + + + { const body: Record = {}; @@ -432,6 +434,9 @@ export class DaemonKimiWebApi implements KimiWebApi { if (input.goalObjective !== undefined) agentConfig['goal_objective'] = input.goalObjective; if (input.goalControl !== undefined) agentConfig['goal_control'] = input.goalControl; if (input.thinking !== undefined) agentConfig['thinking'] = input.thinking; + if (input.subagentModel !== undefined) agentConfig['subagent_model'] = input.subagentModel; + if (input.subagentThinkingEffort !== undefined) + agentConfig['subagent_thinking_effort'] = input.subagentThinkingEffort; if (Object.keys(agentConfig).length > 0) body['agent_config'] = agentConfig; const data = await this.http.post( `/sessions/${encodeURIComponent(sessionId)}/profile`, @@ -459,6 +464,14 @@ export class DaemonKimiWebApi implements KimiWebApi { contextTokens: data.context_tokens ?? 0, maxContextTokens: data.max_context_tokens ?? 0, contextUsage: data.context_usage ?? 0, + subagentModel: + typeof data.subagent_model === 'string' && data.subagent_model.length > 0 + ? data.subagent_model + : undefined, + subagentThinkingEffort: + typeof data.subagent_thinking_effort === 'string' && data.subagent_thinking_effort.length > 0 + ? data.subagent_thinking_effort + : undefined, }; } diff --git a/apps/kimi-web/src/api/daemon/wire.ts b/apps/kimi-web/src/api/daemon/wire.ts index 900d98bcf0..bef381869c 100644 --- a/apps/kimi-web/src/api/daemon/wire.ts +++ b/apps/kimi-web/src/api/daemon/wire.ts @@ -94,6 +94,11 @@ export interface WireSession { swarm_mode?: boolean; goal_objective?: string; goal_control?: 'pause' | 'resume' | 'cancel'; + /** Separate model for subagents (dual-model-routing experimental flag). + * Empty string clears it so subagents use the main model. */ + subagent_model?: string; + /** Separate thinking effort for subagents (dual-model-routing). */ + subagent_thinking_effort?: string; }; usage: WireSessionUsage; permission_rules: WirePermissionRule[]; @@ -111,6 +116,11 @@ export interface WireSessionRuntimeStatus { context_tokens: number; max_context_tokens: number; context_usage: number; + /** Separate model for subagents (dual-model-routing experimental flag). + * Empty/absent means subagents use the main model. */ + subagent_model?: string; + /** Separate thinking effort for subagents (dual-model-routing). */ + subagent_thinking_effort?: string; } // GET /sessions/{id}/goal — camelCase, same shape as the `goal.updated` event diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 5c34d26ccb..b8e27e8ffd 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -106,6 +106,11 @@ export interface AppSessionRuntimeStatus { contextTokens: number; maxContextTokens: number; contextUsage: number; + /** Separate model used for subagents when dual-model-routing is enabled. + * Empty/undefined means subagents use the main model. */ + subagentModel?: string; + /** Separate thinking effort for subagents (dual-model-routing). */ + subagentThinkingEffort?: string; } // --------------------------------------------------------------------------- @@ -704,7 +709,7 @@ export interface KimiWebApi { createSession(input: { title?: string; cwd?: string; model?: string; workspaceId?: string }): Promise; /** Fetch one session by id (deep links beyond the first listSessions page). */ getSession(sessionId: string): Promise; - updateSession(sessionId: string, input: { title?: string; cwd?: string; model?: string; permissionMode?: string; planMode?: boolean; swarmMode?: boolean; goalObjective?: string; goalControl?: 'pause' | 'resume' | 'cancel'; thinking?: string }): Promise; + updateSession(sessionId: string, input: { title?: string; cwd?: string; model?: string; permissionMode?: string; planMode?: boolean; swarmMode?: boolean; goalObjective?: string; goalControl?: 'pause' | 'resume' | 'cancel'; thinking?: string; subagentModel?: string; subagentThinkingEffort?: string }): Promise; getSessionStatus(sessionId: string): Promise; /** Current goal snapshot, or null when the session has no active goal. */ getSessionGoal(sessionId: string): Promise; diff --git a/apps/kimi-web/src/components/chat/ChatDock.vue b/apps/kimi-web/src/components/chat/ChatDock.vue index 160c77a242..a924542702 100644 --- a/apps/kimi-web/src/components/chat/ChatDock.vue +++ b/apps/kimi-web/src/components/chat/ChatDock.vue @@ -54,6 +54,8 @@ const props = defineProps<{ /** True while the visible approval has a respond in flight. */ approvalBusy?: boolean; mobile?: boolean; + /** True when the dual-model-routing experimental flag is on. */ + dualModelRouting?: boolean; }>(); const emit = defineEmits<{ @@ -74,6 +76,8 @@ const emit = defineEmits<{ compact: []; pickModel: []; selectModel: [modelId: string]; + pickSubagentModel: []; + clearSubagentModel: []; answer: [questionId: string, response: QuestionResponse]; dismiss: [questionId: string]; approval: [approvalId: string, response: { decision: 'approved' | 'rejected' | 'cancelled'; scope?: 'session'; feedback?: string; selectedLabel?: string }]; @@ -282,6 +286,8 @@ defineExpose({ loadForEdit, loadAttachmentsForEdit, focus }); :starred-ids="starredIds" :skills="skills" :starting="starting" + :dual-model-routing="dualModelRouting" + :subagent-model-id="status.subagentModelId" @submit="emit('submit', $event)" @steer="emit('steer', $event)" @command="emit('command', $event)" @@ -299,6 +305,8 @@ defineExpose({ loadForEdit, loadAttachmentsForEdit, focus }); @compact="emit('compact')" @pick-model="emit('pickModel')" @select-model="emit('selectModel', $event)" + @pick-subagent-model="emit('pickSubagentModel')" + @clear-subagent-model="emit('clearSubagentModel')" /> diff --git a/apps/kimi-web/src/components/chat/Composer.vue b/apps/kimi-web/src/components/chat/Composer.vue index 5d2e16f6d9..151a8d2831 100644 --- a/apps/kimi-web/src/components/chat/Composer.vue +++ b/apps/kimi-web/src/components/chat/Composer.vue @@ -64,6 +64,11 @@ const props = withDefaults(defineProps<{ skills?: AppSkill[]; /** Hide the context-usage indicator (used on the empty-session landing page). */ hideContext?: boolean; + /** True when the dual-model-routing experimental flag is enabled on the + * server. Gates the subagent model pill in the toolbar. */ + dualModelRouting?: boolean; + /** Subagent model id for the active session (undefined = same as main). */ + subagentModelId?: string; }>(), { running: false, starting: false, @@ -73,6 +78,8 @@ const props = withDefaults(defineProps<{ models: () => [], starredIds: () => [], skills: () => [], + dualModelRouting: false, + subagentModelId: undefined, }); const placeholder = computed(() => @@ -105,6 +112,10 @@ const emit = defineEmits<{ compact: []; pickModel: []; selectModel: [modelId: string]; + /** Open the full model picker for the subagent model. */ + pickSubagentModel: []; + /** Clear the subagent model (revert to "same as main"). */ + clearSubagentModel: []; }>(); const { t, locale } = useI18n(); @@ -825,6 +836,16 @@ const starredSet = computed(() => new Set(props.starredIds ?? [])); function isStarred(modelId: string): boolean { return starredSet.value.has(modelId); } + +/** Display name for the subagent model pill. Resolved against the catalog; + * falls back to the raw id. Empty when no subagent model is set. */ +const subagentDisplay = computed(() => { + const id = props.subagentModelId; + if (!id) return ''; + const matched = + props.models.find((m) => m.id === id) ?? props.models.find((m) => m.model === id); + return matched?.displayName ?? matched?.model ?? (id.includes('/') ? id.split('/').pop()! : id); +}); const starredOtherModels = computed(() => { if (!props.models?.length) return []; return props.models.filter( @@ -1116,6 +1137,36 @@ function selectModel(modelId: string): void { {{ thinkingSuffix }} + + + + {{ t('status.subagentLabel') }} + {{ subagentDisplay }} + {{ t('status.subagentSameAsMain') }} + + +