Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/swarm-subagent-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": minor
"@moonshot-ai/kimi-code": minor
---

Add `/swarm-model` command and `sub_agent_model` config key to let users set a different model for swarm subagents.
97 changes: 97 additions & 0 deletions apps/kimi-code/src/tui/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,23 @@ export function handleModelCommand(host: SlashCommandHost, args: string): void {
showModelPicker(host, alias);
}

export function handleSwarmModelCommand(host: SlashCommandHost, args: string): void {
const alias = args.trim();
if (alias.length === 0) {
showSwarmModelPicker(host);
return;
}
if (alias.toLowerCase() === 'off') {
void clearSwarmModel(host);
return;
}
if (host.state.appState.availableModels[alias] === undefined) {
host.showError(`Unknown model alias: ${alias}`);
return;
}
showSwarmModelPicker(host, alias);
}

// ---------------------------------------------------------------------------
// Pickers & config apply
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -285,6 +302,86 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string =
);
}

function showSwarmModelPicker(host: SlashCommandHost, selectedValue?: string): void {
const entries = Object.entries(host.state.appState.availableModels);
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: host.state.appState.availableModels,
currentValue: host.state.appState.subAgentModel ?? '',
selectedValue,
currentThinking: false,
colors: host.state.theme.colors,
onSelect: ({ alias }) => {
host.restoreEditor();
void setSwarmModel(host, alias);
},
onCancel: () => {
host.restoreEditor();
},
}),
);
}

async function setSwarmModel(host: SlashCommandHost, alias: string): Promise<void> {
if (host.state.appState.streamingPhase !== 'idle') {
host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.');
return;
}

try {
await host.harness.setConfig({ subAgentModel: alias });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Update the active session when changing swarm model

When /swarm-model is used after a session is already active, this only persists the new value to config.toml and updates TUI state; existing Session/Agent instances keep the config object they were constructed with, and the new subagent path reads parent.kimiConfig?.subAgentModel when spawning. As a result, the command can report Swarm subagents will use <alias> while swarms in the current session still use the old/inherited model until a reload or new session. Please also update the live session/agent runtime state, or make the subagent host read the refreshed config.

Useful? React with 👍 / 👎.

} catch (error) {
host.showError(`Failed to save sub-agent model: ${formatErrorMessage(error)}`);
return;
}

// Push the updated config to the live session so active agents pick it up
const session = host.session;
if (session !== undefined) {
await session.reloadSession();
}

host.setAppState({ subAgentModel: alias });
host.showStatus(
`Swarm subagents will use ${alias}.`,
host.state.theme.colors.success,
);
host.track('swarm_model_switch', { model: alias });
}

async function clearSwarmModel(host: SlashCommandHost): Promise<void> {
if (host.state.appState.streamingPhase !== 'idle') {
host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.');
return;
}

try {
await host.harness.setConfig({ subAgentModel: null });
} catch (error) {
host.showError(`Failed to clear sub-agent model: ${formatErrorMessage(error)}`);
return;
}

// Push the updated config to the live session so active agents pick it up
const session = host.session;
if (session !== undefined) {
await session.reloadSession();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Block clearing swarm model while streaming

When /swarm-model off is entered while a turn is still running, this path is allowed by the command registry (availability: 'always') and unlike setSwarmModel it does not check streamingPhase before calling session.reloadSession(). The core reload path rejects active sessions with TURN_AGENT_BUSY, and because clearSwarmModel is launched with void clearSwarmModel(host) that rejection is not caught by the slash-command dispatcher after the config has already been written. Please apply the same idle guard/catch here so clearing the override during streaming reports a normal error instead of leaving the UI/runtime out of sync or surfacing an unhandled rejection.

Useful? React with 👍 / 👎.

}

host.setAppState({ subAgentModel: undefined });
host.showStatus(
'Swarm subagents will inherit the main model.',
host.state.theme.colors.success,
);
}

