Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/sdk-session-secondary-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code-sdk": minor
---

Add `Session.setSecondaryModel()` and `Session.getSecondaryModel()` for the session-scoped secondary model on the v2 engine (the v1 client rejects the setter with `NOT_IMPLEMENTED` and reads back `undefined`).
5 changes: 5 additions & 0 deletions .changeset/secondary-model-session-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add an Alt+S shortcut in the /secondary-model picker to set the subagent model for the current session only, without saving it as the default or affecting other windows.
5 changes: 5 additions & 0 deletions .changeset/secondary-model-stable-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Keep a running window's secondary model unchanged when the saved default is edited elsewhere; the new default now applies only to newly started sessions.
73 changes: 53 additions & 20 deletions apps/kimi-code/src/tui/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,8 +293,11 @@ export async function handleSecondaryModelCommand(host: SlashCommandHost, args:
}
const secondary = (await host.harness.getConfig()).secondaryModel;
// The v2 engine honors a lone legacy `model` key as the fallback pool
// default — reflect it as the picker's current value.
const current = secondary?.defaultModel ?? secondary?.model ?? '';
// default — reflect it as the picker's current value. A session-scoped
// selection (Alt+S) outranks the persisted default.
const sessionValue =
host.session === undefined ? undefined : await host.session.getSecondaryModel();
const current = sessionValue ?? secondary?.defaultModel ?? secondary?.model ?? '';
showSecondaryModelPicker(host, models, current, alias.length > 0 ? alias : undefined);
}

