diff --git a/.changeset/swarm-subagent-model.md b/.changeset/swarm-subagent-model.md new file mode 100644 index 000000000..c193aa50f --- /dev/null +++ b/.changeset/swarm-subagent-model.md @@ -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. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 3e652217e..3f444d371 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -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 // --------------------------------------------------------------------------- @@ -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 { + 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 }); + } 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 { + 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(); + } + + 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 { if (host.state.appState.streamingPhase !== 'idle') { host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.'); diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index b3e4593ac..c862b49d5 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -30,6 +30,7 @@ import { handleEditorCommand, handleModelCommand, handlePlanCommand, + handleSwarmModelCommand, handleThemeCommand, handleYoloCommand, showExperimentsPanel, @@ -67,6 +68,7 @@ export { handleEditorCommand, handleModelCommand, handlePlanCommand, + handleSwarmModelCommand, handleThemeCommand, handleYoloCommand, showModelPicker, @@ -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; diff --git a/apps/kimi-code/src/tui/commands/index.ts b/apps/kimi-code/src/tui/commands/index.ts index 769571a62..a0e33d4e4 100644 --- a/apps/kimi-code/src/tui/commands/index.ts +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -16,6 +16,7 @@ export { handleEditorCommand, handleModelCommand, handlePlanCommand, + handleSwarmModelCommand, handleThemeCommand, handleYoloCommand, showExperimentsPanel, diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 464cc770d..b04472d28 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -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: [], diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index a93d7b6ec..a667b38a7 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -51,5 +51,6 @@ function applyRuntimeConfig(host: SlashCommandHost, config: KimiConfig): void { host.setAppState({ availableModels: config.models ?? {}, availableProviders: config.providers ?? {}, + subAgentModel: config.subAgentModel ?? undefined, }); } diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index 39af925fb..1efaae73c 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -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; @@ -130,6 +131,7 @@ export class AuthFlowController { availableProviders: config.providers ?? {}, model: '', thinking: false, + subAgentModel: undefined, maxContextTokens: 0, contextUsage: 0, contextTokens: 0, diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 3c65fa67f..9712bc9a5 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -20,6 +20,7 @@ export interface AppState { permissionMode: PermissionMode; planMode: boolean; swarmMode: boolean; + subAgentModel?: string; thinking: boolean; contextUsage: number; contextTokens: number; diff --git a/apps/kimi-code/test/tui/commands/swarm.test.ts b/apps/kimi-code/test/tui/commands/swarm.test.ts index 3c9418989..714ba41a0 100644 --- a/apps/kimi-code/test/tui/commands/swarm.test.ts +++ b/apps/kimi-code/test/tui/commands/swarm.test.ts @@ -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'; @@ -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) => 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), + ); + }); +}); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index bd7cefa45..929f51a82 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -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 @@ -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 | diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 953352120..668285b45 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -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 ` | — | Turn swarm mode on, then send `` 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 diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index fc19dc3c0..663d77c78 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -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 @@ -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 模式(先出计划再执行)启动 | diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 3b7c247d2..b200e1df0 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -46,6 +46,7 @@ | `/plan clear` | — | 清除当前 plan 方案 | 否 | | `/swarm on\|off` | — | 开启或关闭 swarm mode,但不发送提示词。 | 是 | | `/swarm ` | — | 先开启 swarm mode,再把 `` 作为普通提示词发送。如果该轮次正常完成,swarm mode 会自动关闭。若当前是 `manual` 权限模式,启动前会提示是否切换到 `auto`。 | 否 | +| `/swarm-model [alias\|off]` | — | 设置 swarm 子智能体使用的模型。不带参数时打开模型选择器;使用 `off` 清除设置,让子智能体继承主模型。 | 是 | | `/goal [...]` | — | 开始或管理目标模式 | 见下文 | ::: warning 注意 diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 11c6500c7..49a956733 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -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(), @@ -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(), diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index 56452e41a..89f61231c 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -286,6 +286,7 @@ export function configToTomlData(config: KimiConfig): Record { const scalarFields: (keyof KimiConfig)[] = [ 'defaultProvider', 'defaultModel', + 'subAgentModel', 'planMode', 'yolo', 'defaultThinking', diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 1848c640a..18df7847a 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -143,7 +143,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: parent.kimiConfig?.subAgentModel ?? parent.config.modelAlias }); return await this.runPromptTurn(parent, agentId, child, profileName, runOptions); } catch (error) { this.emitSubagentFailed(parent, agentId, runOptions, error); @@ -159,7 +159,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: parent.kimiConfig?.subAgentModel ?? parent.config.modelAlias }); this.emitSubagentStarted(parent, agentId); const turnId = child.turn.retry('agent-host'); if (turnId === null) { @@ -220,7 +220,7 @@ export class SessionSubagentHost { ); child.config.update({ - modelAlias: parent.config.modelAlias, + modelAlias: parent.kimiConfig?.subAgentModel ?? parent.config.modelAlias, thinkingLevel: parent.config.thinkingLevel, systemPrompt: parent.config.systemPrompt, }); @@ -355,10 +355,10 @@ export class SessionSubagentHost { child: Agent, profile: ResolvedAgentProfile, ): Promise { - // A subagent always inherits the parent agent's model. + // A subagent uses the configured sub-agent model if set, otherwise inherits the parent agent's model. child.config.update({ cwd: parent.config.cwd, - modelAlias: parent.config.modelAlias, + modelAlias: parent.kimiConfig?.subAgentModel ?? parent.config.modelAlias, thinkingLevel: parent.config.thinkingLevel, }); diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index 551980f9d..4394a34b2 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -352,6 +352,39 @@ not_registered = true expect(config.defaultThinking).toBeUndefined(); }); + it('round-trips sub_agent_model through config file', async () => { + const dir = makeTempDir(); + const configPath = join(dir, 'config.toml'); + + await writeConfigFile(configPath, { + providers: {}, + defaultModel: 'claude-sonnet', + subAgentModel: 'gpt-4o-mini', + }); + + const text = await readFile(configPath, 'utf-8'); + expect(text).toContain('sub_agent_model = "gpt-4o-mini"'); + expect(text).toContain('default_model = "claude-sonnet"'); + + const reloaded = readConfigFile(configPath); + expect(reloaded.subAgentModel).toBe('gpt-4o-mini'); + expect(reloaded.defaultModel).toBe('claude-sonnet'); + + // Clear and verify it becomes null + await writeConfigFile(configPath, { + providers: {}, + defaultModel: 'claude-sonnet', + subAgentModel: null, + }); + + const cleared = readConfigFile(configPath); + expect(cleared.subAgentModel).toBeUndefined(); + expect(cleared.defaultModel).toBe('claude-sonnet'); + + const clearedText = await readFile(configPath, 'utf-8'); + expect(clearedText).not.toContain('sub_agent_model'); + }); + it('does not overwrite an existing config file', async () => { const dir = makeTempDir(); const configPath = join(dir, 'config.toml');