async function performModelSwitch(host: SlashCommandHost, alias: string, thinking: boolean): Promise<void> {
if (host.state.appState.streamingPhase !== 'idle') {
host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.');
Expand Down
5 changes: 5 additions & 0 deletions apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
handleEditorCommand,
handleModelCommand,
handlePlanCommand,
handleSwarmModelCommand,
handleThemeCommand,
handleYoloCommand,
showExperimentsPanel,
Expand Down Expand Up @@ -67,6 +68,7 @@ export {
handleEditorCommand,
handleModelCommand,
handlePlanCommand,
handleSwarmModelCommand,
handleThemeCommand,
handleYoloCommand,
showModelPicker,
Expand Down Expand Up @@ -305,6 +307,9 @@ async function handleBuiltInSlashCommand(
case 'swarm':
await handleSwarmCommand(host, args);
return;
case 'swarm-model':
handleSwarmModelCommand(host, args);
return;
case 'compact':
await handleCompactCommand(host, args);
return;
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export {
handleEditorCommand,
handleModelCommand,
handlePlanCommand,
handleSwarmModelCommand,
handleThemeCommand,
handleYoloCommand,
showExperimentsPanel,
Expand Down
7 changes: 7 additions & 0 deletions apps/kimi-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ export const BUILTIN_SLASH_COMMANDS = [
completeArgs: swarmArgumentCompletions,
availability: 'idle-only',
},
{
name: 'swarm-model',
aliases: [],
description: 'Switch the model used by swarm subagents',
priority: 95,
availability: 'always',
},
{
name: 'model',
aliases: [],
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/commands/reload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,6 @@ function applyRuntimeConfig(host: SlashCommandHost, config: KimiConfig): void {
host.setAppState({
availableModels: config.models ?? {},
availableProviders: config.providers ?? {},
subAgentModel: config.subAgentModel ?? undefined,
});
}
2 changes: 2 additions & 0 deletions apps/kimi-code/src/tui/controllers/auth-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export class AuthFlowController {
availableProviders,
model: defaultModel,
maxContextTokens: selected.maxContextSize,
subAgentModel: config.subAgentModel ?? undefined,
};
if (config.defaultThinking !== undefined) {
appStatePatch.thinking = config.defaultThinking;
Expand All @@ -130,6 +131,7 @@ export class AuthFlowController {
availableProviders: config.providers ?? {},
model: '',
thinking: false,
subAgentModel: undefined,
maxContextTokens: 0,
contextUsage: 0,
contextTokens: 0,
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface AppState {
permissionMode: PermissionMode;
planMode: boolean;
swarmMode: boolean;
subAgentModel?: string;
thinking: boolean;
contextUsage: number;
contextTokens: number;
Expand Down
84 changes: 83 additions & 1 deletion apps/kimi-code/test/tui/commands/swarm.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';

import { handleSwarmCommand } from '#/tui/commands/index';
import { handleSwarmCommand, handleSwarmModelCommand } from '#/tui/commands/index';
import type { SlashCommandHost } from '#/tui/commands/dispatch';
import { getColorPalette } from '#/tui/theme/colors';

Expand Down Expand Up @@ -316,3 +316,85 @@ describe('handleSwarmCommand', () => {
expect(host.sendNormalUserInput).not.toHaveBeenCalled();
});
});

// ---------------------------------------------------------------------------
// /swarm-model tests
// ---------------------------------------------------------------------------

describe('/swarm-model', () => {
const MODEL_ALIAS = 'gpt-4o-mini';
const availableModels = {
[MODEL_ALIAS]: { provider: 'openai', model: MODEL_ALIAS, maxContextSize: 100_000 },
'claude-sonnet': { provider: 'anthropic', model: 'claude-sonnet', maxContextSize: 200_000 },
};

function makeSwarmModelHost(overrides: { subAgentModel?: string } = {}) {
const harness = {
setConfig: vi.fn(async () => ({})),
getConfig: vi.fn(async () => ({})),
};
const host = {
state: {
appState: {
model: 'claude-sonnet',
availableModels,
subAgentModel: overrides.subAgentModel,
swarmMode: false,
permissionMode: 'auto' as const,
streamingPhase: 'idle' as const,
thinking: false,
},
theme: { colors: getColorPalette('dark') },
transcriptContainer: { addChild: vi.fn() },
ui: { requestRender: vi.fn() },
},
session: undefined,
harness,
setAppState: vi.fn((patch: Record<string, unknown>) => Object.assign(host.state.appState, patch)),
showError: vi.fn(),
showStatus: vi.fn(),
showNotice: vi.fn(),
mountEditorReplacement: vi.fn(),
restoreEditor: vi.fn(),
track: vi.fn(),
} as unknown as SlashCommandHost;
return { host, harness };
}

it('opens the model picker when no args given', () => {
const { host } = makeSwarmModelHost();
handleSwarmModelCommand(host, '');
expect(host.mountEditorReplacement).toHaveBeenCalled();
});

it('opens the model picker with a valid alias pre-selected', () => {
const { host } = makeSwarmModelHost();
handleSwarmModelCommand(host, MODEL_ALIAS);
expect(host.mountEditorReplacement).toHaveBeenCalled();
});

it('shows error for unknown model alias', () => {
const { host } = makeSwarmModelHost();
handleSwarmModelCommand(host, 'nonexistent-model');
expect(host.showError).toHaveBeenCalledWith(
expect.stringContaining('Unknown model alias'),
);
expect(host.mountEditorReplacement).not.toHaveBeenCalled();
});

it('clears the override with "off"', async () => {
const { host, harness } = makeSwarmModelHost({ subAgentModel: MODEL_ALIAS });
handleSwarmModelCommand(host, 'off');

await vi.waitFor(() => {
expect(harness.setConfig).toHaveBeenCalledWith({ subAgentModel: null });
});
expect(host.setAppState).toHaveBeenCalledWith(
expect.objectContaining({ subAgentModel: undefined }),
);
expect(host.showStatus).toHaveBeenCalledWith(
expect.stringContaining('inherit the main model'),
expect.any(String),
);
});
});
2 changes: 2 additions & 0 deletions docs/en/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ The following example covers the most commonly used configuration fields. You ca

```toml
default_model = "kimi-code/kimi-for-coding"
# sub_agent_model = "kimi-code/kimi-for-coding"
default_thinking = true
default_permission_mode = "manual"
default_plan_mode = false
Expand Down Expand Up @@ -76,6 +77,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` |
| `sub_agent_model` | `string` | — | Model alias for subagents spawned by the Agent and AgentSwarm tools; when set, subagents use this model instead of inheriting from the parent. Set to `null` or omit to disable the override |
| `default_thinking` | `boolean` | `false` | Whether new sessions enable Thinking (deep reasoning) mode by default; can be toggled from the model menu inside a session. Even when set to `true`, `[thinking].mode = "off"` will still force Thinking off |
| `default_permission_mode` | `string` | `manual` | Default permission mode for new sessions; one of `manual` (prompt each time), `auto` (auto-approve read operations), or `yolo` (auto-approve everything) |
| `default_plan_mode` | `boolean` | `false` | Whether new sessions start in Plan mode (produce a plan before executing) by default |
Expand Down
1 change: 1 addition & 0 deletions docs/en/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Some commands are only available in the idle state. Executing these commands whi
| `/plan clear` | — | Clear the current plan | No |
| `/swarm on\|off` | — | Turn swarm mode on or off without sending a prompt. | Yes |
| `/swarm <task>` | — | Turn swarm mode on, then send `<task>` as a normal prompt. If the turn completes normally, swarm mode turns off automatically. In `manual` permission mode, Kimi Code asks whether to switch to `auto` before starting. | No |
| `/swarm-model [alias\|off]` | — | Set the model used by swarm subagents. Opens the model picker when no alias is given; use `off` to clear the override and let subagents inherit the main model. | Yes |
| `/goal [...]` | — | Start or manage an autonomous goal | See below |

::: warning
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ TOML 字段名一律用下划线(snake_case),如 `default_model`、`max_co

```toml
default_model = "kimi-code/kimi-for-coding"
# sub_agent_model = "kimi-code/kimi-for-coding"
default_thinking = true
default_permission_mode = "manual"
default_plan_mode = false
Expand Down Expand Up @@ -76,6 +77,7 @@ timeout = 5
| 字段 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `default_model` | `string` | — | 默认模型别名,必须在 `models` 中定义 |
| `sub_agent_model` | `string` | — | Agent 和 AgentSwarm 工具生成的子智能体所使用的模型别名;设置后子智能体将使用此模型而非继承主模型。设为 `null` 或不填可关闭此设置 |
| `default_thinking` | `boolean` | `false` | 新会话是否默认开启 Thinking(深度推理)模式;可在会话内从模型菜单切换。即使设为 `true`,`[thinking].mode = "off"` 也会强制关闭 |
| `default_permission_mode` | `string` | `manual` | 新会话的默认权限模式,可选 `manual`(逐次询问)、`auto`(自动批准读操作)、`yolo`(全部自动批准) |
| `default_plan_mode` | `boolean` | `false` | 新会话是否默认以 Plan 模式(先出计划再执行)启动 |
Expand Down
1 change: 1 addition & 0 deletions docs/zh/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
| `/plan clear` | — | 清除当前 plan 方案 | 否 |
| `/swarm on\|off` | — | 开启或关闭 swarm mode,但不发送提示词。 | 是 |
| `/swarm <task>` | — | 先开启 swarm mode,再把 `<task>` 作为普通提示词发送。如果该轮次正常完成,swarm mode 会自动关闭。若当前是 `manual` 权限模式,启动前会提示是否切换到 `auto`。 | 否 |
| `/swarm-model [alias\|off]` | — | 设置 swarm 子智能体使用的模型。不带参数时打开模型选择器;使用 `off` 清除设置,让子智能体继承主模型。 | 是 |
| `/goal [...]` | — | 开始或管理目标模式 | 见下文 |

::: warning 注意
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ export const KimiConfigSchema = z.object({
providers: z.record(z.string(), ProviderConfigSchema).default({}),
defaultProvider: z.string().optional(),
defaultModel: z.string().optional(),
subAgentModel: z.string().nullable().optional(),
models: z.record(z.string(), ModelAliasSchema).optional(),
thinking: ThinkingConfigSchema.optional(),
planMode: z.boolean().optional(),
Expand Down Expand Up @@ -242,6 +243,7 @@ export const KimiConfigPatchSchema = z
providers: z.record(z.string(), ProviderConfigPatchSchema).optional(),
defaultProvider: z.string().optional(),
defaultModel: z.string().optional(),
subAgentModel: z.string().nullable().optional(),
models: z.record(z.string(), ModelAliasPatchSchema).optional(),
thinking: ThinkingConfigPatchSchema.optional(),
planMode: z.boolean().optional(),
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/config/toml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ export function configToTomlData(config: KimiConfig): Record<string, unknown> {
const scalarFields: (keyof KimiConfig)[] = [
'defaultProvider',
'defaultModel',
'subAgentModel',
'planMode',
'yolo',
'defaultThinking',
Expand Down
Loading