Expand Down Expand Up @@ -639,8 +642,14 @@ function showSecondaryModelPicker(
title: ' Select a secondary model (subagents)',
onSelect: ({ alias }) => {
host.restoreEditor();
void performSecondaryModelSave(host, alias);
void performSecondaryModelSave(host, alias, true);
},
onSessionOnlySelect: host.engineV2
? ({ alias }) => {
host.restoreEditor();
void performSecondaryModelSave(host, alias, false);
Comment on lines +647 to +650

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 Hide the session-only action when force is enabled

With [secondary_model].force = true, this callback is still offered for every v2 session, so Alt-S successfully stores the selected alias and reports that new subagents will use it. However, resolveSubagentBinding returns the forced model before consulting the session value, making the action a silent no-op. Suppress or reject the session-only action when force is active instead of displaying a false success message.

Useful? React with 👍 / 👎.

}
: undefined,
onCancel: () => {
host.restoreEditor();
},
Expand All @@ -649,32 +658,56 @@ function showSecondaryModelPicker(
}

/**
* Persists `[secondary_model] default_model`. When a
* `[secondary_model.models]` pool exists and does not list the alias yet, the
* alias is added with an empty description — the engine requires the default
* to be a pool key. Without a pool the default alone forms an implicit
* single-entry pool, so nothing else is written. No live-apply step: the
* engine resolves the pool per spawn, so the next subagent dispatch picks the
* new value up on its own.
* Saves the secondary-model selection. The runtime channel always comes first
* on v2 (`session.setSecondaryModel` — the session's own binding, durable
* across resume, invisible to other windows); `persist` additionally writes
* `[secondary_model] default_model` so future sessions inherit it, adding the
* alias to an existing `[secondary_model.models]` pool when it is not listed
* yet (the engine requires the default to be a pool key). On v1 there is no
* runtime channel, so every selection is persist-only.
*/
async function performSecondaryModelSave(host: SlashCommandHost, alias: string): Promise<void> {
async function performSecondaryModelSave(
host: SlashCommandHost,
alias: string,
persist: boolean,
): Promise<void> {
const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]);
let session = host.session;
if (session === undefined && host.engineV2) {
// Same lazy-creation race as performModelSwitch: wait so the override
// lands on the new session instead of being dropped.
await host.waitForLazyCreation();
session = host.session;
}
if (!persist && session === undefined) {
host.showError(
'No active session — send a message first, or press Enter to save as the default.',
);
return;
}
try {
const config = await host.harness.getConfig({ reload: true });
const existing = config.secondaryModel?.models;
const patch: { defaultModel: string; models?: Record<string, string> } = {
defaultModel: alias,
};
if (existing !== undefined) {
patch.models = { ...existing, [alias]: existing[alias] ?? '' };
if (session !== undefined && host.engineV2) {
await session.setSecondaryModel(alias);
}
if (persist) {
const config = await host.harness.getConfig({ reload: true });
const existing = config.secondaryModel?.models;
const patch: { defaultModel: string; models?: Record<string, string> } = {
defaultModel: alias,
};
if (existing !== undefined) {
patch.models = { ...existing, [alias]: existing[alias] ?? '' };
}
await host.harness.setConfig({ secondaryModel: patch });
}
await host.harness.setConfig({ secondaryModel: patch });
} catch (error) {
host.showError(`Failed to save secondary model: ${formatErrorMessage(error)}`);
return;
}
host.showStatus(
`Secondary model set to ${displayName}. Newly spawned subagents will use it by default.`,
persist
? `Secondary model set to ${displayName}. Newly spawned subagents will use it by default.`
: `Secondary model set to ${displayName} for this session only. Newly spawned subagents will use it by default.`,
'success',
);
}
Expand Down
45 changes: 44 additions & 1 deletion apps/kimi-code/test/tui/commands/secondary-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface PickerOptions {
readonly title?: string;
readonly thinkingControl?: boolean;
readonly onSelect: (selection: { alias: string }) => void;
readonly onSessionOnlySelect?: (selection: { alias: string }) => void;
}

function model(name: string): ModelAlias {
Expand All @@ -32,6 +33,7 @@ function model(name: string): ModelAlias {

function makeHost(options?: {
readonly secondaryModel?: { defaultModel?: string; models?: Record<string, string> };
readonly engineV2?: boolean;
}) {
const appState = {
availableModels: {
Expand All @@ -45,11 +47,21 @@ function makeHost(options?: {
availableProviders: {},
transcriptEntries: [],
};
const session =
options?.engineV2 === true
? {
setSecondaryModel: vi.fn(async () => {}),
getSecondaryModel: vi.fn(async () => undefined),
}
: undefined;
const host = {
state: {
appState,
transcriptEntries: [],
},
engineV2: options?.engineV2 === true,
session,
waitForLazyCreation: vi.fn(async () => {}),
authFlow: {
refreshOAuthProviderModels: vi.fn(async () => undefined),
},
Expand Down Expand Up @@ -77,7 +89,7 @@ function makeHost(options?: {
showError: ReturnType<typeof vi.fn>;
showNotice: ReturnType<typeof vi.fn>;
};
return { host };
return { host, session };
}

function mountedPicker(host: { mountEditorReplacement: ReturnType<typeof vi.fn> }): PickerOptions {
Expand Down Expand Up @@ -231,4 +243,35 @@ describe('handleSecondaryModelCommand', () => {
expect(host.showError.mock.calls[0]![0]).toContain('disk full');
expect(host.showStatus).not.toHaveBeenCalled();
});

it('applies the selection to the v2 session first and then persists on Enter', async () => {
const { host, session } = makeHost({ engineV2: true });

await handleSecondaryModelCommand(host, '');
mountedPicker(host).onSelect({ alias: 'k2' });

await vi.waitFor(() => {
expect(host.showStatus).toHaveBeenCalled();
});
expect(session!.setSecondaryModel).toHaveBeenCalledWith('k2');
expect(host.harness.setConfig).toHaveBeenCalledWith({
secondaryModel: { defaultModel: 'k2' },
});
});

it('applies Alt+S to the v2 session only, without touching the config file', async () => {
const { host, session } = makeHost({ engineV2: true });

await handleSecondaryModelCommand(host, '');
const opts = mountedPicker(host);
expect(opts.onSessionOnlySelect).toBeDefined();
opts.onSessionOnlySelect!({ alias: 'k2' });

await vi.waitFor(() => {
expect(host.showStatus).toHaveBeenCalled();
});
expect(session!.setSecondaryModel).toHaveBeenCalledWith('k2');
expect(host.harness.setConfig).not.toHaveBeenCalled();
expect(host.showStatus.mock.calls[0]![0]).toContain('for this session only');
});
});
7 changes: 5 additions & 2 deletions docs/en/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,9 @@ Constraints between the fields:

Pool aliases reference the current `[models]` table: if a provider is later deleted or logged out, or its refreshed model list no longer contains an alias, session startup fails with a configuration error naming the broken alias — fix or remove the entry to recover. The `[secondary_model]` section itself is never rewritten automatically.

In the interactive TUI, the [`/secondary-model`](../reference/slash-commands.md) command (alias `/subagent-model`) opens a model selector: the choice is written to `default_model` (when a models table exists and the picked alias is not in it, an entry with an empty description is added), and newly spawned subagents pick up the new default immediately — no session restart needed.
In the interactive TUI, the [`/secondary-model`](../reference/slash-commands.md) command (alias `/subagent-model`) opens a model selector. `Enter` saves the choice as the default: it is written to `default_model` (when a models table exists and the picked alias is not in it, an entry with an empty description is added) and applied to the current session, so newly spawned subagents pick it up immediately. `Alt-S` applies the choice to the current session only — the saved default and other windows stay unchanged.

The saved `default_model` is only the initial value a session starts with. Editing it never moves windows that are already running; it takes effect in newly started sessions.

A configured pool — an explicit `models` table or a lone `default_model` — enables model selection: the `Agent` / `AgentSwarm` tools gain a `model` parameter, and the tool description lists the pool (the default marked `[default]`) so the main agent can choose per spawn. Pool keys can only reference configured [`[models]`](#models) entries — the `kimi-code/*` aliases below are provisioned by `/login`:

Expand All @@ -240,7 +242,8 @@ default_model = "kimi-code/kimi-for-coding-highspeed"
A spawn resolves the subagent's model in this order:

1. An explicit `model` passed in the tool call
2. `default_model`
2. The session-scoped selection (set with `Alt-S` in `/secondary-model`), when present
3. `default_model`

Rules for the `model` parameter:

Expand Down
2 changes: 1 addition & 1 deletion docs/en/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Some commands are only available in the idle state. Executing these commands whi
| `/logout` | — | Clear credentials for the currently selected account | No |
| `/provider` | — | Open the interactive provider manager to view, add, and remove configured providers. See [Platforms & Models — `/provider` and provider management](../configuration/providers.md#provider-—-interactive-provider-management) | Yes |
| `/model` | — | Switch the LLM model used in the current session | Yes |
| `/secondary-model` | `/subagent-model` | Pick the default model for subagents (writes `[secondary_model] default_model`; see the [subagent model pool](../configuration/config-files.md#subagent-model-pool)). Hidden when the subagent model pool is disabled | Yes |
| `/secondary-model` | `/subagent-model` | Pick the default model for subagents (`Enter` writes `[secondary_model] default_model`; `Alt-S` applies to the current session only; see the [subagent model pool](../configuration/config-files.md#subagent-model-pool)). Hidden when the subagent model pool is disabled | Yes |
| `/settings` | `/config` | Open the settings panel inside the TUI | Yes |
| `/experiments` | `/experimental` | Open the experimental feature panel | Yes |
| `/permission` | — | Select a permission mode | Yes |
Expand Down
7 changes: 5 additions & 2 deletions docs/zh/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,9 @@ default_model = "kimi-code/kimi-for-coding-highspeed"

池别名引用的是 `[models]` 表的当前内容:如果之后删除供应商、登出账号,或其刷新后的模型列表不再包含某个别名,会话启动时会报出指明失效别名的配置错误,修正或移除对应条目即可恢复。系统不会自动改写 `[secondary_model]` 节。

在交互式 TUI 中,也可以用 [`/secondary-model`](../reference/slash-commands.md) 命令(别名 `/subagent-model`)打开模型选择器:选择后写入 `default_model`(已有 models 表而所选别名不在其中时,会一并补一条空描述条目),之后派生的 subagent 立即按新默认值绑定,无需重启会话。
在交互式 TUI 中,也可以用 [`/secondary-model`](../reference/slash-commands.md) 命令(别名 `/subagent-model`)打开模型选择器。按 `Enter` 会把所选模型保存为默认值:写入 `default_model`(已有 models 表而所选别名不在其中时,会一并补一条空描述条目),并同时应用到当前会话,之后派生的 subagent 立即按新默认值绑定。按 `Alt-S` 则只应用到当前会话——保存的默认值和其他窗口都不受影响。

保存的 `default_model` 只是会话启动时的初始值:修改它不会改变正在运行的窗口,只对新建的会话生效。

配置了模型池(显式的 `models` 表或隐式的单条目池)即启用模型选择:`Agent` / `AgentSwarm` 工具会获得 `model` 参数,工具描述中列出模型池(默认模型标注 `[default]`),main agent 可按次派生选择模型。池 key 只能引用已配置的 [`[models]`](#models) 条目——下面的 `kimi-code/*` 别名由 `/login` 自动提供:

Expand All @@ -240,7 +242,8 @@ default_model = "kimi-code/kimi-for-coding-highspeed"
派生时按以下顺序解析 subagent 的模型:

1. 工具调用显式传入的 `model`
2. `default_model`
2. 会话级选择(在 `/secondary-model` 中按 `Alt-S` 设置),如果存在
3. `default_model`

`model` 参数的取值规则:

Expand Down
2 changes: 1 addition & 1 deletion docs/zh/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
| `/logout` | — | 清除当前所选账号的凭据 | 否 |
| `/provider` | — | 打开交互式供应商管理器,查看、添加和删除已配置的供应商。详见[平台与模型 — `/provider` 与供应商管理](../configuration/providers.md#provider-—-交互式供应商管理) | 是 |
| `/model` | — | 切换当前会话使用的 LLM 模型 | 是 |
| `/secondary-model` | `/subagent-model` | 选择 subagent 的默认模型(写入 `[secondary_model] default_model`,详见[subagent 模型池](../configuration/config-files.md#subagent-模型池))。subagent 模型池被禁用时不显示 | 是 |
| `/secondary-model` | `/subagent-model` | 选择 subagent 的默认模型(`Enter` 写入 `[secondary_model] default_model`,`Alt-S` 仅应用于当前会话;详见[subagent 模型池](../configuration/config-files.md#subagent-模型池))。subagent 模型池被禁用时不显示 | 是 |
| `/settings` | `/config` | 打开 TUI 内的设置面板 | 是 |
| `/experiments` | `/experimental` | 打开实验功能面板 | 是 |
| `/permission` | — | 选择权限模式 | 是 |
Expand Down
5 changes: 4 additions & 1 deletion packages/agent-core-v2/docs/state-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
// references become '(circular)', and class instances collapse to a '(ClassName)'
// marker — the wire shape of an entry is the JSON projection of the type here.
//
// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 83 keys)
// Index (App: 0 keys · Workspace: 6 keys · Session: 10 keys · Agent: 83 keys)
// App
// Workspace
// workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts
Expand All @@ -43,6 +43,7 @@
// sessionMetadata.data src/session/sessionMetadata/sessionMetadataService.ts
// sessionSkillCatalog.contributions src/features/skill/session/skillCatalogService.ts
// sessionSkillCatalog.merged src/features/skill/session/skillCatalogService.ts
// sessionSubagent.secondaryModel src/session/subagent/subagentService.ts
// sessionToolPolicy.state src/session/sessionToolPolicy/sessionToolPolicyService.ts
// workspaceContext.additionalDirs src/session/workspaceContext/workspaceContextService.ts
// workspaceContext.workDir src/session/workspaceContext/workspaceContextService.ts
Expand Down Expand Up @@ -696,6 +697,8 @@ export interface SessionStateSnapshot {
'sessionToolPolicy.state': /* SessionToolPolicyState — packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicyService.ts */ {
readonly disabledTools: readonly string[];
};
// src/session/subagent/subagentService.ts
'sessionSubagent.secondaryModel': string | undefined;
// src/session/workspaceContext/workspaceContextService.ts
'workspaceContext.additionalDirs': string[];
'workspaceContext.workDir': string;
Expand Down
7 changes: 6 additions & 1 deletion packages/agent-core-v2/src/agent/tools/agent/agentTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,11 @@ export class SubagentTool implements ISubagentTool {
readonly name: string = 'Agent';

get parameters(): Record<string, unknown> {
const parameters = exposesSubagentModelChoice(this.config, this.flags)
const parameters = exposesSubagentModelChoice(
this.config,
this.flags,
this.subagents.secondaryModel,
)
Comment on lines +88 to +92

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 Pass the session default into the Agent description

When a session-only model is selected, this makes the Agent schema expose the model parameter, but the description still calls buildSubagentModelDescriptions without this.subagents.secondaryModel. With no configured pool it therefore advertises no model choices at all, and with a pool it continues marking the persisted default rather than the active session default; pass the same session value to the description builder as AgentSwarmTool does.

Useful? React with 👍 / 👎.

? SUBAGENT_TOOL_PARAMETERS
: SUBAGENT_TOOL_PARAMETERS_NO_MODEL;
return this.flags.enabled(SUBAGENT_FORK_FLAG_ID)
Expand Down Expand Up @@ -151,6 +155,7 @@ export class SubagentTool implements ISubagentTool {
this.config,
this.flags,
this.profile.data().modelAlias,
this.subagents.secondaryModel,
);
if (modelLines !== undefined) {
description += `\n\n${modelLines}`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@ export class AgentSwarmTool implements IAgentSwarmTool {
readonly name = 'AgentSwarm' as const;

get parameters(): Record<string, unknown> {
const parameters = exposesSubagentModelChoice(this.config, this.flags)
const parameters = exposesSubagentModelChoice(
this.config,
this.flags,
this.subagents.secondaryModel,
)
? AGENT_SWARM_PARAMETERS
: AGENT_SWARM_PARAMETERS_NO_MODEL;
return this.flags.enabled(SUBAGENT_FORK_FLAG_ID)
Expand Down Expand Up @@ -104,6 +108,7 @@ export class AgentSwarmTool implements IAgentSwarmTool {
this.config,
this.flags,
this.profile.data().modelAlias,
this.subagents.secondaryModel,
);
return modelLines === undefined ? description : `${description}\n\n${modelLines}`;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export class TowerSpawnTool implements ITowerSpawnTool {
try {
const controller = new AbortController();
const own = this.profile.data();
const sessionDefault = await this.subagents.getSecondaryModel();
const binding =
own.modelAlias === undefined
? undefined
Expand All @@ -178,6 +179,7 @@ export class TowerSpawnTool implements ITowerSpawnTool {
args.kind === 'reviewer' && !isSubagentModelForced(this.config)
? 'primary'
: undefined,
sessionDefault,
);
let handle: SubagentHandle;
try {
Expand Down
Loading