diff --git a/.changeset/sdk-session-secondary-model.md b/.changeset/sdk-session-secondary-model.md new file mode 100644 index 00000000000..71642855719 --- /dev/null +++ b/.changeset/sdk-session-secondary-model.md @@ -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`). diff --git a/.changeset/secondary-model-session-only.md b/.changeset/secondary-model-session-only.md new file mode 100644 index 00000000000..aea15f6c827 --- /dev/null +++ b/.changeset/secondary-model-session-only.md @@ -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. diff --git a/.changeset/secondary-model-stable-default.md b/.changeset/secondary-model-stable-default.md new file mode 100644 index 00000000000..e94a4e707bb --- /dev/null +++ b/.changeset/secondary-model-stable-default.md @@ -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. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 4832e930e37..ece9c39292b 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -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); } @@ -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); + } + : undefined, onCancel: () => { host.restoreEditor(); }, @@ -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 { +async function performSecondaryModelSave( + host: SlashCommandHost, + alias: string, + persist: boolean, +): Promise { 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 } = { - 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 } = { + 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', ); } diff --git a/apps/kimi-code/test/tui/commands/secondary-model.test.ts b/apps/kimi-code/test/tui/commands/secondary-model.test.ts index 9bce58d4c0f..6ab9bab76d6 100644 --- a/apps/kimi-code/test/tui/commands/secondary-model.test.ts +++ b/apps/kimi-code/test/tui/commands/secondary-model.test.ts @@ -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 { @@ -32,6 +33,7 @@ function model(name: string): ModelAlias { function makeHost(options?: { readonly secondaryModel?: { defaultModel?: string; models?: Record }; + readonly engineV2?: boolean; }) { const appState = { availableModels: { @@ -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), }, @@ -77,7 +89,7 @@ function makeHost(options?: { showError: ReturnType; showNotice: ReturnType; }; - return { host }; + return { host, session }; } function mountedPicker(host: { mountEditorReplacement: ReturnType }): PickerOptions { @@ -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'); + }); }); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 28957a03b1b..a7529d92ea8 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -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`: @@ -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: diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 726578b4aa5..16b6548c238 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -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 | diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 76f4b135395..04b5be67b69 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -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` 自动提供: @@ -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` 参数的取值规则: diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 986e8d755fc..f186f9db4e4 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -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` | — | 选择权限模式 | 是 | diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 6294b09f02e..c38634c600d 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -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 @@ -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 @@ -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; diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index 1eedfdf1505..516725db719 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -85,7 +85,11 @@ export class SubagentTool implements ISubagentTool { readonly name: string = 'Agent'; get parameters(): Record { - const parameters = exposesSubagentModelChoice(this.config, this.flags) + const parameters = exposesSubagentModelChoice( + this.config, + this.flags, + this.subagents.secondaryModel, + ) ? SUBAGENT_TOOL_PARAMETERS : SUBAGENT_TOOL_PARAMETERS_NO_MODEL; return this.flags.enabled(SUBAGENT_FORK_FLAG_ID) @@ -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}`; diff --git a/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agentSwarmTool.ts b/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agentSwarmTool.ts index 997fd27549e..b8395539d62 100644 --- a/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agentSwarmTool.ts +++ b/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agentSwarmTool.ts @@ -73,7 +73,11 @@ export class AgentSwarmTool implements IAgentSwarmTool { readonly name = 'AgentSwarm' as const; get parameters(): Record { - 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) @@ -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}`; } diff --git a/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts index 7ab16708351..ae37639ea15 100644 --- a/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts @@ -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 @@ -178,6 +179,7 @@ export class TowerSpawnTool implements ITowerSpawnTool { args.kind === 'reviewer' && !isSubagentModelForced(this.config) ? 'primary' : undefined, + sessionDefault, ); let handle: SubagentHandle; try { diff --git a/packages/agent-core-v2/src/session/subagent/configSection.ts b/packages/agent-core-v2/src/session/subagent/configSection.ts index f71ed8e5155..8c9c2288dfc 100644 --- a/packages/agent-core-v2/src/session/subagent/configSection.ts +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -114,10 +114,11 @@ export function isSubagentModelForced(config: IConfigService): boolean { export function exposesSubagentModelChoice( config: IConfigService, flags: IFlagService, + sessionDefault?: string, ): boolean { if (!flags.enabled(SECONDARY_MODEL_FLAG_ID)) return false; if (isSubagentModelForced(config)) return false; - return resolveSubagentModelPool(config) !== undefined; + return resolveSubagentModelPool(config) !== undefined || sessionDefault !== undefined; } export const SECONDARY_MODEL_DEFAULT_MODEL_REQUIRED_MESSAGE = @@ -189,11 +190,30 @@ export function assertValidSubagentModelConfig( export type SubagentModelSource = 'forced' | 'primary_override' | 'inherited' | 'secondary_pool'; +export function poolWithSessionDefault( + configPool: SubagentModelPool | undefined, + sessionDefault: string | undefined, +): SubagentModelPool | undefined { + if (configPool === undefined) { + return sessionDefault === undefined + ? undefined + : { defaultModel: sessionDefault, models: { [sessionDefault]: '' } }; + } + if (sessionDefault === undefined || Object.hasOwn(configPool.models, sessionDefault)) { + return configPool; + } + return { + defaultModel: configPool.defaultModel, + models: { ...configPool.models, [sessionDefault]: '' }, + }; +} + export function resolveSubagentBinding( config: IConfigService, flags: IFlagService, own: { modelAlias: string; thinkingLevel: string }, requested?: string, + sessionDefault?: string, ): { model: string; thinking?: string; modelSource: SubagentModelSource } { const section = config.get(SECONDARY_MODEL_SECTION); const enabled = flags.enabled(SECONDARY_MODEL_FLAG_ID); @@ -221,7 +241,9 @@ export function resolveSubagentBinding( if (requested === PRIMARY_SUBAGENT_MODEL_CHOICE) { return { model: own.modelAlias, thinking: own.thinkingLevel, modelSource: 'primary_override' }; } - const pool = enabled ? resolveSubagentModelPool(config) : undefined; + const bound = enabled ? sessionDefault : undefined; + const configPool = enabled ? resolveSubagentModelPool(config) : undefined; + const pool = poolWithSessionDefault(configPool, bound); if (pool === undefined) { if (requested !== undefined) { throw new Error2( @@ -241,20 +263,20 @@ export function resolveSubagentBinding( }, }); } - const choice = requested ?? pool.defaultModel; - if (choice === undefined) { - throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_DEFAULT_MODEL_REQUIRED_MESSAGE, { - details: { section: SECONDARY_MODEL_SECTION, field: 'defaultModel' }, - }); - } - if (!Object.hasOwn(pool.models, choice)) { + if (requested !== undefined && !Object.hasOwn(pool.models, requested)) { const available = [...Object.keys(pool.models), PRIMARY_SUBAGENT_MODEL_CHOICE]; throw new Error2( ErrorCodes.CONFIG_INVALID, - `Invalid model "${choice}". Available models: ${available.join(', ')}.`, - { details: { model: choice, availableModels: available } }, + `Invalid model "${requested}". Available models: ${available.join(', ')}.`, + { details: { model: requested, availableModels: available } }, ); } + const choice = requested ?? bound ?? pool.defaultModel; + if (choice === undefined) { + throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_DEFAULT_MODEL_REQUIRED_MESSAGE, { + details: { section: SECONDARY_MODEL_SECTION, field: 'defaultModel' }, + }); + } return { model: choice, thinking: section?.defaultEffort, modelSource: 'secondary_pool' }; } @@ -272,11 +294,13 @@ export function buildSubagentModelDescriptions( config: IConfigService, flags: IFlagService, callerModelAlias: string | undefined, + sessionDefault?: string, ): string | undefined { - if (!exposesSubagentModelChoice(config, flags)) return undefined; - const pool = resolveSubagentModelPool(config)!; + if (!exposesSubagentModelChoice(config, flags, sessionDefault)) return undefined; + const pool = poolWithSessionDefault(resolveSubagentModelPool(config), sessionDefault); + if (pool === undefined) return undefined; const lines = ['Available models (pass via model):']; - const defaultModel = pool.defaultModel; + const defaultModel = sessionDefault ?? pool.defaultModel; const markersFor = (alias: string): string => { const markers: string[] = []; if (alias === defaultModel) markers.push('[default]'); diff --git a/packages/agent-core-v2/src/session/subagent/subagent.ts b/packages/agent-core-v2/src/session/subagent/subagent.ts index d3994abc5eb..b4640f29dd3 100644 --- a/packages/agent-core-v2/src/session/subagent/subagent.ts +++ b/packages/agent-core-v2/src/session/subagent/subagent.ts @@ -51,12 +51,18 @@ export interface ISessionSubagentService { readonly onDidStopAgentTask: Event; + readonly secondaryModel: string | undefined; + run(agent: AgentContext, request: AgentRunRequest, opts: RunAgentOptions): Promise; planSpawn(input: SubagentSpawnPlanInput): Promise; spawn(opts: SpawnSubagentOptions): Promise; + getSecondaryModel(): Promise; + + setSecondaryModel(model: string): Promise; + notifyAgentTaskStopped(context: AgentTaskStopHookContext): void; } diff --git a/packages/agent-core-v2/src/session/subagent/subagentService.ts b/packages/agent-core-v2/src/session/subagent/subagentService.ts index ee2eff33112..cc44915a4bd 100644 --- a/packages/agent-core-v2/src/session/subagent/subagentService.ts +++ b/packages/agent-core-v2/src/session/subagent/subagentService.ts @@ -27,6 +27,9 @@ import { IFlagService } from '#/app/flag/flag'; import { IModelCatalog, type Model } from '#/kosong/model/catalog'; import { ILogService } from '#/_base/log/log'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionStateService } from '#/session/state/sessionState'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { defineState } from '#/state/state'; import { RuntimeWorkspaceView } from '#/runtime/runtimeWorkspaceView'; import { createHooks } from '#/hooks'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; @@ -43,7 +46,9 @@ import { } from './subagent'; import { runAgentTurn } from './runAgentTurn'; import { + PRIMARY_SUBAGENT_MODEL_CHOICE, resolveSubagentBinding, + resolveSubagentModelPool, resolveSubagentThinking, wrapSubagentModelError, } from './configSection'; @@ -56,6 +61,13 @@ import { type SubagentSpawnPlanInput, } from './spawn'; +export const sessionSecondaryModelKey = defineState( + 'sessionSubagent.secondaryModel', + () => undefined, +); + +const SECONDARY_MODEL_DOC_KEY = 'secondary-model.json'; + export class SessionSubagentService extends Service implements ISessionSubagentService { declare readonly _serviceBrand: undefined; @@ -63,11 +75,25 @@ export class SessionSubagentService extends Service implements ISessionSubagentS private readonly onDidStopAgentTaskEmitter = this._register( new Emitter(), ); + readonly ready: Promise; + + private secondaryModelSeeded = false; + private secondaryModelSeed: string | undefined; get onDidStopAgentTask() { return this.onDidStopAgentTaskEmitter.event; } + get secondaryModel(): string | undefined { + const persisted = this.states.get(sessionSecondaryModelKey); + if (persisted !== undefined) return persisted; + if (!this.secondaryModelSeeded) { + this.secondaryModelSeeded = true; + this.secondaryModelSeed = resolveSubagentModelPool(this.configService)?.defaultModel; + } + return this.secondaryModelSeed; + } + constructor( @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, @ISessionAgentProfileCatalog private readonly catalog: ISessionAgentProfileCatalog, @@ -76,8 +102,53 @@ export class SessionSubagentService extends Service implements ISessionSubagentS @IModelCatalog private readonly modelCatalog: IModelCatalog, @ISessionContext private readonly sessionContext: ISessionContext, @ILogService private readonly log: ILogService, + @ISessionStateService private readonly states: ISessionStateService, + @IAtomicDocumentStore private readonly documentStore: IAtomicDocumentStore, ) { super(); + this.states.contributeState(sessionSecondaryModelKey); + this.ready = this.loadSecondaryModel(); + } + + async getSecondaryModel(): Promise { + await this.ready; + return this.secondaryModel; + } + + async setSecondaryModel(model: string): Promise { + await this.ready; + if (model === PRIMARY_SUBAGENT_MODEL_CHOICE) { + throw new Error2( + ErrorCodes.CONFIG_INVALID, + `Subagent model "${PRIMARY_SUBAGENT_MODEL_CHOICE}" is reserved: it always binds the caller's own model and cannot be the session default.`, + { details: { model } }, + ); + } + try { + this.modelCatalog.get(model); + } catch (error) { + throw wrapSubagentModelError(error, model, undefined); + } + this.states.set(sessionSecondaryModelKey, model); + await this.documentStore.set(this.sessionContext.metaScope, SECONDARY_MODEL_DOC_KEY, { + model, + }); + } + + private async loadSecondaryModel(): Promise { + try { + const doc = await this.documentStore.get<{ model?: unknown }>( + this.sessionContext.metaScope, + SECONDARY_MODEL_DOC_KEY, + ); + if (typeof doc?.model === 'string' && doc.model.length > 0) { + this.states.set(sessionSecondaryModelKey, doc.model); + } + } catch (error) { + this.log.warn('failed to load the session secondary model', { + error: error instanceof Error ? error.message : String(error), + }); + } } run(agent: AgentContext, request: AgentRunRequest, opts: RunAgentOptions): Promise { @@ -98,6 +169,7 @@ export class SessionSubagentService extends Service implements ISessionSubagentS const caller = this.requireCaller(input.callerAgentId); const fork = input.fork === true; await this.catalog.ready; + await this.ready; const own = caller.accessor.get(IAgentProfileService).data(); const requested = input.profileName !== undefined && input.profileName.length > 0 ? input.profileName @@ -137,6 +209,7 @@ export class SessionSubagentService extends Service implements ISessionSubagentS this.flags, { modelAlias: own.modelAlias, thinkingLevel: own.thinkingLevel }, input.model, + this.secondaryModel, ); let model: Model; try { diff --git a/packages/agent-core-v2/test/features/swarm/sessionSwarm.test.ts b/packages/agent-core-v2/test/features/swarm/sessionSwarm.test.ts index 98d39fcd95f..326d7b48a64 100644 --- a/packages/agent-core-v2/test/features/swarm/sessionSwarm.test.ts +++ b/packages/agent-core-v2/test/features/swarm/sessionSwarm.test.ts @@ -1284,6 +1284,9 @@ function subagentStub( _serviceBrand: undefined, hooks: createHooks(['onWillStartAgentTask']), onDidStopAgentTask: Event.None, + secondaryModel: undefined, + getSecondaryModel: vi.fn(async () => undefined), + setSecondaryModel: vi.fn(async () => {}), run: vi.fn(async (agent: AgentContext) => ({ agentId: agent.agentId, turn: {} as never, diff --git a/packages/agent-core-v2/test/features/swarm/swarm.test.ts b/packages/agent-core-v2/test/features/swarm/swarm.test.ts index 16d151c1e7d..c0a8dc108be 100644 --- a/packages/agent-core-v2/test/features/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/features/swarm/swarm.test.ts @@ -40,6 +40,8 @@ import { import { ISessionSubagentService } from '#/session/subagent/subagent'; import { SessionSubagentService } from '#/session/subagent/subagentService'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { SessionStateService } from '#/session/state/sessionStateService'; +import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import type { @@ -271,6 +273,8 @@ function realSubagents( modelCatalog, sessionContext, stubLog(), + new SessionStateService(), + new JsonAtomicDocumentStore(new InMemoryStorageService()), ); } diff --git a/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts b/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts index f4cdf4958f8..d1f7d5c233c 100644 --- a/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts +++ b/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts @@ -177,7 +177,10 @@ describe('TowerSpawnTool', () => { }, create: createAgent, } as unknown as IAgentLifecycleService); - ix.stub(ISessionSubagentService, { run: runAgent } as unknown as ISessionSubagentService); + ix.stub(ISessionSubagentService, { + run: runAgent, + getSecondaryModel: async () => undefined, + } as unknown as ISessionSubagentService); ix.stub(IAgentTaskService, { registerTask, getTask: (taskId: string) => taskInfoLookup(taskId) } as unknown as IAgentTaskService); ix.stub(IAgentProfileService, { data: () => ({ profileName: 'agent', modelAlias: 'kimi-code', thinkingLevel: 'off' }), diff --git a/packages/agent-core-v2/test/session/subagent/spawn.test.ts b/packages/agent-core-v2/test/session/subagent/spawn.test.ts index f1d3023ca67..a7a8ef16487 100644 --- a/packages/agent-core-v2/test/session/subagent/spawn.test.ts +++ b/packages/agent-core-v2/test/session/subagent/spawn.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; +import { ServiceCollection } from '#/_base/di/serviceCollection'; import { TestInstantiationService } from '#/_base/di/test'; import { Event } from '#/_base/event'; import { LifecycleScope } from '#/app/scopes'; @@ -26,6 +27,12 @@ import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle' import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionStateService } from '#/session/state/sessionState'; +import { SessionStateService } from '#/session/state/sessionStateService'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; +import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; +import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { SECONDARY_MODEL_SECTION } from '#/session/subagent/configSection'; import { ISessionSubagentService } from '#/session/subagent/subagent'; import { SessionSubagentService } from '#/session/subagent/subagentService'; @@ -212,8 +219,15 @@ describe('SessionSubagentService planSpawn and spawn', () => { return { id: alias, ...modelMeta.get(alias) } as Model; }, } as unknown as IModelCatalog); - ix.stub(ISessionContext, { _serviceBrand: undefined, cwd: '/repo' } as unknown as ISessionContext); + ix.stub(ISessionContext, { + _serviceBrand: undefined, + cwd: '/repo', + metaScope: 'sessions/wd_test/s1/session-meta', + } as unknown as ISessionContext); ix.stub(ILogService, stubLog()); + ix.set(ISessionStateService, new SyncDescriptor(SessionStateService)); + ix.set(IFileSystemStorageService, new SyncDescriptor(InMemoryStorageService)); + ix.set(IAtomicDocumentStore, new SyncDescriptor(JsonAtomicDocumentStore)); }); afterEach(() => { @@ -226,6 +240,12 @@ describe('SessionSubagentService planSpawn and spawn', () => { return ix.get(ISessionSubagentService); } + function serviceWithConfig(config: StubConfigService): ISessionSubagentService { + ix.stub(IConfigService, config); + ix.set(ISessionSubagentService, new SyncDescriptor(SessionSubagentService)); + return ix.get(ISessionSubagentService); + } + async function planSpawnError( svc: ISessionSubagentService, input: SubagentSpawnPlanInput, @@ -465,6 +485,116 @@ describe('SessionSubagentService planSpawn and spawn', () => { }); }); + it('keeps the seed default captured at first use when the global default changes mid-session', async () => { + modelIds.add('provider/fast').add('provider/smart'); + const config = new StubConfigService({ + [SECONDARY_MODEL_SECTION]: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast', 'provider/smart': 'smart' }, + }, + }); + const svc = serviceWithConfig(config); + + const first = await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' }); + expect(first.model).toBe('provider/fast'); + + await config.replace(SECONDARY_MODEL_SECTION, { + defaultModel: 'provider/smart', + models: { 'provider/fast': 'fast', 'provider/smart': 'smart' }, + }); + + const second = await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' }); + expect(second.model).toBe('provider/fast'); + }); + + it('binds the session-scoped secondary model ahead of the pool default', async () => { + modelIds.add('provider/fast').add('provider/smart'); + const svc = service({ + [SECONDARY_MODEL_SECTION]: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast', 'provider/smart': 'smart' }, + }, + }); + + await svc.setSecondaryModel('provider/smart'); + + const plan = await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' }); + expect(plan.model).toBe('provider/smart'); + expect(plan.modelSource).toBe('secondary_pool'); + expect(await svc.getSecondaryModel()).toBe('provider/smart'); + }); + + it('lets explicit spawn choices and [secondary_model].force win over the session-scoped default', async () => { + modelIds.add('provider/fast').add('provider/smart'); + const svc = service({ + [SECONDARY_MODEL_SECTION]: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast', 'provider/smart': 'smart' }, + }, + }); + await svc.setSecondaryModel('provider/smart'); + + const explicit = await svc.planSpawn({ + callerAgentId: CALLER_ID, + profileName: 'coder', + model: 'provider/fast', + }); + expect(explicit.model).toBe('provider/fast'); + + const child = ix.createChild( + new ServiceCollection([ISessionStateService, new SessionStateService()]), + ); + child.stub( + IConfigService, + new StubConfigService({ + [SECONDARY_MODEL_SECTION]: { force: true, defaultModel: 'provider/fast' }, + }), + ); + const forced = child.createInstance(SessionSubagentService); + await forced.setSecondaryModel('provider/smart'); + + const plan = await forced.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' }); + expect(plan.model).toBe('provider/fast'); + expect(plan.modelSource).toBe('forced'); + }); + + it('binds the session-scoped secondary model as an implicit pool when none is configured', async () => { + modelIds.add('provider/fast'); + const svc = service(); + + await svc.setSecondaryModel('provider/fast'); + + const plan = await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' }); + expect(plan).toEqual({ + profileName: 'coder', + model: 'provider/fast', + modelSource: 'secondary_pool', + thinking: undefined, + fork: false, + }); + }); + + it('rejects session-scoped secondary models that are unresolvable or reserved', async () => { + const svc = service(); + + await expect(svc.setSecondaryModel('provider/typo')).rejects.toThrow(/provider\/typo/); + await expect(svc.setSecondaryModel('primary')).rejects.toThrow(/reserved/); + await expect(svc.getSecondaryModel()).resolves.toBeUndefined(); + }); + + it('restores the session-scoped secondary model from the persisted document', async () => { + modelIds.add('provider/fast'); + const svc = service(); + await svc.setSecondaryModel('provider/fast'); + + const restored = ix + .createChild(new ServiceCollection([ISessionStateService, new SessionStateService()])) + .createInstance(SessionSubagentService); + await restored.ready; + + expect(await restored.getSecondaryModel()).toBe('provider/fast'); + }); + it('skips the allowlist check when forking', async () => { callerData = { ...callerData, profileName: 'coder', subagents: ['explore'] }; const svc = service(); diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 4adefe5a929..14b04e257b9 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -390,6 +390,9 @@ function createAgentLifecycleStub(options: AgentLifecycleStubOptions = {}): Agen return contextFor(agentId); }), notifyAgentTaskStopped: vi.fn(), + secondaryModel: undefined, + getSecondaryModel: vi.fn(async () => undefined), + setSecondaryModel: vi.fn(async () => {}), planSpawn: vi.fn(async () => { throw new Error('unexpected planSpawn'); }), diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 36ad66a82b7..0592b2f4025 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -124,6 +124,10 @@ export interface SetSessionThinkingRpcInput extends SessionIdRpcInput { readonly effort: string; } +export interface SetSessionSecondaryModelRpcInput extends SessionIdRpcInput { + readonly model: string; +} + export interface SetSessionPermissionRpcInput extends SessionIdRpcInput { readonly mode: PermissionMode; } @@ -653,6 +657,19 @@ export abstract class SDKRpcClientBase { }); } + async setSecondaryModel(input: SetSessionSecondaryModelRpcInput): Promise { + void input; + throw new KimiError( + ErrorCodes.NOT_IMPLEMENTED, + 'This SDK client does not support a session-scoped secondary model.', + ); + } + + async getSecondaryModel(input: SessionIdRpcInput): Promise { + void input; + return undefined; + } + async setPermission(input: SetSessionPermissionRpcInput): Promise { const rpc = await this.getRpc(); return rpc.setPermission({ diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 9da7370515a..c282265cb72 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -202,6 +202,7 @@ import { ISessionMcpHandle, ISessionMetadata, ISessionSkillCatalog, + ISessionSubagentService, IAgentTodoService, ISessionWorkspaceContext, ITelemetryService, @@ -261,6 +262,7 @@ import { type SetSessionModelRpcResult, type SetSessionPermissionRpcInput, type SetSessionPlanModeRpcInput, + type SetSessionSecondaryModelRpcInput, type SetSessionSwarmModeRpcInput, type SetSessionThinkingRpcInput, type SetSessionTowerModeRpcInput, @@ -1679,6 +1681,16 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { agent.accessor.get(IAgentProfileService).setThinking(input.effort); } + override async setSecondaryModel(input: SetSessionSecondaryModelRpcInput): Promise { + const session = this.requireLiveSession(input.sessionId); + await session.accessor.get(ISessionSubagentService).setSecondaryModel(input.model); + } + + override async getSecondaryModel(input: SessionIdRpcInput): Promise { + const session = this.requireLiveSession(input.sessionId); + return session.accessor.get(ISessionSubagentService).getSecondaryModel(); + } + override async setPermission(input: SetSessionPermissionRpcInput): Promise { const agent = await this.agentFacade(input.sessionId); return agent.setPermission(input.mode); diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index a2512c60305..d6046c6d355 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -280,6 +280,21 @@ export class Session { await this.rpc.setThinking({ sessionId: this.id, effort: normalized }); } + async setSecondaryModel(model: string): Promise { + this.ensureOpen(); + const normalized = normalizeRequiredString( + model, + 'Session secondary model cannot be empty', + ErrorCodes.SESSION_MODEL_EMPTY, + ); + await this.rpc.setSecondaryModel({ sessionId: this.id, model: normalized }); + } + + async getSecondaryModel(): Promise { + this.ensureOpen(); + return this.rpc.getSecondaryModel({ sessionId: this.id }); + } + async setPermission(mode: PermissionMode): Promise { this.ensureOpen(); if (!isPermissionMode(mode)) {