From 12f22a7d8e0d0348953182a84fa6bbdcccd7215b Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 28 Aug 2026 17:02:12 +0800 Subject: [PATCH 01/12] feat(secondary-model): enable the subagent model pool by default --- .changeset/secondary-model-default-on.md | 5 +++++ docs/en/configuration/config-files.md | 2 +- docs/en/configuration/env-vars.md | 2 +- docs/en/reference/slash-commands.md | 2 +- docs/en/reference/tools.md | 4 ++-- docs/zh/configuration/config-files.md | 2 +- docs/zh/configuration/env-vars.md | 2 +- docs/zh/reference/slash-commands.md | 2 +- docs/zh/reference/tools.md | 4 ++-- .../skill/catalog/builtin/update-config.md | 2 +- .../src/session/subagent/flag.ts | 2 +- packages/agent-core/src/flags/registry.ts | 2 +- packages/agent-core/test/agent/basic.test.ts | 6 +++--- packages/agent-core/test/agent/tool.test.ts | 5 ++++- .../test/session/subagent-host.test.ts | 20 +++++++++++++------ packages/kap-server/test/config.test.ts | 4 +++- packages/node-sdk/test/config.test.ts | 4 ++-- 17 files changed, 44 insertions(+), 26 deletions(-) create mode 100644 .changeset/secondary-model-default-on.md diff --git a/.changeset/secondary-model-default-on.md b/.changeset/secondary-model-default-on.md new file mode 100644 index 00000000000..bd9a16cb128 --- /dev/null +++ b/.changeset/secondary-model-default-on.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +The subagent model pool (`[secondary_model]`) is now enabled by default: configured subagents bind the pool's `default_model` and the `Agent` / `AgentSwarm` tools gain the `model` parameter. Set `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=0` to restore the inherit-only behavior. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 71d802dd69c..77cd8007284 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -196,7 +196,7 @@ Subagents inherit the model the main agent is running by default. The `[secondar ### Subagent model pool -This feature is experimental and disabled by default. Enable it with `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`, or the master `KIMI_CODE_EXPERIMENTAL_FLAG=1`; it takes effect in every launch mode, including the interactive TUI. While the experiment is off, the pool keys stay inert: subagents inherit the caller's model and session startup skips the pool validation. +The pool is enabled by default in every launch mode, including the interactive TUI. To disable it, set `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=0` (or `secondary-model = false` under `[experimental]` in `config.toml`); while disabled, the pool keys stay inert: subagents inherit the caller's model and session startup skips the pool validation. The minimal configuration is one line — a lone `default_model` is a pool with a single entry: diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 02fa25c8f5c..a45f60ac978 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -150,7 +150,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_CODE_IDENTITY_SLUG` | Protocol identifier for the `User-Agent` product token sent to third-party providers and the MCP client name; takes higher priority than `[identity] slug`. Derived from the name when unset | Any non-empty string; normalized to lowercase with non-alphanumeric runs folded to `-` | | `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` | Whether the built-in skills documenting Kimi Code itself are offered to the model; takes higher priority than `builtin_product_skills` in `config.toml` (default enabled) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_TUI_FULL_SCREEN` | Enable the experimental fullscreen alternate-screen UI: scrollable transcript viewport, mouse text selection, clickable links, and Ctrl-Shift-F transcript search | `1` enables it; anything else keeps the regular inline UI | -| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | Enable the experimental [subagent model pool](./config-files.md#subagent-model-pool) in every launch mode, including the interactive TUI; the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | +| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | The [subagent model pool](./config-files.md#subagent-model-pool) is enabled by default in every launch mode, including the interactive TUI; set a falsy value to disable it; the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` force-enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK` | Enable the experimental `fork` parameter on the `Agent` and `AgentSwarm` tools, letting the model start a subagent with a snapshot of the calling agent's conversation history instead of an empty context; the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | Global default connection timeout (ms) for all MCP servers; takes higher priority than `[mcp] startup_timeout_ms` in `config.toml`, but a per-server `startupTimeoutMs` in `mcp.json` still wins (default `30000`) | Integer from `1` to `2147483647`; invalid values are ignored | | `KIMI_MCP_TOOL_TIMEOUT_MS` | Global default single tool-call timeout (ms) for all MCP servers; takes higher priority than `[mcp] tool_timeout_ms` in `config.toml`, but a per-server `toolTimeoutMs` in `mcp.json` still wins (default `60000`) | Integer from `1` to `2147483647`; invalid values are ignored | diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index d6510ac1963..b137b1a0396 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)). Visible when the subagent model pool experiment is enabled | 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 | | `/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/en/reference/tools.md b/docs/en/reference/tools.md index 12272bb76bb..179e2f864a7 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -89,9 +89,9 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill | `AskUserQuestion` | Auto-allow | Ask the user a question to gather structured input | | `Skill` | Auto-allow | Invoke a registered inline Skill | -**`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), `run_in_background` (defaults to false), and `model` (available only when the [subagent model pool](../configuration/config-files.md#subagent-model-pool) experiment is enabled and a pool is configured — a `[secondary_model.models]` table or a lone `default_model`: a pool alias, or `"primary"` for the model the caller itself is running; ignored when resuming). Without it, the subagent binds the pool's `default_model`; without a configured pool, subagents always inherit the caller's model. Agent tasks time out after 2 hours by default; the limit is configurable via `[subagent] timeout_ms` in `config.toml` (`0` = no timeout, or the `KIMI_SUBAGENT_TIMEOUT_MS` env var), and defaults to no timeout in print mode (`kimi -p`). In foreground mode the parent Agent waits for the sub-Agent to complete before continuing; in background mode a task ID is returned immediately and the result is automatically delivered back to the main Agent via a synthetic User message when done. When several foreground `Agent` calls run in the same step, the TUI groups them and shows each subagent's running, waiting, completed, or failed status with elapsed time. See [Agent & Sub-Agents](../customization/agents.md) for details. +**`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), `run_in_background` (defaults to false), and `model` (available when a [subagent model pool](../configuration/config-files.md#subagent-model-pool) is configured — either a `[secondary_model.models]` table or a lone `default_model`: a pool alias, or `"primary"` for the model the caller itself is running; ignored when resuming). Without it, the subagent binds the pool's `default_model`; without a configured pool, subagents always inherit the caller's model. Agent tasks time out after 2 hours by default; the limit is configurable via `[subagent] timeout_ms` in `config.toml` (`0` = no timeout, or the `KIMI_SUBAGENT_TIMEOUT_MS` env var), and defaults to no timeout in print mode (`kimi -p`). In foreground mode the parent Agent waits for the sub-Agent to complete before continuing; in background mode a task ID is returned immediately and the result is automatically delivered back to the main Agent via a synthetic User message when done. When several foreground `Agent` calls run in the same step, the TUI groups them and shows each subagent's running, waiting, completed, or failed status with elapsed time. See [Agent & Sub-Agents](../customization/agents.md) for details. -**`AgentSwarm`** launches subagents from a shared `prompt_template` and an `items` array, resumes existing subagents through `resume_agent_ids`, or combines both in one call. The template must contain the `{{item}}` placeholder; each item replaces that placeholder and launches one new subagent. Pass `subagent_type` to choose the profile used by every spawned subagent in the swarm, or omit it to use `coder`. Pass `model` (available only when the [subagent model pool](../configuration/config-files.md#subagent-model-pool) experiment is enabled and a pool is configured — a `[secondary_model.models]` table or a lone `default_model`) to run item-spawned subagents on a pool alias or on the caller's own model (`"primary"`). Without it, item-spawned subagents bind the pool's `default_model`; without a configured pool, they inherit the caller's model. Resumed subagents keep their own model. Without `resume_agent_ids`, the tool requires at least 2 items; with `resume_agent_ids`, it can resume one or more existing subagents. The tool supports up to 128 total subagents, waits for all subagents to finish, and returns an aggregated report. Each subagent times out after 2 hours by default; the limit is configurable via [`[swarm] timeout_ms`](../configuration/config-files.md#swarm) in `config.toml` (`0` = no timeout, or the `KIMI_CODE_SWARM_TIMEOUT_MS` env var), and defaults to no timeout in print mode (`kimi -p`). A timed-out subagent is aborted and marked as failed in the aggregated report. In the TUI, foreground swarms show a live `Agent swarm` progress panel above the input box. If a model response calls `AgentSwarm`, that call must be the only tool call in the response; to run multiple swarms, call one `AgentSwarm`, wait for its result, then call the next, or combine the work into one swarm when a single template can cover it. In `manual` permission mode, `AgentSwarm` calls outside active swarm mode request approval unless a permission rule allows them; while swarm mode is active, `AgentSwarm` itself is auto-approved. Permission rules match `AgentSwarm` by tool name only — argument patterns such as `AgentSwarm(swarm)` are not supported. By default the tool ramps up concurrency without an upper limit (5 subagents start immediately, then 1 more every 700 ms); set `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` to a positive integer to cap how many subagents run at the same time during that ramp, or leave it unset for no cap. If it is set to a value that is not a positive integer, the AgentSwarm call fails fast. +**`AgentSwarm`** launches subagents from a shared `prompt_template` and an `items` array, resumes existing subagents through `resume_agent_ids`, or combines both in one call. The template must contain the `{{item}}` placeholder; each item replaces that placeholder and launches one new subagent. Pass `subagent_type` to choose the profile used by every spawned subagent in the swarm, or omit it to use `coder`. Pass `model` (available when a [subagent model pool](../configuration/config-files.md#subagent-model-pool) is configured — a `[secondary_model.models]` table or a lone `default_model`) to run item-spawned subagents on a pool alias or on the caller's own model (`"primary"`). Without it, item-spawned subagents bind the pool's `default_model`; without a configured pool, they inherit the caller's model. Resumed subagents keep their own model. Without `resume_agent_ids`, the tool requires at least 2 items; with `resume_agent_ids`, it can resume one or more existing subagents. The tool supports up to 128 total subagents, waits for all subagents to finish, and returns an aggregated report. Each subagent times out after 2 hours by default; the limit is configurable via [`[swarm] timeout_ms`](../configuration/config-files.md#swarm) in `config.toml` (`0` = no timeout, or the `KIMI_CODE_SWARM_TIMEOUT_MS` env var), and defaults to no timeout in print mode (`kimi -p`). A timed-out subagent is aborted and marked as failed in the aggregated report. In the TUI, foreground swarms show a live `Agent swarm` progress panel above the input box. If a model response calls `AgentSwarm`, that call must be the only tool call in the response; to run multiple swarms, call one `AgentSwarm`, wait for its result, then call the next, or combine the work into one swarm when a single template can cover it. In `manual` permission mode, `AgentSwarm` calls outside active swarm mode request approval unless a permission rule allows them; while swarm mode is active, `AgentSwarm` itself is auto-approved. Permission rules match `AgentSwarm` by tool name only — argument patterns such as `AgentSwarm(swarm)` are not supported. By default the tool ramps up concurrency without an upper limit (5 subagents start immediately, then 1 more every 700 ms); set `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` to a positive integer to cap how many subagents run at the same time during that ramp, or leave it unset for no cap. If it is set to a value that is not a positive integer, the AgentSwarm call fails fast. **`AskUserQuestion`** asks the user a structured multiple-choice question — useful for disambiguation or option selection. The `questions` parameter accepts 1–4 questions; each question requires `question` (ending with `?`), `options` (2–4 choices, each with a `label` and `description`), and optional `header` (max 12 characters) and `multi_select` (defaults to false). An "Other" option is appended automatically. Setting `background` to true starts a background question task and returns a task ID immediately. When the host does not support interactive questioning, a failure message is returned and the Agent should ask the user directly in a text reply instead. diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 597b352cdf5..0280bf9664d 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -196,7 +196,7 @@ subagent 默认继承 main agent 正在运行的模型。`[secondary_model]` 节 ### subagent 模型池 -该功能目前是实验功能,默认关闭。通过 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1` 启用,或使用 master `KIMI_CODE_EXPERIMENTAL_FLAG=1`,在包括交互式 TUI 在内的所有启动方式下生效。实验功能关闭时模型池配置不生效:subagent 继承调用方模型,会话启动也会跳过池校验。 +模型池默认启用,在包括交互式 TUI 在内的所有启动方式下生效。如需禁用,设置 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=0`(或在 `config.toml` 的 `[experimental]` 下配置 `secondary-model = false`);禁用期间模型池配置不生效:subagent 继承调用方模型,会话启动也会跳过池校验。 最小配置只有一行——单独写下的 `default_model` 就是只含一个条目的模型池: diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 7a4e2b9f91a..20b5e890da8 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -150,7 +150,7 @@ kimi | `KIMI_CODE_IDENTITY_SLUG` | 协议标识,用于发给第三方 provider 的 `User-Agent` 产品名和 MCP 客户端名,优先级高于 `[identity] slug`。未设置时由名称派生 | 任意非空字符串;会转小写并将连续非字母数字字符折叠为 `-` | | `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` | 是否向模型提供介绍 Kimi Code 自身的内置 Skills,优先级高于 `config.toml` 的 `builtin_product_skills`(默认开启) | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_TUI_FULL_SCREEN` | 启用实验性的 fullscreen alternate-screen 界面:可滚动的 transcript 视口、鼠标选择文本、可点击链接、Ctrl-Shift-F 搜索 | `1` 开启;其他值保持常规内联界面 | -| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | 在包括交互式 TUI 在内的所有启动方式下启用实验性的[subagent 模型池](./config-files.md#subagent-模型池);master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | +| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | [subagent 模型池](./config-files.md#subagent-模型池)默认启用,在包括交互式 TUI 在内的所有启动方式下生效;设为假值可禁用;master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 会强制启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK` | 在 `Agent` 和 `AgentSwarm` 工具上启用实验性的 `fork` 参数,让模型可以以调用方 Agent 对话历史的快照而不是空上下文启动 subagent;master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | 所有 MCP server 的全局默认连接超时(毫秒);优先级高于 `config.toml` 的 `[mcp] startup_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `startupTimeoutMs`(默认 `30000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | | `KIMI_MCP_TOOL_TIMEOUT_MS` | 所有 MCP server 的全局默认单次工具调用超时(毫秒);优先级高于 `config.toml` 的 `[mcp] tool_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `toolTimeoutMs`(默认 `60000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index cff37a00a0e..a3a99ae25d1 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 的默认模型(写入 `[secondary_model] default_model`,详见[subagent 模型池](../configuration/config-files.md#subagent-模型池))。subagent 模型池被禁用时不显示 | 是 | | `/settings` | `/config` | 打开 TUI 内的设置面板 | 是 | | `/experiments` | `/experimental` | 打开实验功能面板 | 是 | | `/permission` | — | 选择权限模式 | 是 | diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 2a872d36ee3..bbf2699ebc0 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -89,9 +89,9 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只 | `AskUserQuestion` | 自动放行 | 向用户提问以获取结构化输入 | | `Skill` | 自动放行 | 调用已注册的 inline Skill | -**`Agent`** 将子任务委托给 subagent 执行。必填参数:`prompt`(完整任务描述)和 `description`(3–5 个词的简短说明)。可选参数:`subagent_type`(默认 `coder`)、`resume`(恢复已有 Agent 的 ID,与 `subagent_type` 互斥)、`run_in_background`(默认 false)和 `model`(仅在启用 [subagent 模型池](../configuration/config-files.md#subagent-模型池) 实验功能并配置模型池后可用——`[secondary_model.models]` 表或仅一行 `default_model`:池中别名,或 `"primary"` 表示调用方自己运行的模型;resume 时无效)。未传入时 subagent 绑定池的 `default_model`;未配置模型池时,subagent 一律继承调用方模型。Agent 任务默认 2 小时超时,可通过 `config.toml` 的 `[subagent] timeout_ms`(`0` = 无超时,或 `KIMI_SUBAGENT_TIMEOUT_MS` 环境变量)配置,且在 print 模式(`kimi -p`)下默认无超时。前台模式下父 Agent 等待 subagent 完成再继续;后台模式立即返回任务 ID,完成时通过合成 User 消息自动回到 main agent。多个前台 `Agent` 调用在同一步运行时,TUI 会合并展示,并为每个 subagent 显示运行、等待、完成或失败状态以及已耗时长。subagent 体系细节见 [Agent 与 subagent](../customization/agents.md)。 +**`Agent`** 将子任务委托给 subagent 执行。必填参数:`prompt`(完整任务描述)和 `description`(3–5 个词的简短说明)。可选参数:`subagent_type`(默认 `coder`)、`resume`(恢复已有 Agent 的 ID,与 `subagent_type` 互斥)、`run_in_background`(默认 false)和 `model`(在配置 [subagent 模型池](../configuration/config-files.md#subagent-模型池) 后可用——`[secondary_model.models]` 表或仅一行 `default_model`:池中别名,或 `"primary"` 表示调用方自己运行的模型;resume 时无效)。未传入时 subagent 绑定池的 `default_model`;未配置模型池时,subagent 一律继承调用方模型。Agent 任务默认 2 小时超时,可通过 `config.toml` 的 `[subagent] timeout_ms`(`0` = 无超时,或 `KIMI_SUBAGENT_TIMEOUT_MS` 环境变量)配置,且在 print 模式(`kimi -p`)下默认无超时。前台模式下父 Agent 等待 subagent 完成再继续;后台模式立即返回任务 ID,完成时通过合成 User 消息自动回到 main agent。多个前台 `Agent` 调用在同一步运行时,TUI 会合并展示,并为每个 subagent 显示运行、等待、完成或失败状态以及已耗时长。subagent 体系细节见 [Agent 与 subagent](../customization/agents.md)。 -**`AgentSwarm`** 可以从共享的 `prompt_template` 和 `items` 数组启动 subagent,也可以通过 `resume_agent_ids` 恢复已有 subagent,或在一次调用中同时使用两者。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个新的 subagent。传入 `subagent_type` 可以指定整个 swarm 中所有新启动的 subagent 使用的 profile;省略时默认使用 `coder`。传入 `model`(仅在启用 [subagent 模型池](../configuration/config-files.md#subagent-模型池) 实验功能并配置模型池后可用——`[secondary_model.models]` 表或仅一行 `default_model`)可以让新启动的 subagent 运行在池中别名指定的模型或调用方自己的模型(`"primary"`)上。未传入时新启动的 subagent 绑定池的 `default_model`;未配置模型池时则继承调用方模型。恢复的 subagent 保持其原有模型。不传 `resume_agent_ids` 时,本工具要求至少 2 个 item;传入 `resume_agent_ids` 时,可以恢复 1 个或多个已有 subagent。本工具最多支持 128 个 subagent,会等待全部 subagent 完成,并返回聚合报告。每个 subagent 默认 2 小时超时,可通过 `config.toml` 的 [`[swarm] timeout_ms`](../configuration/config-files.md#swarm)(`0` = 无超时,或 `KIMI_CODE_SWARM_TIMEOUT_MS` 环境变量)配置,且在 print 模式(`kimi -p`)下默认无超时;超时的 subagent 会被中止,并在聚合报告中标记为失败。在 TUI 中,前台 swarm 会在输入框上方显示实时 `Agent swarm` 进度面板。若一次模型响应调用 `AgentSwarm`,该调用必须是该响应中的唯一工具调用;如需运行多个 swarm,应先调用一个 `AgentSwarm` 并等待结果,再调用下一个,若单个模板可以覆盖这些工作,也可以合并为一个 swarm。在 `manual` 权限模式下,未处于 swarm mode 时调用 `AgentSwarm` 会触发审批,除非已有权限规则允许;swarm mode 已开启时,`AgentSwarm` 本身会自动放行。权限规则只能按工具名 `AgentSwarm` 匹配,不支持 `AgentSwarm(swarm)` 这类参数模式。默认情况下,本工具会逐步提升并发且不设上限(立即启动 5 个 subagent,之后每 700 毫秒再启动 1 个);将 `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` 设为正整数可限制该阶段同时运行的 subagent 数量,不设置则表示不限制。若设置为非正整数的值,本次 AgentSwarm 调用会立即失败。 +**`AgentSwarm`** 可以从共享的 `prompt_template` 和 `items` 数组启动 subagent,也可以通过 `resume_agent_ids` 恢复已有 subagent,或在一次调用中同时使用两者。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个新的 subagent。传入 `subagent_type` 可以指定整个 swarm 中所有新启动的 subagent 使用的 profile;省略时默认使用 `coder`。传入 `model`(在配置 [subagent 模型池](../configuration/config-files.md#subagent-模型池) 后可用——`[secondary_model.models]` 表或仅一行 `default_model`)可以让新启动的 subagent 运行在池中别名指定的模型或调用方自己的模型(`"primary"`)上。未传入时新启动的 subagent 绑定池的 `default_model`;未配置模型池时则继承调用方模型。恢复的 subagent 保持其原有模型。不传 `resume_agent_ids` 时,本工具要求至少 2 个 item;传入 `resume_agent_ids` 时,可以恢复 1 个或多个已有 subagent。本工具最多支持 128 个 subagent,会等待全部 subagent 完成,并返回聚合报告。每个 subagent 默认 2 小时超时,可通过 `config.toml` 的 [`[swarm] timeout_ms`](../configuration/config-files.md#swarm)(`0` = 无超时,或 `KIMI_CODE_SWARM_TIMEOUT_MS` 环境变量)配置,且在 print 模式(`kimi -p`)下默认无超时;超时的 subagent 会被中止,并在聚合报告中标记为失败。在 TUI 中,前台 swarm 会在输入框上方显示实时 `Agent swarm` 进度面板。若一次模型响应调用 `AgentSwarm`,该调用必须是该响应中的唯一工具调用;如需运行多个 swarm,应先调用一个 `AgentSwarm` 并等待结果,再调用下一个,若单个模板可以覆盖这些工作,也可以合并为一个 swarm。在 `manual` 权限模式下,未处于 swarm mode 时调用 `AgentSwarm` 会触发审批,除非已有权限规则允许;swarm mode 已开启时,`AgentSwarm` 本身会自动放行。权限规则只能按工具名 `AgentSwarm` 匹配,不支持 `AgentSwarm(swarm)` 这类参数模式。默认情况下,本工具会逐步提升并发且不设上限(立即启动 5 个 subagent,之后每 700 毫秒再启动 1 个);将 `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` 设为正整数可限制该阶段同时运行的 subagent 数量,不设置则表示不限制。若设置为非正整数的值,本次 AgentSwarm 调用会立即失败。 **`AskUserQuestion`** 以结构化多选题的形式向用户提问,适用于需要消歧或选择方案的场景。`questions` 参数接受 1–4 道题,每道题需提供 `question`(以 `?` 结尾)、`options`(2–4 个选项,每项含 `label` 和 `description`)以及可选的 `header`(最多 12 字符)和 `multi_select`(默认 false)。系统自动附加"其他"选项。`background` 为 true 时启动后台问题任务并立即返回任务 ID。宿主未实现交互式提问能力时返回失败提示,Agent 应改为在文本回复中直接提问。 diff --git a/packages/agent-core-v2/src/features/skill/catalog/builtin/update-config.md b/packages/agent-core-v2/src/features/skill/catalog/builtin/update-config.md index 9fcdddb4b4e..b58eefb99f9 100644 --- a/packages/agent-core-v2/src/features/skill/catalog/builtin/update-config.md +++ b/packages/agent-core-v2/src/features/skill/catalog/builtin/update-config.md @@ -20,7 +20,7 @@ echo "$HOME/.kimi-code" Use the first line when it is non-empty; otherwise use the second line. In the rest of this skill, `` means that resolved root — **never assume `~/.kimi-code`**. -- **`config.toml`** — agent / runtime settings: `default_model`, `[secondary_model]` (experimental `secondary-model` flag: `default_model` / `[secondary_model.models]` subagent model pool / `force` to pin subagents to `default_model`; a lone legacy v1 `model` key is honored as a fallback default), `[subagent]` (`timeout_ms`), `providers`, `models`, `thinking`, `permission`, `hooks`, `loop_control`, etc. +- **`config.toml`** — agent / runtime settings: `default_model`, `[secondary_model]` (subagent model pool: `default_model` / `[secondary_model.models]` / `force` to pin subagents to `default_model`; enabled by default, disable with `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=0`; a lone legacy v1 `model` key is honored as a fallback default), `[subagent]` (`timeout_ms`), `providers`, `models`, `thinking`, `permission`, `hooks`, `loop_control`, etc. - **`tui.toml`** — terminal-UI / client preferences: `theme`, `[editor].command`, `[notifications]`, `[upgrade].auto_install` (auto-update). These can usually also be changed with the interactive commands `/config`, `/theme`, `/editor`, which is easier — prefer pointing the user at those. The "read → copy → Edit → validate → back up → overwrite" flow below applies to both files; only **which reload command applies** differs (see Capability 4). diff --git a/packages/agent-core-v2/src/session/subagent/flag.ts b/packages/agent-core-v2/src/session/subagent/flag.ts index 4be94ff5121..e5a805e1eb6 100644 --- a/packages/agent-core-v2/src/session/subagent/flag.ts +++ b/packages/agent-core-v2/src/session/subagent/flag.ts @@ -9,7 +9,7 @@ export const secondaryModelFlag: FlagDefinitionInput = { description: 'Let newly spawned subagents use a separately configured secondary model by default, with an explicit primary-model override for quality-sensitive tasks.', env: SECONDARY_MODEL_FLAG_ENV, - default: false, + default: true, surface: 'core', }; diff --git a/packages/agent-core/src/flags/registry.ts b/packages/agent-core/src/flags/registry.ts index 55903c6d00b..70b86a38dac 100644 --- a/packages/agent-core/src/flags/registry.ts +++ b/packages/agent-core/src/flags/registry.ts @@ -38,7 +38,7 @@ export const FLAG_DEFINITIONS = [ description: 'Let newly spawned subagents use a separately configured secondary model by default, with an explicit primary-model override for quality-sensitive tasks.', env: 'KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', - default: false, + default: true, surface: 'core', }, ] as const satisfies readonly FlagDefinitionInput[]; diff --git a/packages/agent-core/test/agent/basic.test.ts b/packages/agent-core/test/agent/basic.test.ts index 1df4648ecf7..efea2c658d0 100644 --- a/packages/agent-core/test/agent/basic.test.ts +++ b/packages/agent-core/test/agent/basic.test.ts @@ -15,9 +15,9 @@ it('creates an independent agent with a scoped experimental flag resolver', () = experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS), }); - // No experimental flags are currently registered, so the scoped resolver - // reports none enabled. - expect(ctx.agent.experimentalFlags.enabledIds()).toEqual([]); + // The scoped resolver reflects the registry defaults on its own: + // secondary-model defaults on, tool-select stays off. + expect(ctx.agent.experimentalFlags.enabledIds()).toEqual(['secondary-model']); }); it('runs a text-only agent turn from prompt to completion', async () => { diff --git a/packages/agent-core/test/agent/tool.test.ts b/packages/agent-core/test/agent/tool.test.ts index a0d885d14c5..adffe5b5a1a 100644 --- a/packages/agent-core/test/agent/tool.test.ts +++ b/packages/agent-core/test/agent/tool.test.ts @@ -456,7 +456,10 @@ describe('Agent tools', () => { } as unknown as SessionSubagentHost; const ctx = testAgent({ subagentHost, - experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS), + experimentalFlags: new FlagResolver( + { KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL: '0' }, + FLAG_DEFINITIONS, + ), }); ctx.configure({ tools: ['Agent'] }); diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 52edef997fd..55a84aa70f3 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -1153,13 +1153,20 @@ describe('SessionSubagentHost', () => { text: 'Resumed the subagent from its earlier context and carried the task through to completion, then reported a full and detailed technical summary so the parent agent can continue without repeating prior work.', }); - const session = fakeSession(parent.agent, child.agent, { - 'agent-0': { - homedir: '/tmp/kimi-session/agents/agent-0', - type: 'sub', - parentAgentId: 'main', + const session = fakeSession( + parent.agent, + child.agent, + { + 'agent-0': { + homedir: '/tmp/kimi-session/agents/agent-0', + type: 'sub', + parentAgentId: 'main', + }, }, - }); + { + experimentalFlags: new FlagResolver({ KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL: '0' }), + }, + ); const host = new SessionSubagentHost(session, 'main'); const handle = await host.resume('agent-0', { @@ -1278,6 +1285,7 @@ describe('SessionSubagentHost', () => { it('inherits the parent model when the experiment is off', async () => { const { parent, child } = await spawnChild({ + experimentalFlags: new FlagResolver({ KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL: '0' }), config: { providers: {}, secondaryModel: { model: 'cheap-model' } }, }); expect(child.agent.config.modelAlias).toBe(parent.agent.config.modelAlias); diff --git a/packages/kap-server/test/config.test.ts b/packages/kap-server/test/config.test.ts index 61ba89503a0..04c0a0da59c 100644 --- a/packages/kap-server/test/config.test.ts +++ b/packages/kap-server/test/config.test.ts @@ -174,7 +174,9 @@ describe('server-v2 /api/v1/config', () => { }); it('session create with a broken subagent model pool succeeds while the experiment is off', async () => { - await boot('[secondary_model.models]\n"provider/fast" = "fast and cheap"\n'); + await boot( + '[experimental]\n"secondary-model" = false\n\n[secondary_model.models]\n"provider/fast" = "fast and cheap"\n', + ); const res = await authedFetch(server as RunningServer, base, '/api/v1/sessions', { method: 'POST', headers: { 'content-type': 'application/json' }, diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index 3fae01040de..e7f7fe769d8 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -352,8 +352,8 @@ describe('KimiHarness config API', () => { 'Let newly spawned subagents use a separately configured secondary model by default, with an explicit primary-model override for quality-sensitive tasks.', surface: 'core', env: 'KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', - defaultEnabled: false, - enabled: false, + defaultEnabled: true, + enabled: true, source: 'default', }, ]); From e43225a3adfaec38a9bc19504c8c0893b17d776f Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 28 Aug 2026 17:37:54 +0800 Subject: [PATCH 02/12] feat(secondary-model): graduate the subagent model pool out of experimental --- .changeset/secondary-model-default-on.md | 2 +- apps/kimi-code/src/tui/commands/registry.ts | 1 - .../test/tui/commands/registry.test.ts | 4 +- docs/en/configuration/config-files.md | 2 +- docs/en/configuration/env-vars.md | 1 - docs/en/reference/slash-commands.md | 2 +- docs/zh/configuration/config-files.md | 2 +- docs/zh/configuration/env-vars.md | 1 - docs/zh/reference/slash-commands.md | 2 +- .../src/agent/tools/agent/agentTool.ts | 3 +- .../skill/catalog/builtin/update-config.md | 2 +- .../swarm/tools/agent-swarm/agentSwarmTool.ts | 3 +- .../features/tower/tools/spawn/spawnTool.ts | 3 - .../src/session/subagent/configSection.ts | 17 ++--- .../src/session/subagent/flag.ts | 15 ---- .../subagentModelsValidationService.ts | 4 +- .../src/session/subagent/subagentService.ts | 3 - .../sessionLifecycleService.ts | 4 +- .../workspaceInstanceManagerService.ts | 3 - .../test/app/config/config.test.ts | 65 ++++++----------- .../test/features/swarm/swarm.test.ts | 44 ++++++------ .../features/tower/tools/spawnTool.test.ts | 13 +--- .../test/session/subagent/spawn.test.ts | 27 +------ .../subagent/subagentModelsValidation.test.ts | 9 +-- packages/agent-core-v2/test/tool/tool.test.ts | 52 +++++--------- .../workspaceInstanceManager.test.ts | 4 +- packages/agent-core/src/agent/tool/index.ts | 10 ++- packages/agent-core/src/flags/registry.ts | 9 --- packages/agent-core/src/session/index.ts | 2 +- .../src/session/subagent-binding.ts | 35 ++++----- .../agent-core/src/session/subagent-host.ts | 17 +---- .../builtin/collaboration/agent-swarm.ts | 6 +- .../src/tools/builtin/collaboration/agent.ts | 6 +- packages/agent-core/test/agent/basic.test.ts | 6 +- packages/agent-core/test/agent/tool.test.ts | 11 ++- .../agent-core/test/harness/runtime.test.ts | 1 - packages/agent-core/test/session/init.test.ts | 4 +- .../test/session/subagent-host.test.ts | 72 ++----------------- packages/kap-server/test/config.test.ts | 17 +---- packages/node-sdk/test/config.test.ts | 11 --- 40 files changed, 124 insertions(+), 371 deletions(-) diff --git a/.changeset/secondary-model-default-on.md b/.changeset/secondary-model-default-on.md index bd9a16cb128..7e9c028af31 100644 --- a/.changeset/secondary-model-default-on.md +++ b/.changeset/secondary-model-default-on.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -The subagent model pool (`[secondary_model]`) is now enabled by default: configured subagents bind the pool's `default_model` and the `Agent` / `AgentSwarm` tools gain the `model` parameter. Set `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=0` to restore the inherit-only behavior. +The subagent model pool (`[secondary_model]`) is now a permanent feature rather than an experiment: subagents bind the pool's `default_model`, and the `Agent` / `AgentSwarm` tools expose the `model` parameter when a pool is configured. The `secondary-model` experiment and its `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` env var are removed and now have no effect. diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 67e7cd74d94..0bc558299dd 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -216,7 +216,6 @@ export const BUILTIN_SLASH_COMMANDS = [ description: 'Configure the secondary model for subagents', priority: 90, availability: 'always', - experimentalFlag: 'secondary-model', }, { name: 'effort', diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index 1113a5998e3..4e7ee590136 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -209,10 +209,10 @@ describe('built-in slash command registry', () => { expect(resolveSlashCommandAvailability(reloadTui!, '')).toBe('always'); }); - it('gates secondary-model behind the secondary-model experiment, always available', () => { + it('registers secondary-model unconditionally, always available', () => { const command = findBuiltInSlashCommand('secondary-model'); expect(command).toBeDefined(); - expect((command as KimiSlashCommand).experimentalFlag).toBe('secondary-model'); + expect((command as KimiSlashCommand).experimentalFlag).toBeUndefined(); expect(resolveSlashCommandAvailability(command!, '')).toBe('always'); }); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 77cd8007284..eb85e53fc96 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -196,7 +196,7 @@ Subagents inherit the model the main agent is running by default. The `[secondar ### Subagent model pool -The pool is enabled by default in every launch mode, including the interactive TUI. To disable it, set `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=0` (or `secondary-model = false` under `[experimental]` in `config.toml`); while disabled, the pool keys stay inert: subagents inherit the caller's model and session startup skips the pool validation. +Configured values take effect in every launch mode, including the interactive TUI. The minimal configuration is one line — a lone `default_model` is a pool with a single entry: diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index a45f60ac978..76b642cb21f 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -150,7 +150,6 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_CODE_IDENTITY_SLUG` | Protocol identifier for the `User-Agent` product token sent to third-party providers and the MCP client name; takes higher priority than `[identity] slug`. Derived from the name when unset | Any non-empty string; normalized to lowercase with non-alphanumeric runs folded to `-` | | `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` | Whether the built-in skills documenting Kimi Code itself are offered to the model; takes higher priority than `builtin_product_skills` in `config.toml` (default enabled) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_TUI_FULL_SCREEN` | Enable the experimental fullscreen alternate-screen UI: scrollable transcript viewport, mouse text selection, clickable links, and Ctrl-Shift-F transcript search | `1` enables it; anything else keeps the regular inline UI | -| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | The [subagent model pool](./config-files.md#subagent-model-pool) is enabled by default in every launch mode, including the interactive TUI; set a falsy value to disable it; the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` force-enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK` | Enable the experimental `fork` parameter on the `Agent` and `AgentSwarm` tools, letting the model start a subagent with a snapshot of the calling agent's conversation history instead of an empty context; the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | Global default connection timeout (ms) for all MCP servers; takes higher priority than `[mcp] startup_timeout_ms` in `config.toml`, but a per-server `startupTimeoutMs` in `mcp.json` still wins (default `30000`) | Integer from `1` to `2147483647`; invalid values are ignored | | `KIMI_MCP_TOOL_TIMEOUT_MS` | Global default single tool-call timeout (ms) for all MCP servers; takes higher priority than `[mcp] tool_timeout_ms` in `config.toml`, but a per-server `toolTimeoutMs` in `mcp.json` still wins (default `60000`) | Integer from `1` to `2147483647`; invalid values are ignored | diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index b137b1a0396..860e75ff06f 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 (writes `[secondary_model] default_model`; see the [subagent model pool](../configuration/config-files.md#subagent-model-pool)) | 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 0280bf9664d..3c81c0624e6 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -196,7 +196,7 @@ subagent 默认继承 main agent 正在运行的模型。`[secondary_model]` 节 ### subagent 模型池 -模型池默认启用,在包括交互式 TUI 在内的所有启动方式下生效。如需禁用,设置 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=0`(或在 `config.toml` 的 `[experimental]` 下配置 `secondary-model = false`);禁用期间模型池配置不生效:subagent 继承调用方模型,会话启动也会跳过池校验。 +配置后在包括交互式 TUI 在内的所有启动方式下生效。 最小配置只有一行——单独写下的 `default_model` 就是只含一个条目的模型池: diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 20b5e890da8..4ff69fc3226 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -150,7 +150,6 @@ kimi | `KIMI_CODE_IDENTITY_SLUG` | 协议标识,用于发给第三方 provider 的 `User-Agent` 产品名和 MCP 客户端名,优先级高于 `[identity] slug`。未设置时由名称派生 | 任意非空字符串;会转小写并将连续非字母数字字符折叠为 `-` | | `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` | 是否向模型提供介绍 Kimi Code 自身的内置 Skills,优先级高于 `config.toml` 的 `builtin_product_skills`(默认开启) | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_TUI_FULL_SCREEN` | 启用实验性的 fullscreen alternate-screen 界面:可滚动的 transcript 视口、鼠标选择文本、可点击链接、Ctrl-Shift-F 搜索 | `1` 开启;其他值保持常规内联界面 | -| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | [subagent 模型池](./config-files.md#subagent-模型池)默认启用,在包括交互式 TUI 在内的所有启动方式下生效;设为假值可禁用;master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 会强制启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK` | 在 `Agent` 和 `AgentSwarm` 工具上启用实验性的 `fork` 参数,让模型可以以调用方 Agent 对话历史的快照而不是空上下文启动 subagent;master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | 所有 MCP server 的全局默认连接超时(毫秒);优先级高于 `config.toml` 的 `[mcp] startup_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `startupTimeoutMs`(默认 `30000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | | `KIMI_MCP_TOOL_TIMEOUT_MS` | 所有 MCP server 的全局默认单次工具调用超时(毫秒);优先级高于 `config.toml` 的 `[mcp] tool_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `toolTimeoutMs`(默认 `60000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index a3a99ae25d1..3dd5a3944bd 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 的默认模型(写入 `[secondary_model] default_model`,详见[subagent 模型池](../configuration/config-files.md#subagent-模型池)) | 是 | | `/settings` | `/config` | 打开 TUI 内的设置面板 | 是 | | `/experiments` | `/experimental` | 打开实验功能面板 | 是 | | `/permission` | — | 选择权限模式 | 是 | 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 b68ad9fb712..13522486561 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -84,7 +84,7 @@ export class SubagentTool implements ISubagentTool { readonly name: string = 'Agent'; get parameters(): Record { - const parameters = exposesSubagentModelChoice(this.config, this.flags) + const parameters = exposesSubagentModelChoice(this.config) ? SUBAGENT_TOOL_PARAMETERS : SUBAGENT_TOOL_PARAMETERS_NO_MODEL; return this.flags.enabled(SUBAGENT_FORK_FLAG_ID) @@ -148,7 +148,6 @@ export class SubagentTool implements ISubagentTool { } const modelLines = buildSubagentModelDescriptions( this.config, - this.flags, this.profile.data().modelAlias, ); if (modelLines !== undefined) { diff --git a/packages/agent-core-v2/src/features/skill/catalog/builtin/update-config.md b/packages/agent-core-v2/src/features/skill/catalog/builtin/update-config.md index b58eefb99f9..749c7c438d9 100644 --- a/packages/agent-core-v2/src/features/skill/catalog/builtin/update-config.md +++ b/packages/agent-core-v2/src/features/skill/catalog/builtin/update-config.md @@ -20,7 +20,7 @@ echo "$HOME/.kimi-code" Use the first line when it is non-empty; otherwise use the second line. In the rest of this skill, `` means that resolved root — **never assume `~/.kimi-code`**. -- **`config.toml`** — agent / runtime settings: `default_model`, `[secondary_model]` (subagent model pool: `default_model` / `[secondary_model.models]` / `force` to pin subagents to `default_model`; enabled by default, disable with `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=0`; a lone legacy v1 `model` key is honored as a fallback default), `[subagent]` (`timeout_ms`), `providers`, `models`, `thinking`, `permission`, `hooks`, `loop_control`, etc. +- **`config.toml`** — agent / runtime settings: `default_model`, `[secondary_model]` (subagent model pool: `default_model` / `[secondary_model.models]` / `force` to pin subagents to `default_model`; a lone legacy v1 `model` key is honored as a fallback default), `[subagent]` (`timeout_ms`), `providers`, `models`, `thinking`, `permission`, `hooks`, `loop_control`, etc. - **`tui.toml`** — terminal-UI / client preferences: `theme`, `[editor].command`, `[notifications]`, `[upgrade].auto_install` (auto-update). These can usually also be changed with the interactive commands `/config`, `/theme`, `/editor`, which is easier — prefer pointing the user at those. The "read → copy → Edit → validate → back up → overwrite" flow below applies to both files; only **which reload command applies** differs (see Capability 4). 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..d530dbdf788 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,7 @@ 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) ? AGENT_SWARM_PARAMETERS : AGENT_SWARM_PARAMETERS_NO_MODEL; return this.flags.enabled(SUBAGENT_FORK_FLAG_ID) @@ -102,7 +102,6 @@ export class AgentSwarmTool implements IAgentSwarmTool { } const modelLines = buildSubagentModelDescriptions( this.config, - this.flags, this.profile.data().modelAlias, ); 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 a50eb5c83be..a23aab2d8f4 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 @@ -21,7 +21,6 @@ import { import { IAgentTowerService, TOWER_WORKER_PROFILE } from '#/features/tower/tower'; import { ITowerRateLimitService } from '#/features/tower/towerRateLimit'; import { IConfigService } from '#/app/config/config'; -import { IFlagService } from '#/app/flag/flag'; import { IModelCatalog } from '#/kosong/model/catalog'; import { toInputJsonSchema } from '#/tool/input-schema'; import { @@ -67,7 +66,6 @@ export class TowerSpawnTool implements ITowerSpawnTool { @IAgentTaskService private readonly tasks: IAgentTaskService, @IAgentProfileService private readonly profile: IAgentProfileService, @IConfigService private readonly config: IConfigService, - @IFlagService private readonly flags: IFlagService, @IModelCatalog private readonly modelCatalog: IModelCatalog, ) { this.callerAgentId = scopeContext.agentId; @@ -164,7 +162,6 @@ export class TowerSpawnTool implements ITowerSpawnTool { ? undefined : resolveSubagentBinding( this.config, - this.flags, { modelAlias: own.modelAlias, thinkingLevel: own.thinkingLevel }, args.kind === 'reviewer' ? 'primary' : undefined, ); diff --git a/packages/agent-core-v2/src/session/subagent/configSection.ts b/packages/agent-core-v2/src/session/subagent/configSection.ts index 9e27bde6c06..739d37b6466 100644 --- a/packages/agent-core-v2/src/session/subagent/configSection.ts +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -2,7 +2,6 @@ import { z } from 'zod'; import { Error2, ErrorCodes, isError2 } from '#/errors'; import { isPlainObject } from '#/app/config/toml'; -import type { IFlagService } from '#/app/flag/flag'; import { type EnvBindings, envBindings, @@ -17,8 +16,6 @@ import { type ThinkingConfig, } from '#/kosong/model/thinking'; -import { SECONDARY_MODEL_FLAG_ID } from './flag'; - export const SUBAGENT_SECTION = 'subagent'; export const SECONDARY_MODEL_SECTION = 'secondaryModel'; @@ -111,8 +108,7 @@ export function isSubagentModelForced(config: IConfigService): boolean { return config.get(SECONDARY_MODEL_SECTION)?.force === true; } -export function exposesSubagentModelChoice(config: IConfigService, flags: IFlagService): boolean { - if (!flags.enabled(SECONDARY_MODEL_FLAG_ID)) return false; +export function exposesSubagentModelChoice(config: IConfigService): boolean { if (isSubagentModelForced(config)) return false; return resolveSubagentModelPool(config) !== undefined; } @@ -163,10 +159,8 @@ export function assertValidSubagentModelPool( export function assertValidSubagentModelConfig( config: IConfigService, - flags: IFlagService, modelCatalog: IModelCatalog, ): void { - if (!flags.enabled(SECONDARY_MODEL_FLAG_ID)) return; const section = config.get(SECONDARY_MODEL_SECTION); if (section?.force === true) { if (section.models !== undefined) { @@ -186,13 +180,11 @@ export function assertValidSubagentModelConfig( export function resolveSubagentBinding( config: IConfigService, - flags: IFlagService, own: { modelAlias: string; thinkingLevel: string }, requested?: string, ): { model: string; thinking?: string } { - const enabled = flags.enabled(SECONDARY_MODEL_FLAG_ID); const section = config.get(SECONDARY_MODEL_SECTION); - if (enabled && section?.force === true) { + if (section?.force === true) { if (section.models !== undefined) { throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_FORCE_EXCLUDES_MODELS_MESSAGE, { details: { section: SECONDARY_MODEL_SECTION, field: 'force' }, @@ -216,7 +208,7 @@ export function resolveSubagentBinding( if (requested === PRIMARY_SUBAGENT_MODEL_CHOICE) { return { model: own.modelAlias, thinking: own.thinkingLevel }; } - const pool = enabled ? resolveSubagentModelPool(config) : undefined; + const pool = resolveSubagentModelPool(config); if (pool === undefined) { if (requested !== undefined) { throw new Error2( @@ -265,10 +257,9 @@ export function resolveSubagentThinking( export function buildSubagentModelDescriptions( config: IConfigService, - flags: IFlagService, callerModelAlias: string | undefined, ): string | undefined { - if (!exposesSubagentModelChoice(config, flags)) return undefined; + if (!exposesSubagentModelChoice(config)) return undefined; const pool = resolveSubagentModelPool(config)!; const lines = ['Available models (pass via model):']; const defaultModel = pool.defaultModel; diff --git a/packages/agent-core-v2/src/session/subagent/flag.ts b/packages/agent-core-v2/src/session/subagent/flag.ts index e5a805e1eb6..1bcbc8f741b 100644 --- a/packages/agent-core-v2/src/session/subagent/flag.ts +++ b/packages/agent-core-v2/src/session/subagent/flag.ts @@ -1,20 +1,5 @@ import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; -export const SECONDARY_MODEL_FLAG_ID = 'secondary-model'; -export const SECONDARY_MODEL_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL'; - -export const secondaryModelFlag: FlagDefinitionInput = { - id: SECONDARY_MODEL_FLAG_ID, - title: 'Secondary model for subagents', - description: - 'Let newly spawned subagents use a separately configured secondary model by default, with an explicit primary-model override for quality-sensitive tasks.', - env: SECONDARY_MODEL_FLAG_ENV, - default: true, - surface: 'core', -}; - -registerFlagDefinition(secondaryModelFlag); - export const SUBAGENT_FORK_FLAG_ID = 'subagent_fork'; export const SUBAGENT_FORK_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK'; diff --git a/packages/agent-core-v2/src/session/subagent/subagentModelsValidationService.ts b/packages/agent-core-v2/src/session/subagent/subagentModelsValidationService.ts index cd19ad03951..a23cf0b97dc 100644 --- a/packages/agent-core-v2/src/session/subagent/subagentModelsValidationService.ts +++ b/packages/agent-core-v2/src/session/subagent/subagentModelsValidationService.ts @@ -1,7 +1,6 @@ import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IConfigService } from '#/app/config/config'; -import { IFlagService } from '#/app/flag/flag'; import { IModelCatalog } from '#/kosong/model/catalog'; import { assertValidSubagentModelConfig } from './configSection'; @@ -14,10 +13,9 @@ export class SessionSubagentModelsValidationService constructor( @IConfigService config: IConfigService, - @IFlagService flags: IFlagService, @IModelCatalog modelCatalog: IModelCatalog, ) { - assertValidSubagentModelConfig(config, flags, modelCatalog); + assertValidSubagentModelConfig(config, modelCatalog); } } diff --git a/packages/agent-core-v2/src/session/subagent/subagentService.ts b/packages/agent-core-v2/src/session/subagent/subagentService.ts index f97b6c71866..881fd441425 100644 --- a/packages/agent-core-v2/src/session/subagent/subagentService.ts +++ b/packages/agent-core-v2/src/session/subagent/subagentService.ts @@ -23,7 +23,6 @@ import { IAgentUserToolService } from '#/agent/userTool/userTool'; import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; import type { Runtime } from '#/runtime/runtime'; import { IConfigService } from '#/app/config/config'; -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'; @@ -71,7 +70,6 @@ export class SessionSubagentService extends Service implements ISessionSubagentS @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, @ISessionAgentProfileCatalog private readonly catalog: ISessionAgentProfileCatalog, @IConfigService private readonly configService: IConfigService, - @IFlagService private readonly flags: IFlagService, @IModelCatalog private readonly modelCatalog: IModelCatalog, @ISessionContext private readonly sessionContext: ISessionContext, @ILogService private readonly log: ILogService, @@ -133,7 +131,6 @@ export class SessionSubagentService extends Service implements ISessionSubagentS ? { model: own.modelAlias, thinking: own.thinkingLevel } : resolveSubagentBinding( this.configService, - this.flags, { modelAlias: own.modelAlias, thinkingLevel: own.thinkingLevel }, input.model, ); diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index a6b2ac436e3..24a90f2df48 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -60,7 +60,6 @@ import { repairWireJournal } from '#/wire/repair'; import { IModelCatalog } from '#/kosong/model/catalog'; import { IModelService } from '#/kosong/model/model'; import { IProviderService } from '#/kosong/provider/provider'; -import { IFlagService } from '#/app/flag/flag'; import { assertValidSubagentModelConfig } from '#/session/subagent/configSection'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; @@ -173,7 +172,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec @IModelCatalog private readonly modelCatalog: IModelCatalog, @IModelService private readonly models: IModelService, @IProviderService private readonly providers: IProviderService, - @IFlagService private readonly flags: IFlagService, onDispose?: () => void, ) { super(); @@ -226,7 +224,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private async assertSubagentModelPoolPreFlight(): Promise { await Promise.all([this.config.ready, this.models.ready, this.providers.ready]); - assertValidSubagentModelConfig(this.config, this.flags, this.modelCatalog); + assertValidSubagentModelConfig(this.config, this.modelCatalog); } private async materializeSession(opts: MaterializeSessionOptions): Promise { diff --git a/packages/agent-core-v2/src/workspace/workspaceInstance/workspaceInstanceManagerService.ts b/packages/agent-core-v2/src/workspace/workspaceInstance/workspaceInstanceManagerService.ts index 8c4466fa05d..ec4c3a13562 100644 --- a/packages/agent-core-v2/src/workspace/workspaceInstance/workspaceInstanceManagerService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceInstance/workspaceInstanceManagerService.ts @@ -8,7 +8,6 @@ import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileReg import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { IEventService } from '#/app/event/event'; -import { IFlagService } from '#/app/flag/flag'; import { IGitService } from '#/app/git/git'; import { IMcpOAuthService } from '#/app/mcpConfig/oauthService'; import type { McpOAuthService } from '#/mcpCore/oauth/service'; @@ -58,7 +57,6 @@ export class WorkspaceInstanceManager implements IWorkspaceInstanceManager { @IAppStateService private readonly appState: IAppStateService, @IConfigService private readonly config: IConfigService, @IEventService private readonly event: IEventService, - @IFlagService private readonly flags: IFlagService, @ref(IGitService) private readonly git: LiveRef, @IAgentIdentity private readonly identity: IAgentIdentity, @ISessionIndex private readonly index: ISessionIndex, @@ -244,7 +242,6 @@ export class WorkspaceInstanceManager implements IWorkspaceInstanceManager { this.modelCatalog, this.models, this.modelProviders, - this.flags, input.onDispose, ), }, diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index 343b93a1f8e..cf560dd236e 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -93,7 +93,6 @@ import { type SubagentConfig, wrapSubagentModelError, } from '#/session/subagent/configSection'; -import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; import { DEFAULT_SWARM_TIMEOUT_MS, resolveSwarmTimeoutMs, @@ -126,10 +125,6 @@ import { stubBootstrap } from '../bootstrap/stubs'; import { stubLog } from '../../_base/log/stubs'; import { stubFlag } from '../flag/stubs'; -function secondaryModelFlags(enabled = true) { - return stubFlag((id) => enabled && id === SECONDARY_MODEL_FLAG_ID); -} - const TEST_OS_ENV = { osKind: 'Linux', osArch: 'x86_64', @@ -1871,11 +1866,11 @@ describe('subagent config section', () => { const own = { modelAlias: 'provider/main', thinkingLevel: 'medium' }; const noPool = await createConfig({}); - expect(resolveSubagentBinding(noPool.config, secondaryModelFlags(), own)).toEqual({ + expect(resolveSubagentBinding(noPool.config, own)).toEqual({ model: 'provider/main', thinking: 'medium', }); - expect(resolveSubagentBinding(noPool.config, secondaryModelFlags(), own, 'primary')).toEqual({ + expect(resolveSubagentBinding(noPool.config, own, 'primary')).toEqual({ model: 'provider/main', thinking: 'medium', }); @@ -1885,39 +1880,21 @@ describe('subagent config section', () => { {}, '[secondary_model]\ndefault_model = "provider/fast"\n\n[secondary_model.models]\n"provider/fast" = "fast and cheap"\n"provider/smart" = "hard tasks"\n', ); - expect(resolveSubagentBinding(pool.config, secondaryModelFlags(), own)).toEqual({ + expect(resolveSubagentBinding(pool.config, own)).toEqual({ model: 'provider/fast', thinking: undefined, }); - expect(resolveSubagentBinding(pool.config, secondaryModelFlags(), own, 'provider/smart')).toEqual({ + expect(resolveSubagentBinding(pool.config, own, 'provider/smart')).toEqual({ model: 'provider/smart', thinking: undefined, }); - expect(resolveSubagentBinding(pool.config, secondaryModelFlags(), own, 'primary')).toEqual({ + expect(resolveSubagentBinding(pool.config, own, 'primary')).toEqual({ model: 'provider/main', thinking: 'medium', }); pool.disposables.dispose(); }); - it('keeps the pool inert while the secondary-model experiment is off', async () => { - const own = { modelAlias: 'provider/main', thinkingLevel: 'medium' }; - const { config, disposables } = await createConfig( - {}, - '[secondary_model]\ndefault_model = "provider/fast"\nforce = true\n\n[secondary_model.models]\n"provider/fast" = "fast and cheap"\n', - ); - - expect(resolveSubagentBinding(config, secondaryModelFlags(false), own)).toEqual({ - model: 'provider/main', - thinking: 'medium', - }); - expect(() => - resolveSubagentBinding(config, secondaryModelFlags(false), own, 'provider/fast'), - ).toThrow(/no \[secondary_model\.models\] pool is configured/); - - disposables.dispose(); - }); - it('treats a pool-less default_model as an implicit single-entry pool', async () => { const own = { modelAlias: 'provider/main', thinkingLevel: 'medium' }; const { config, disposables } = await createConfig( @@ -1925,15 +1902,15 @@ describe('subagent config section', () => { '[secondary_model]\ndefault_model = "provider/fast"\n', ); - expect(resolveSubagentBinding(config, secondaryModelFlags(), own)).toEqual({ + expect(resolveSubagentBinding(config, own)).toEqual({ model: 'provider/fast', thinking: undefined, }); - expect(resolveSubagentBinding(config, secondaryModelFlags(), own, 'primary')).toEqual({ + expect(resolveSubagentBinding(config, own, 'primary')).toEqual({ model: 'provider/main', thinking: 'medium', }); - expect(() => resolveSubagentBinding(config, secondaryModelFlags(), own, 'provider/smart')).toThrow( + expect(() => resolveSubagentBinding(config, own, 'provider/smart')).toThrow( /Invalid model "provider\/smart"\. Available models: provider\/fast, primary\./, ); @@ -1955,11 +1932,11 @@ describe('subagent config section', () => { defaultModel: 'provider/fast', models: { 'provider/fast': '' }, }); - expect(resolveSubagentBinding(config, secondaryModelFlags(), own)).toEqual({ + expect(resolveSubagentBinding(config, own)).toEqual({ model: 'provider/fast', thinking: 'low', }); - expect(() => resolveSubagentBinding(config, secondaryModelFlags(), own, 'provider/smart')).toThrow( + expect(() => resolveSubagentBinding(config, own, 'provider/smart')).toThrow( /Invalid model "provider\/smart"\. Available models: provider\/fast, primary\./, ); @@ -1973,7 +1950,7 @@ describe('subagent config section', () => { '[secondary_model]\nmodel = "provider/slow"\ndefault_model = "provider/fast"\n', ); - expect(resolveSubagentBinding(config, secondaryModelFlags(), own)).toEqual({ + expect(resolveSubagentBinding(config, own)).toEqual({ model: 'provider/fast', thinking: undefined, }); @@ -1988,7 +1965,7 @@ describe('subagent config section', () => { '[secondary_model]\nmodel = "provider/fast"\n\n[secondary_model.models]\n"provider/fast" = "fast and cheap"\n', ); - expect(() => resolveSubagentBinding(config, secondaryModelFlags(), own)).toThrow( + expect(() => resolveSubagentBinding(config, own)).toThrow( '[secondary_model].default_model is required when [secondary_model.models] is configured', ); @@ -2002,11 +1979,11 @@ describe('subagent config section', () => { '[secondary_model]\nmodel = "provider/fast"\nforce = true\n', ); - expect(resolveSubagentBinding(config, secondaryModelFlags(), own)).toEqual({ + expect(resolveSubagentBinding(config, own)).toEqual({ model: 'provider/fast', thinking: undefined, }); - expect(() => resolveSubagentBinding(config, secondaryModelFlags(), own, 'primary')).toThrow( + expect(() => resolveSubagentBinding(config, own, 'primary')).toThrow( /Invalid model "primary": \[secondary_model\]\.force is set/, ); @@ -2044,11 +2021,11 @@ describe('subagent config section', () => { '[secondary_model]\ndefault_model = "provider/fast"\ndefault_effort = "max"\n', ); - expect(resolveSubagentBinding(config, secondaryModelFlags(), own)).toEqual({ + expect(resolveSubagentBinding(config, own)).toEqual({ model: 'provider/fast', thinking: 'max', }); - expect(resolveSubagentBinding(config, secondaryModelFlags(), own, 'primary')).toEqual({ + expect(resolveSubagentBinding(config, own, 'primary')).toEqual({ model: 'provider/main', thinking: 'medium', }); @@ -2067,11 +2044,11 @@ describe('subagent config section', () => { defaultModel: 'provider/fast', force: true, }); - expect(resolveSubagentBinding(config, secondaryModelFlags(), own)).toEqual({ + expect(resolveSubagentBinding(config, own)).toEqual({ model: 'provider/fast', thinking: undefined, }); - expect(() => resolveSubagentBinding(config, secondaryModelFlags(), own, 'primary')).toThrow( + expect(() => resolveSubagentBinding(config, own, 'primary')).toThrow( /Invalid model "primary": \[secondary_model\]\.force is set/, ); @@ -2085,7 +2062,7 @@ describe('subagent config section', () => { '[secondary_model]\ndefault_model = "provider/fast"\nforce = true\n\n[secondary_model.models]\n"provider/fast" = "fast and cheap"\n', ); - expect(() => resolveSubagentBinding(config, secondaryModelFlags(), own)).toThrow( + expect(() => resolveSubagentBinding(config, own)).toThrow( /\[secondary_model\]\.force cannot be combined with \[secondary_model\.models\]/, ); @@ -2101,7 +2078,7 @@ describe('subagent config section', () => { let caught: unknown; try { - resolveSubagentBinding(config, secondaryModelFlags(), own, 'provider/typo'); + resolveSubagentBinding(config, own, 'provider/typo'); } catch (error) { caught = error; } @@ -2118,7 +2095,7 @@ describe('subagent config section', () => { const own = { modelAlias: 'provider/main', thinkingLevel: 'medium' }; const { config, disposables } = await createConfig({}); - expect(() => resolveSubagentBinding(config, secondaryModelFlags(), own, 'provider/fast')).toThrow( + expect(() => resolveSubagentBinding(config, own, 'provider/fast')).toThrow( /Invalid model "provider\/fast": no \[secondary_model\.models\] pool is configured/, ); 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 8386c91f0de..17ef064444e 100644 --- a/packages/agent-core-v2/test/features/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/features/swarm/swarm.test.ts @@ -13,7 +13,6 @@ import { IModelCatalog, type Model } from '#/kosong/model/catalog'; import { stubLog } from '../../_base/log/stubs'; import { stubFlag } from '../../app/flag/stubs'; import { stubAgentContext } from '../../agent/agentContext/stubs'; -import type { IFlagService } from '#/app/flag/flag'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextInjectionProvider, ContextInjectionResult } from '#/features/reminder/types'; import { createReminderStub, lifecycleWithReminder } from '../reminder/stubs'; @@ -208,7 +207,6 @@ const SWARM_MODEL_ALIASES: ReadonlySet = new Set([ function realSubagents( catalog: ISessionAgentProfileCatalog, config: IConfigService, - flags: IFlagService, callerProfile: IAgentProfileService, ): ISessionSubagentService { const caller = { @@ -268,7 +266,6 @@ function realSubagents( agentLifecycle, catalog, config, - flags, modelCatalog, sessionContext, stubLog(), @@ -705,7 +702,7 @@ describe('AgentSwarmTool', () => { ]), }); const swarmMode = mockSwarmMode(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), swarmMode, stubConfig({ defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap' } }), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig({ defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap' } }), stubFlag(true), stubCallerProfile()), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), swarmMode, stubConfig({ defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap' } }), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig({ defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap' } }), stubCallerProfile()), stubCallerProfile()); const input = { description: 'Review files', prompt_template: 'Review {{item}}', @@ -804,7 +801,7 @@ describe('AgentSwarmTool', () => { it('does not expose permission rule argument matching', () => { const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), stubFlag(true), stubCallerProfile()), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), stubCallerProfile()), stubCallerProfile()); const execution = tool.resolveExecution({ description: 'Review files', prompt_template: 'Review {{item}}', @@ -819,7 +816,7 @@ describe('AgentSwarmTool', () => { it('description documents the {{item}} placeholder', () => { const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), stubFlag(true), stubCallerProfile()), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), stubCallerProfile()), stubCallerProfile()); expect(tool.description).toContain('{{item}}'); }); @@ -840,7 +837,6 @@ describe('AgentSwarmTool', () => { realSubagents( stubSwarmCatalog(caller), stubConfig(), - stubFlag(true), stubCallerProfile({ profileName: 'deleted-profile', subagents: ['explore'] }), ), stubCallerProfile({ profileName: 'deleted-profile', subagents: ['explore'] }), @@ -907,7 +903,7 @@ describe('AgentSwarmTool', () => { for (const testCase of cases) { const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), stubFlag(true), stubCallerProfile()), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), stubCallerProfile()), stubCallerProfile()); const result = await executeTool(tool, context(testCase.input)); @@ -940,7 +936,7 @@ describe('AgentSwarmTool', () => { async ({ agentId }: { readonly agentId: string }) => persistedItems[agentId], ); const host = mockSwarmHost({ run, getSwarmItem }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), stubFlag(true), stubCallerProfile()), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), stubCallerProfile()), stubCallerProfile()); const input = { description: 'Finish review', subagent_type: 'explore', @@ -1061,7 +1057,7 @@ describe('AgentSwarmTool', () => { ); const getSwarmItem = vi.fn(async () => 'src/old-a.ts'); const host = mockSwarmHost({ run, getSwarmItem }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), stubFlag(true), stubCallerProfile()), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), stubCallerProfile()), stubCallerProfile()); const input = { description: 'Resume review', resume_agent_ids: { @@ -1124,7 +1120,7 @@ describe('AgentSwarmTool', () => { }, ]), }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), stubFlag(true), stubCallerProfile()), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), stubCallerProfile()), stubCallerProfile()); const result = await executeTool( tool, @@ -1150,7 +1146,7 @@ describe('AgentSwarmTool', () => { it('passes the configured swarm timeout to swarm tasks', async () => { const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ timeoutMs: 5_000 }), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig({ timeoutMs: 5_000 }), stubFlag(true), stubCallerProfile()), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ timeoutMs: 5_000 }), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig({ timeoutMs: 5_000 }), stubCallerProfile()), stubCallerProfile()); await executeTool( tool, @@ -1178,7 +1174,7 @@ describe('AgentSwarmTool', () => { get: (section: string) => section === SWARM_SECTION ? { timeoutMs: 5_000 } : { timeoutMs: 1_000 }, } as unknown as IConfigService; - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), sectionAwareConfig, stubFlag(true), realSubagents(stubSwarmCatalog(), sectionAwareConfig, stubFlag(true), stubCallerProfile()), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), sectionAwareConfig, stubFlag(true), realSubagents(stubSwarmCatalog(), sectionAwareConfig, stubCallerProfile()), stubCallerProfile()); await executeTool( tool, @@ -1201,7 +1197,7 @@ describe('AgentSwarmTool', () => { it('resolves spawn task plans from the configured model pool default', async () => { const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap', 'provider/smart': 'hard tasks' } }), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig({ defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap', 'provider/smart': 'hard tasks' } }), stubFlag(true), stubCallerProfile({ modelAlias: 'main-model', thinkingLevel: 'high' })), stubCallerProfile({ modelAlias: 'main-model', thinkingLevel: 'high' })); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap', 'provider/smart': 'hard tasks' } }), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig({ defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap', 'provider/smart': 'hard tasks' } }), stubCallerProfile({ modelAlias: 'main-model', thinkingLevel: 'high' })), stubCallerProfile({ modelAlias: 'main-model', thinkingLevel: 'high' })); await executeTool( tool, @@ -1228,7 +1224,7 @@ describe('AgentSwarmTool', () => { it('lets the tool call opt back into the primary model', async () => { const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap' } }), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig({ defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap' } }), stubFlag(true), stubCallerProfile({ modelAlias: 'main-model', thinkingLevel: 'high' })), stubCallerProfile({ modelAlias: 'main-model', thinkingLevel: 'high' })); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap' } }), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig({ defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap' } }), stubCallerProfile({ modelAlias: 'main-model', thinkingLevel: 'high' })), stubCallerProfile({ modelAlias: 'main-model', thinkingLevel: 'high' })); await executeTool( tool, @@ -1256,14 +1252,14 @@ describe('AgentSwarmTool', () => { it('advertises the configured pool in the description only when configured', async () => { const host = mockSwarmHost(); - const configured = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap', 'main-model': 'the main model' } }), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig({ defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap', 'main-model': 'the main model' } }), stubFlag(true), stubCallerProfile({ modelAlias: 'main-model' })), stubCallerProfile({ modelAlias: 'main-model' })); + const configured = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap', 'main-model': 'the main model' } }), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig({ defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap', 'main-model': 'the main model' } }), stubCallerProfile({ modelAlias: 'main-model' })), stubCallerProfile({ modelAlias: 'main-model' })); expect(configured.description).toContain('Available models'); expect(configured.description).toContain('- provider/fast [default]: fast and cheap'); expect(configured.description).toContain('- main-model [main model]: the main model'); expect(configured.description).toContain('- primary (main-model)'); - const unconfigured = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), stubFlag(true), stubCallerProfile({ modelAlias: 'main-model' })), stubCallerProfile({ modelAlias: 'main-model' })); + const unconfigured = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), stubCallerProfile({ modelAlias: 'main-model' })), stubCallerProfile({ modelAlias: 'main-model' })); expect(unconfigured.description).not.toContain('Available models'); }); @@ -1283,7 +1279,7 @@ describe('AgentSwarmTool', () => { }, ]), }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), stubFlag(true), stubCallerProfile()), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), stubCallerProfile()), stubCallerProfile()); const result = await executeTool( tool, @@ -1330,7 +1326,7 @@ describe('AgentSwarmTool', () => { }, ]), }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), stubFlag(true), stubCallerProfile()), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), stubCallerProfile()), stubCallerProfile()); const result = await executeTool( tool, @@ -1357,7 +1353,7 @@ describe('AgentSwarmTool', () => { it('rejects fork combined with resume_agent_ids', async () => { const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), stubFlag(true), stubCallerProfile()), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), stubCallerProfile()), stubCallerProfile()); const result = await executeTool( tool, @@ -1375,7 +1371,7 @@ describe('AgentSwarmTool', () => { it('rejects fork with a different subagent type', async () => { const host = mockSwarmHost(); const callerProfile = stubCallerProfile({ profileName: 'orchestrator' }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), stubFlag(true), callerProfile), callerProfile); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), callerProfile), callerProfile); const result = await executeTool( tool, @@ -1395,7 +1391,7 @@ describe('AgentSwarmTool', () => { it('rejects fork with a model override', async () => { const host = mockSwarmHost(); const callerProfile = stubCallerProfile({ profileName: 'orchestrator', modelAlias: 'main-model' }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), stubFlag(true), callerProfile), callerProfile); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), callerProfile), callerProfile); const result = await executeTool( tool, @@ -1414,7 +1410,7 @@ describe('AgentSwarmTool', () => { it('rejects fork while the subagent_fork experimental flag is off', async () => { const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(false), realSubagents(stubSwarmCatalog(), stubConfig(), stubFlag(false), stubCallerProfile()), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(false), realSubagents(stubSwarmCatalog(), stubConfig(), stubCallerProfile()), stubCallerProfile()); const result = await executeTool( tool, @@ -1437,7 +1433,7 @@ describe('AgentSwarmTool', () => { modelAlias: 'main-model', thinkingLevel: 'high', }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), stubFlag(true), callerProfile), callerProfile); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), realSubagents(stubSwarmCatalog(), stubConfig(), callerProfile), callerProfile); const result = await executeTool( tool, 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 af01729be2b..9e64124089b 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 @@ -33,7 +33,6 @@ import { DEFAULT_SUBAGENT_TIMEOUT_MS, SECONDARY_MODEL_SECTION, } from '#/session/subagent/configSection'; -import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; import { ISessionSubagentService, type AgentRunHandle, @@ -75,7 +74,6 @@ describe('TowerSpawnTool', () => { let runAgent: Mock; let registerTask: Mock; let completion: Deferred<{ readonly summary: string }>; - let secondaryFlagOn: boolean; let secondaryModel: { readonly model: string; readonly defaultEffort?: string } | undefined; let thinkingEnabled: boolean | undefined; let modelMeta: Record>; @@ -101,7 +99,6 @@ describe('TowerSpawnTool', () => { gate = { ok: true }; release = vi.fn(); completion = deferred(); - secondaryFlagOn = false; secondaryModel = undefined; thinkingEnabled = undefined; modelMeta = {}; @@ -182,9 +179,6 @@ describe('TowerSpawnTool', () => { ? { enabled: thinkingEnabled } : undefined) as IConfigService['get'], }); - ix.stub(IFlagService, { - enabled: (id: string) => id === SECONDARY_MODEL_FLAG_ID && secondaryFlagOn, - } as unknown as IFlagService); ix.stub(IModelCatalog, { get: (alias: string) => ({ id: alias, ...modelMeta[alias] }) as Model, } as unknown as IModelCatalog); @@ -317,7 +311,6 @@ describe('TowerSpawnTool', () => { }); it('binds the configured secondary model and reports it in the output and activity log', async () => { - secondaryFlagOn = true; secondaryModel = { model: 'cheap/fast' }; const result = await execute(WORKER_ARGS); @@ -333,7 +326,6 @@ describe('TowerSpawnTool', () => { }); it('passes [secondary_model].default_effort to the spawned worker', async () => { - secondaryFlagOn = true; secondaryModel = { model: 'cheap/fast', defaultEffort: 'low' }; const result = await execute(WORKER_ARGS); @@ -346,7 +338,6 @@ describe('TowerSpawnTool', () => { }); it('falls back to the bound model default_effort when the section declares none', async () => { - secondaryFlagOn = true; secondaryModel = { model: 'cheap/fast' }; modelMeta['cheap/fast'] = { capabilities: { ...UNKNOWN_CAPABILITY, thinking: true }, @@ -364,7 +355,6 @@ describe('TowerSpawnTool', () => { }); it('leaves thinking unset for global resolution when thinking is disabled', async () => { - secondaryFlagOn = true; secondaryModel = { model: 'cheap/fast' }; thinkingEnabled = false; modelMeta['cheap/fast'] = { @@ -382,7 +372,7 @@ describe('TowerSpawnTool', () => { }); }); - it('inherits the tower model when the secondary-model experiment is off', async () => { + it('inherits the tower model when no secondary model is configured', async () => { const result = await execute(WORKER_ARGS); expect(result.isError).toBeUndefined(); @@ -392,7 +382,6 @@ describe('TowerSpawnTool', () => { }); it('binds reviewers to the tower model even when the secondary model is configured', async () => { - secondaryFlagOn = true; secondaryModel = { model: 'cheap/fast' }; const result = await execute({ 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 0ba013a3acf..7a2b8768d4c 100644 --- a/packages/agent-core-v2/test/session/subagent/spawn.test.ts +++ b/packages/agent-core-v2/test/session/subagent/spawn.test.ts @@ -27,7 +27,6 @@ import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { SECONDARY_MODEL_SECTION } from '#/session/subagent/configSection'; -import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; import { ISessionSubagentService } from '#/session/subagent/subagent'; import { SessionSubagentService } from '#/session/subagent/subagentService'; import { @@ -216,15 +215,8 @@ describe('SessionSubagentService planSpawn and spawn', () => { disposables.dispose(); }); - function service( - configValues: Record = {}, - secondaryModelEnabled = false, - ): ISessionSubagentService { + function service(configValues: Record = {}): ISessionSubagentService { ix.stub(IConfigService, new StubConfigService(configValues)); - ix.stub( - IFlagService, - stubFlag((id) => secondaryModelEnabled && id === SECONDARY_MODEL_FLAG_ID), - ); ix.set(ISessionSubagentService, new SyncDescriptor(SessionSubagentService)); return ix.get(ISessionSubagentService); } @@ -312,7 +304,6 @@ describe('SessionSubagentService planSpawn and spawn', () => { models: { 'provider/bad': 'broken' }, }, }, - true, ); const error = await planSpawnError(svc, { callerAgentId: CALLER_ID, profileName: 'coder' }); @@ -333,7 +324,6 @@ describe('SessionSubagentService planSpawn and spawn', () => { }, thinking: { enabled: false }, }, - true, ); const plan = await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' }); @@ -361,7 +351,6 @@ describe('SessionSubagentService planSpawn and spawn', () => { defaultEffort: 'max', }, }, - true, ); const plan = await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' }); @@ -383,7 +372,6 @@ describe('SessionSubagentService planSpawn and spawn', () => { models: { 'provider/fast': 'fast model' }, }, }, - true, ); const plan = await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' }); @@ -406,7 +394,6 @@ describe('SessionSubagentService planSpawn and spawn', () => { }, thinking: { enabled: false }, }, - true, ); const plan = await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' }); @@ -428,7 +415,6 @@ describe('SessionSubagentService planSpawn and spawn', () => { models: { 'provider/fast': 'fast model' }, }, }, - true, ); const plan = await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' }); @@ -446,7 +432,6 @@ describe('SessionSubagentService planSpawn and spawn', () => { defaultEffort: 'max', }, }, - true, ); const plan = await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' }); @@ -459,14 +444,8 @@ describe('SessionSubagentService planSpawn and spawn', () => { }); }); - it('inherits the caller model and thinking when the secondary-model experiment is off', async () => { - const svc = service({ - [SECONDARY_MODEL_SECTION]: { - defaultModel: 'provider/fast', - models: { 'provider/fast': 'fast model' }, - defaultEffort: 'max', - }, - }); + it('inherits the caller model and thinking when no pool is configured', async () => { + const svc = service({}); const plan = await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' }); diff --git a/packages/agent-core-v2/test/session/subagent/subagentModelsValidation.test.ts b/packages/agent-core-v2/test/session/subagent/subagentModelsValidation.test.ts index b31a61a90af..2e1073ea0e1 100644 --- a/packages/agent-core-v2/test/session/subagent/subagentModelsValidation.test.ts +++ b/packages/agent-core-v2/test/session/subagent/subagentModelsValidation.test.ts @@ -11,7 +11,6 @@ import { SECONDARY_MODEL_SECTION, SUBAGENT_SECTION, } from '#/session/subagent/configSection'; -import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; import { ISessionSubagentModelsValidationService } from '#/session/subagent/subagentModelsValidation'; import { SessionSubagentModelsValidationService } from '#/session/subagent/subagentModelsValidationService'; @@ -32,9 +31,8 @@ describe('SessionSubagentModelsValidationService', () => { disposables.dispose(); }); - function setup(configValues: Record, flagEnabled = true): void { + function setup(configValues: Record): void { ix.stub(IConfigService, new StubConfigService(configValues)); - ix.stub(IFlagService, stubFlag((id) => flagEnabled && id === SECONDARY_MODEL_FLAG_ID)); ix.stub(IModelCatalog, { _serviceBrand: undefined, get: (id: string) => { @@ -73,11 +71,6 @@ describe('SessionSubagentModelsValidationService', () => { expect(resolve()).toBeUndefined(); }); - it('is a no-op for a broken pool while the secondary-model experiment is off', () => { - setup({ [SECONDARY_MODEL_SECTION]: { defaultModel: 'provider/typo' } }, false); - expect(resolve()).toBeUndefined(); - }); - it('constructs fine when default_model alone forms an implicit single-entry pool', () => { modelIds.add('provider/fast'); setup({ [SECONDARY_MODEL_SECTION]: { defaultModel: 'provider/fast' } }); diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 64866292293..2b832df77b3 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -43,7 +43,7 @@ import { FORK_WITH_TYPE_UNAVAILABLE, } from '#/session/subagent/spawn'; import { DEFAULT_SUBAGENT_TIMEOUT_MS, SECONDARY_MODEL_SECTION, SUBAGENT_SECTION } from '#/session/subagent/configSection'; -import { SECONDARY_MODEL_FLAG_ID, SUBAGENT_FORK_FLAG_ID } from '#/session/subagent/flag'; +import { SUBAGENT_FORK_FLAG_ID } from '#/session/subagent/flag'; import { Error2, ErrorCodes } from '#/errors'; import { runAgentTurn } from '#/session/subagent/runAgentTurn'; import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/subagent/mirrorAgentRun'; @@ -108,13 +108,6 @@ import { AgentSkill, skillAgentRuntimeProvider } from '#/features/skill/skillAge const signal = new AbortController().signal; -function secondaryModelFlags(enabled = true): TestAgentServiceOverride { - return appService( - IFlagService, - stubFlag((id) => enabled && id === SECONDARY_MODEL_FLAG_ID), - ); -} - function forkFlags(enabled = true): TestAgentServiceOverride { return appService( IFlagService, @@ -1042,7 +1035,7 @@ describe('Agent tool description', () => { }); it('renders the pool in config order with the default first and a generic primary line', () => { - ctx = createTestAgent(secondaryModelFlags(), { + ctx = createTestAgent({ initialConfig: { secondaryModel: { defaultModel: 'provider/fast', @@ -1067,7 +1060,7 @@ describe('Agent tool description', () => { }); it('lists the caller-in-pool alias with a [main model] marker and renders empty descriptions bare', () => { - ctx = createTestAgent(secondaryModelFlags(), { + ctx = createTestAgent({ initialConfig: { secondaryModel: { defaultModel: 'provider/fast', @@ -1090,7 +1083,7 @@ describe('Agent tool description', () => { }); it('marks the caller-as-default alias with both [default] and [main model]', () => { - ctx = createTestAgent(secondaryModelFlags(), { + ctx = createTestAgent({ initialConfig: { secondaryModel: { defaultModel: 'mock-model', @@ -1130,7 +1123,7 @@ describe('Agent tool description', () => { }); it('advertises the model parameter when a pool is configured', () => { - ctx = createTestAgent(secondaryModelFlags(), { + ctx = createTestAgent({ initialConfig: { secondaryModel: { defaultModel: 'provider/fast', @@ -1149,13 +1142,9 @@ describe('Agent tool description', () => { expect(properties['model']?.enum).toBeUndefined(); }); - it('strips the model parameter and pool description while the experiment is off', () => { - ctx = createTestAgent(secondaryModelFlags(false), { + it('strips the model parameter and pool description when no pool is configured', () => { + ctx = createTestAgent({ initialConfig: { - secondaryModel: { - defaultModel: 'provider/fast', - models: { 'provider/fast': 'fast and cheap' }, - }, models: POOL_MODEL_ENTRIES, }, }); @@ -1166,7 +1155,7 @@ describe('Agent tool description', () => { }); it('treats a pool-less default_model as an implicit single-entry pool', () => { - ctx = createTestAgent(secondaryModelFlags(), { + ctx = createTestAgent({ initialConfig: { secondaryModel: { defaultModel: 'provider/fast' }, models: POOL_MODEL_ENTRIES, @@ -1182,7 +1171,7 @@ describe('Agent tool description', () => { }); it('hides the model parameter and the pool description when force is set', () => { - ctx = createTestAgent(secondaryModelFlags(), { + ctx = createTestAgent({ initialConfig: { secondaryModel: { defaultModel: 'provider/fast', force: true }, models: POOL_MODEL_ENTRIES, @@ -1702,7 +1691,7 @@ describe('Agent tool execution contract', () => { it('spawns the subagent on the pool default model when the tool call omits model', async () => { const lifecycle = createAgentLifecycleStub({ createAgentIds: ['agent-child'] }); - const context = createAgentToolContext(lifecycle, secondaryModelFlags(), { + const context = createAgentToolContext(lifecycle, { initialConfig: { secondaryModel: { defaultModel: 'provider/fast', @@ -1735,7 +1724,7 @@ describe('Agent tool execution contract', () => { it('spawns on the caller model when the tool call opts into "primary"', async () => { const lifecycle = createAgentLifecycleStub({ createAgentIds: ['agent-child'] }); - const context = createAgentToolContext(lifecycle, secondaryModelFlags(), { + const context = createAgentToolContext(lifecycle, { initialConfig: { secondaryModel: { defaultModel: 'provider/fast', @@ -1764,7 +1753,7 @@ describe('Agent tool execution contract', () => { const lifecycle = createAgentLifecycleStub({ createAgentIds: ['agent-child', 'agent-child-2'], }); - const context = createAgentToolContext(lifecycle, secondaryModelFlags(), { + const context = createAgentToolContext(lifecycle, { initialConfig: { secondaryModel: { defaultModel: 'provider/fast', @@ -1800,7 +1789,7 @@ describe('Agent tool execution contract', () => { it('spawns on the pool alias chosen via the model parameter', async () => { const lifecycle = createAgentLifecycleStub({ createAgentIds: ['agent-child'] }); - const context = createAgentToolContext(lifecycle, secondaryModelFlags(), { + const context = createAgentToolContext(lifecycle, { initialConfig: { secondaryModel: { defaultModel: 'provider/fast', @@ -1827,7 +1816,7 @@ describe('Agent tool execution contract', () => { it('rejects a model choice outside the pool, listing the available models', async () => { const lifecycle = createAgentLifecycleStub({ createAgentIds: ['agent-child'] }); - const context = createAgentToolContext(lifecycle, secondaryModelFlags(), { + const context = createAgentToolContext(lifecycle, { initialConfig: { secondaryModel: { defaultModel: 'provider/fast', @@ -1870,7 +1859,7 @@ describe('Agent tool execution contract', () => { it('binds the forced default_model and rejects any explicit choice, "primary" included', async () => { const lifecycle = createAgentLifecycleStub({ createAgentIds: ['agent-child'] }); - const context = createAgentToolContext(lifecycle, secondaryModelFlags(), { + const context = createAgentToolContext(lifecycle, { initialConfig: { secondaryModel: { defaultModel: 'provider/fast', force: true }, }, @@ -1898,7 +1887,7 @@ describe('Agent tool execution contract', () => { it('rejects a pool that gained the reserved "primary" key through a runtime config edit', async () => { const lifecycle = createAgentLifecycleStub({ createAgentIds: ['agent-child'] }); - const context = createAgentToolContext(lifecycle, secondaryModelFlags(), { + const context = createAgentToolContext(lifecycle, { initialConfig: { secondaryModel: { defaultModel: 'provider/fast', @@ -1932,7 +1921,6 @@ describe('Agent tool execution contract', () => { const context = createAgentToolContext( lifecycle, modelProviderServices(modelCatalogResolving('mock-model', 'provider/bad')), - secondaryModelFlags(), { initialConfig: { secondaryModel: { defaultModel: 'provider/bad', models: { 'provider/bad': 'broken' } }, @@ -1954,7 +1942,7 @@ describe('Agent tool execution contract', () => { const lifecycle = createAgentLifecycleStub({ createError: new Error('MCP server failed to start'), }); - const context = createAgentToolContext(lifecycle, secondaryModelFlags(), { + const context = createAgentToolContext(lifecycle, { initialConfig: { secondaryModel: { defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap' } }, }, @@ -2859,7 +2847,7 @@ describe('AgentSwarm tool description', () => { }); it('renders the configured pool with the default marker and a generic primary line', () => { - ctx = createTestAgent(secondaryModelFlags(), { + ctx = createTestAgent({ initialConfig: { secondaryModel: { defaultModel: 'provider/fast', @@ -2893,7 +2881,7 @@ describe('AgentSwarm tool description', () => { }); it('advertises the model parameter when a pool is configured', () => { - ctx = createTestAgent(secondaryModelFlags(), { + ctx = createTestAgent({ initialConfig: { secondaryModel: { defaultModel: 'provider/fast', @@ -3017,7 +3005,6 @@ describe('AgentSwarm tool execution contract', () => { }; ctx = createTestAgent( swarmServices(swarmService), - secondaryModelFlags(), { initialConfig: { secondaryModel: { @@ -3078,7 +3065,6 @@ describe('AgentSwarm tool execution contract', () => { }; ctx = createTestAgent( swarmServices(swarmService), - secondaryModelFlags(), { initialConfig: { secondaryModel: { diff --git a/packages/agent-core-v2/test/workspace/workspaceInstance/workspaceInstanceManager.test.ts b/packages/agent-core-v2/test/workspace/workspaceInstance/workspaceInstanceManager.test.ts index d71c698e7fc..4fb7aee46c1 100644 --- a/packages/agent-core-v2/test/workspace/workspaceInstance/workspaceInstanceManager.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceInstance/workspaceInstanceManager.test.ts @@ -139,10 +139,10 @@ function manager( { scope: () => 'sessions' }, workspaces, { ready }, - ...Array.from({ length: 23 }, () => undefined), + ...Array.from({ length: 22 }, () => undefined), new TestRuntimeUnitHostFactory(), ]; - args[20] = { entries: () => [] }; + args[19] = { entries: () => [] }; const value = Reflect.construct(WorkspaceInstanceManager, args) as WorkspaceInstanceManager; const providers = (value as unknown as { providers: Map }).providers; providers.clear(); diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index 5dfe3f171a7..f578200521b 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -15,7 +15,7 @@ import { mcpResultToExecutableOutput } from '../../mcp/output'; import { isMcpToolName, qualifyMcpToolName } from '../../mcp/tool-naming'; import type { MCPClient, MCPToolDefinition } from '../../mcp/types'; import { resolveSubagentTimeoutMs } from '../../session/subagent-host'; -import { buildSubagentModelDescriptions } from '../../session/subagent-binding'; +import { buildSubagentModelDescriptions, resolveSecondaryModel } from '../../session/subagent-binding'; import { extendWorkspaceWithSkillRoots } from '../../skill'; import { fingerprint } from '../llm-request-logger'; import * as b from '../../tools/builtin'; @@ -849,11 +849,10 @@ export class ToolManager { allowBackground, log: this.agent.log, subagentTimeoutMs: resolveSubagentTimeoutMs(this.agent.kimiConfig?.subagent?.timeoutMs), - showModelPreferences: this.agent.experimentalFlags.enabled('secondary-model'), - modelChoiceEnabled: this.agent.experimentalFlags.enabled('secondary-model'), + showModelPreferences: resolveSecondaryModel(this.agent.kimiConfig) !== undefined, + modelChoiceEnabled: resolveSecondaryModel(this.agent.kimiConfig) !== undefined, subagentModelDescription: buildSubagentModelDescriptions( this.agent.kimiConfig, - this.agent.experimentalFlags, this.agent.config.modelAlias, ), }, @@ -865,10 +864,9 @@ export class ToolManager { resolveSubagentTimeoutMs(this.agent.kimiConfig?.subagent?.timeoutMs), buildSubagentModelDescriptions( this.agent.kimiConfig, - this.agent.experimentalFlags, this.agent.config.modelAlias, ), - this.agent.experimentalFlags.enabled('secondary-model'), + resolveSecondaryModel(this.agent.kimiConfig) !== undefined, ), toolServices?.webSearcher && new b.WebSearchTool(toolServices.webSearcher), toolServices?.urlFetcher && new b.FetchURLTool(toolServices.urlFetcher), diff --git a/packages/agent-core/src/flags/registry.ts b/packages/agent-core/src/flags/registry.ts index 70b86a38dac..dbec75b80b5 100644 --- a/packages/agent-core/src/flags/registry.ts +++ b/packages/agent-core/src/flags/registry.ts @@ -32,15 +32,6 @@ export const FLAG_DEFINITIONS = [ default: false, surface: 'core', }, - { - id: 'secondary-model', - title: 'Secondary model for subagents', - description: - 'Let newly spawned subagents use a separately configured secondary model by default, with an explicit primary-model override for quality-sensitive tasks.', - env: 'KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', - default: true, - surface: 'core', - }, ] as const satisfies readonly FlagDefinitionInput[]; /** Literal union of registered flag ids. */ diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 08a6667d4fd..219dea7d0c7 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -953,7 +953,7 @@ export class Session { private computeSecondaryModelWarnings(): SessionWarning[] { if (this.secondaryModelWarnings !== undefined) return [...this.secondaryModelWarnings]; const warnings: SessionWarning[] = []; - const secondary = resolveSecondaryModel(this.kimiConfig, this.experimentalFlags); + const secondary = resolveSecondaryModel(this.kimiConfig); if (secondary?.model !== undefined) { const boundAlias = secondaryModelPatch(secondary) === undefined diff --git a/packages/agent-core/src/session/subagent-binding.ts b/packages/agent-core/src/session/subagent-binding.ts index c0bf7b1fc82..01fc935c794 100644 --- a/packages/agent-core/src/session/subagent-binding.ts +++ b/packages/agent-core/src/session/subagent-binding.ts @@ -6,24 +6,23 @@ import { type SecondaryModelConfig, } from '../config'; import { ErrorCodes, KimiError } from '../errors'; -import type { ExperimentalFlagResolver } from '../flags'; import type { AgentModelPreference } from '../profile'; /** * Subagent model binding — the secondary-model half of the spawn decision. * - * When the `secondary-model` experiment is enabled and `[secondary_model]` is - * configured, newly spawned subagents bind to it by default instead of - * inheriting the caller's model. The caller (the parent model, through the - * `Agent` / `AgentSwarm` tool `model` parameter) or the spawned profile (via - * `model_preference`) can force `primary`. A recipe with patch fields binds - * the synthesized derived entry ({@link SECONDARY_DERIVED_MODEL_ALIAS}, - * materialized by `applySecondaryModelConfig`); a pointer-only recipe binds - * the pointed entry directly. `default_effort` is passed as the explicit - * subagent thinking effort; without it the child resolves thinking naturally - * (global thinking config → the bound model's default effort) rather than - * inheriting the caller's level. When unset, spawning behavior is unchanged: - * subagents inherit the caller's model and effort. + * When `[secondary_model]` is configured, newly spawned subagents bind to it + * by default instead of inheriting the caller's model. The caller (the parent + * model, through the `Agent` / `AgentSwarm` tool `model` parameter) or the + * spawned profile (via `model_preference`) can force `primary`. A recipe + * with patch fields binds the synthesized derived entry + * ({@link SECONDARY_DERIVED_MODEL_ALIAS}, materialized by + * `applySecondaryModelConfig`); a pointer-only recipe binds the pointed + * entry directly. `default_effort` is passed as the explicit subagent + * thinking effort; without it the child resolves thinking naturally (global + * thinking config → the bound model's default effort) rather than inheriting + * the caller's level. When unset, spawning behavior is unchanged: subagents + * inherit the caller's model and effort. */ export type SubagentModelChoice = AgentModelPreference; @@ -35,9 +34,7 @@ export interface SubagentModelBinding { export function resolveSecondaryModel( config: KimiConfig | undefined, - flags: ExperimentalFlagResolver, ): SecondaryModelConfig | undefined { - if (!flags.enabled('secondary-model')) return undefined; return config?.secondaryModel; } @@ -48,11 +45,10 @@ export function resolveSecondaryModel( */ export function resolveSubagentBinding( config: KimiConfig | undefined, - flags: ExperimentalFlagResolver, own: { readonly modelAlias: string | undefined; readonly thinkingEffort: string }, requested?: SubagentModelChoice, ): SubagentModelBinding { - const secondary = resolveSecondaryModel(config, flags); + const secondary = resolveSecondaryModel(config); if (requested !== 'primary' && secondary?.model !== undefined) { return { modelAlias: @@ -72,10 +68,9 @@ export function resolveSubagentBinding( */ export function buildSubagentModelDescriptions( config: KimiConfig | undefined, - flags: ExperimentalFlagResolver, callerModelAlias: string | undefined, ): string | undefined { - const secondaryModel = resolveSecondaryModel(config, flags)?.model; + const secondaryModel = resolveSecondaryModel(config)?.model; if (secondaryModel === undefined || callerModelAlias === undefined) return undefined; return [ 'Available models (pass via model):', @@ -86,7 +81,7 @@ export function buildSubagentModelDescriptions( /** * Strip the `model` property from a subagent collaboration tool's advertised - * JSON schema. While the `secondary-model` experiment is off the parameter is + * JSON schema. When no secondary model is configured the parameter would be * a silent no-op, so the schema the model sees (and the args validator * compiled from the same advertised schema) drops it entirely — the * secondary-model concept never enters the prompt, and a stray `model` diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 790e1b4d080..f95ce094384 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -196,7 +196,6 @@ export class SessionSubagentHost { const completion = this.runWithActiveChild(agentId, options, async (runOptions) => { this.emitSubagentSpawned(parent, agentId, profileName, runOptions); try { - this.reInheritParentModel(parent, child); return await this.runPromptTurn(parent, agentId, child, profileName, runOptions); } catch (error) { this.emitSubagentFailed(parent, agentId, runOptions, error); @@ -212,7 +211,6 @@ export class SessionSubagentHost { const completion = this.runWithActiveChild(agentId, options, async (runOptions) => { try { runOptions.signal.throwIfAborted(); - this.reInheritParentModel(parent, child); this.emitSubagentStarted(parent, agentId); const turnId = child.turn.retry('agent-host'); if (turnId === null) { @@ -463,7 +461,7 @@ export class SessionSubagentHost { /** * The model a newly spawned subagent binds to: the configured secondary - * model by default (when the experiment is on), otherwise the parent's + * model by default (when `[secondary_model]` is set), otherwise the parent's * model and effort, inherited as before. The bound alias is validated up * front so a dangling `[secondary_model]` pointer fails the spawn with a * wrapped, actionable error instead of a mid-turn provider failure. @@ -475,7 +473,6 @@ export class SessionSubagentHost { ): SubagentModelBinding { const binding = resolveSubagentBinding( this.session.kimiConfig, - this.session.experimentalFlags, { modelAlias: parent.config.modelAlias, thinkingEffort: parent.config.thinkingEffort }, modelChoice ?? profile.modelPreference, ); @@ -490,18 +487,6 @@ export class SessionSubagentHost { return binding; } - /** - * Resume/retry historically re-synced the child to the parent's current - * model so subagents follow mid-session `/model` switches. With the - * `secondary-model` experiment on, a resumed subagent instead keeps the - * model it was bound to at spawn (v2 semantics: no child-follows-parent - * invariant). - */ - private reInheritParentModel(parent: Agent, child: Agent): void { - if (this.session.experimentalFlags.enabled('secondary-model')) return; - child.config.update({ modelAlias: parent.config.modelAlias }); - } - /** * Hold the run open until the child agent's background tasks (background * Bash, nested background agents) settle — the print-mode (`kimi -p`) diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts index 06a320ff550..6aac790a626 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts @@ -105,9 +105,9 @@ export class AgentSwarmTool implements BuiltinTool { // SubagentBatch arms no timer for non-positive timeouts. private readonly subagentTimeoutMs?: number, subagentModelDescription?: string, - // Mirrors the `secondary-model` experiment: off (the default), the no-op - // `model` parameter is stripped from the advertised schema so the - // secondary-model concept never enters the prompt. + // When no secondary model is configured, the no-op `model` parameter is + // stripped from the advertised schema so the secondary-model concept + // never enters the prompt. modelChoiceEnabled = false, ) { this.description = diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.ts b/packages/agent-core/src/tools/builtin/collaboration/agent.ts index f5d8e0f9483..ca9a4127c4d 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.ts @@ -128,9 +128,9 @@ export class AgentTool implements BuiltinTool { subagentTimeoutMs?: number | undefined; subagentModelDescription?: string; showModelPreferences?: boolean; - // Mirrors the `secondary-model` experiment: off (the default), the - // no-op `model` parameter is stripped from the advertised schema so the - // secondary-model concept never enters the prompt. + // When no secondary model is configured, the no-op `model` parameter is + // stripped from the advertised schema so the secondary-model concept + // never enters the prompt. modelChoiceEnabled?: boolean; }, ) { diff --git a/packages/agent-core/test/agent/basic.test.ts b/packages/agent-core/test/agent/basic.test.ts index efea2c658d0..4600608188a 100644 --- a/packages/agent-core/test/agent/basic.test.ts +++ b/packages/agent-core/test/agent/basic.test.ts @@ -15,9 +15,9 @@ it('creates an independent agent with a scoped experimental flag resolver', () = experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS), }); - // The scoped resolver reflects the registry defaults on its own: - // secondary-model defaults on, tool-select stays off. - expect(ctx.agent.experimentalFlags.enabledIds()).toEqual(['secondary-model']); + // The scoped resolver reflects the registry defaults on its own: every + // registered flag defaults off, so none report enabled. + expect(ctx.agent.experimentalFlags.enabledIds()).toEqual([]); }); it('runs a text-only agent turn from prompt to completion', async () => { diff --git a/packages/agent-core/test/agent/tool.test.ts b/packages/agent-core/test/agent/tool.test.ts index adffe5b5a1a..7be6565deae 100644 --- a/packages/agent-core/test/agent/tool.test.ts +++ b/packages/agent-core/test/agent/tool.test.ts @@ -419,7 +419,7 @@ describe('Agent tools', () => { expect(ctx.agent.tools.loopTools.some((tool) => tool.name === 'AgentSwarm')).toBe(true); }); - it('shows the model preference for a subagent type when the experiment is enabled', () => { + it('shows the model preference for a subagent type when a secondary model is configured', () => { const subagentHost = { delegatableSubagents: vi.fn(() => ({ coder: { @@ -433,7 +433,7 @@ describe('Agent tools', () => { } as unknown as SessionSubagentHost; const ctx = testAgent({ subagentHost, - experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS, { 'secondary-model': true }), + initialConfig: { providers: {}, secondaryModel: { model: 'cheap-model' } }, }); ctx.configure({ tools: ['Agent'] }); @@ -442,7 +442,7 @@ describe('Agent tools', () => { expect(description).toContain('- coder: General coding.\n Model preference: primary'); }); - it('hides model preferences when the experiment is disabled', () => { + it('hides model preferences when no secondary model is configured', () => { const subagentHost = { delegatableSubagents: vi.fn(() => ({ coder: { @@ -456,10 +456,7 @@ describe('Agent tools', () => { } as unknown as SessionSubagentHost; const ctx = testAgent({ subagentHost, - experimentalFlags: new FlagResolver( - { KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL: '0' }, - FLAG_DEFINITIONS, - ), + initialConfig: { providers: {} }, }); ctx.configure({ tools: ['Agent'] }); diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts index 912a6b5df27..65341584eb9 100644 --- a/packages/agent-core/test/harness/runtime.test.ts +++ b/packages/agent-core/test/harness/runtime.test.ts @@ -235,7 +235,6 @@ micro_compaction = false await mkdir(homeDir, { recursive: true }); await mkdir(workDir, { recursive: true }); await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); - vi.stubEnv('KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', '1'); const [coreRpc, sdkRpc] = createRPC(); const core = new KimiCore(coreRpc, { homeDir }); diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index a215e9bb826..6738b27315e 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -766,9 +766,7 @@ describe('Session secondary-model live config', () => { rpc: createSessionRpc([]), skills: { explicitDirs: [join(workDir, 'missing-skills')] }, providerManager: testProviderManager(), - experimentalFlags: new FlagResolver({ - KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL: '1', - }), + experimentalFlags: new FlagResolver({}), config, }); } diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 55a84aa70f3..89fa696c57e 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -1134,59 +1134,7 @@ describe('SessionSubagentHost', () => { expect(userTextMessages(histories[1] ?? [])).toEqual(['Implement the retry-safe change']); }); - it('realigns a resumed subagent to the parent agent current model', async () => { - const parent = testAgent(); - parent.configure(); - parent.agent.permission.setMode('yolo'); - - const child = testAgent(); - child.configure({ tools: ['Read'] }); - // The child was originally spawned with a model that no longer matches the - // parent agent's current model (as if the parent ran setModel afterwards). - child.agent.config.update({ modelAlias: 'stale-model-from-initial-spawn' }); - child.agent.useProfile( - profile({ name: 'explore', tools: ['Read'], systemPrompt: 'explore prompt' }), - ); - child.agent.context.appendUserMessage([{ type: 'text', text: 'Earlier context' }]); - child.mockNextResponse({ - type: 'text', - text: 'Resumed the subagent from its earlier context and carried the task through to completion, then reported a full and detailed technical summary so the parent agent can continue without repeating prior work.', - }); - - const session = fakeSession( - parent.agent, - child.agent, - { - 'agent-0': { - homedir: '/tmp/kimi-session/agents/agent-0', - type: 'sub', - parentAgentId: 'main', - }, - }, - { - experimentalFlags: new FlagResolver({ KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL: '0' }), - }, - ); - const host = new SessionSubagentHost(session, 'main'); - - const handle = await host.resume('agent-0', { - parentToolCallId: 'call_agent', - prompt: 'Continue from context', - description: 'Continue work', - runInBackground: false, - signal, - }); - - await handle.completion; - // resume must realign the child to the parent agent's current model rather - // than leave it on the stale model from its initial spawn. - expect(child.agent.config.modelAlias).toBe(parent.agent.config.modelAlias); - expect(child.agent.config.modelAlias).not.toBe('stale-model-from-initial-spawn'); - }); - describe('secondary model binding', () => { - const secondaryFlags = () => - new FlagResolver({ KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL: '1' }); const LONG_SUMMARY = 'Completed the delegated task end to end and reported a technically complete summary so the parent agent can continue without repeating prior work. ' + 'The report covers the investigation, the changes made, and the verification results in enough detail for the caller to act on directly.'; @@ -1213,7 +1161,6 @@ describe('SessionSubagentHost', () => { async function spawnChild(options: { config?: KimiConfig; - experimentalFlags?: FlagResolver; providerManager?: Session['options']['providerManager']; modelChoice?: 'primary' | 'secondary'; profilePreference?: 'primary' | 'secondary'; @@ -1226,7 +1173,6 @@ describe('SessionSubagentHost', () => { const session = fakeSession(parent.agent, child.agent, {}, { config: options.config, - experimentalFlags: options.experimentalFlags, providerManager: options.providerManager, }); const host = new SessionSubagentHost(session, 'main'); @@ -1260,7 +1206,6 @@ describe('SessionSubagentHost', () => { it('binds the secondary model when configured', async () => { const { parent, child } = await spawnChild({ - experimentalFlags: secondaryFlags(), config: { providers: {}, secondaryModel: { model: 'cheap-model' }, @@ -1272,7 +1217,6 @@ describe('SessionSubagentHost', () => { it('binds the derived entry when the recipe carries patch fields', async () => { const { child } = await spawnChild({ - experimentalFlags: secondaryFlags(), config: { providers: {}, secondaryModel: { model: 'cheap-model', defaultEffort: 'low' }, @@ -1283,17 +1227,15 @@ describe('SessionSubagentHost', () => { expect(child.agent.config.modelAlias).toBe('__secondary__'); }); - it('inherits the parent model when the experiment is off', async () => { + it('inherits the parent model when no secondary model is configured', async () => { const { parent, child } = await spawnChild({ - experimentalFlags: new FlagResolver({ KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL: '0' }), - config: { providers: {}, secondaryModel: { model: 'cheap-model' } }, + config: { providers: {} }, }); expect(child.agent.config.modelAlias).toBe(parent.agent.config.modelAlias); }); it('inherits the parent model for an explicit model: primary choice', async () => { const { parent, child } = await spawnChild({ - experimentalFlags: secondaryFlags(), config: { providers: {}, secondaryModel: { model: 'cheap-model' } }, modelChoice: 'primary', }); @@ -1302,7 +1244,6 @@ describe('SessionSubagentHost', () => { it('honors the profile model_preference over the configured secondary model', async () => { const { parent, child } = await spawnChild({ - experimentalFlags: secondaryFlags(), config: { providers: {}, secondaryModel: { model: 'cheap-model' } }, profilePreference: 'primary', }); @@ -1319,7 +1260,6 @@ describe('SessionSubagentHost', () => { secondaryModel: { model: 'missing-model' }, }; const session = fakeSession(parent.agent, child.agent, {}, { - experimentalFlags: secondaryFlags(), config, providerManager: new ProviderManager({ config }), }); @@ -1354,7 +1294,6 @@ describe('SessionSubagentHost', () => { secondaryModel: { model: 'cheap-model' }, }; const session = fakeSession(parent.agent, child.agent, {}, { - experimentalFlags: secondaryFlags(), config, providerManager: new ProviderManager({ config }), }); @@ -1374,7 +1313,7 @@ describe('SessionSubagentHost', () => { }); }); - it('keeps the spawned model on resume when the experiment is on', async () => { + it('keeps the spawned model on resume', async () => { const parent = testAgent(); parent.configure(); parent.agent.permission.setMode('yolo'); @@ -1395,7 +1334,6 @@ describe('SessionSubagentHost', () => { parentAgentId: 'main', }, }, { - experimentalFlags: secondaryFlags(), config: { providers: {}, secondaryModel: { model: 'cheap-model' } }, }); const host = new SessionSubagentHost(session, 'main'); @@ -1408,8 +1346,8 @@ describe('SessionSubagentHost', () => { signal, }); await handle.completion; - // With the experiment on, resume no longer realigns the child to the - // parent's model: the subagent keeps the model it was bound to at spawn. + // Resume no longer realigns the child to the parent's model: the + // subagent keeps the model it was bound to at spawn. expect(child.agent.config.modelAlias).toBe('cheap-model'); }); }); diff --git a/packages/kap-server/test/config.test.ts b/packages/kap-server/test/config.test.ts index 04c0a0da59c..a1fdb37ecee 100644 --- a/packages/kap-server/test/config.test.ts +++ b/packages/kap-server/test/config.test.ts @@ -160,9 +160,7 @@ describe('server-v2 /api/v1/config', () => { }); it('session create with a broken subagent model pool fails with VALIDATION_FAILED', async () => { - await boot( - '[experimental]\n"secondary-model" = true\n\n[secondary_model.models]\n"provider/fast" = "fast and cheap"\n', - ); + await boot('[secondary_model.models]\n"provider/fast" = "fast and cheap"\n'); const res = await authedFetch(server as RunningServer, base, '/api/v1/sessions', { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -172,19 +170,6 @@ describe('server-v2 /api/v1/config', () => { expect(body.code).toBe(ErrorCode.VALIDATION_FAILED); expect(body.msg).toContain('[secondary_model].default_model is required'); }); - - it('session create with a broken subagent model pool succeeds while the experiment is off', async () => { - await boot( - '[experimental]\n"secondary-model" = false\n\n[secondary_model.models]\n"provider/fast" = "fast and cheap"\n', - ); - const res = await authedFetch(server as RunningServer, base, '/api/v1/sessions', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ metadata: { cwd: home as string } }), - }); - const body = (await res.json()) as Envelope<{ id: string }>; - expect(body.code).toBe(0); - }); }); describe('server-v2 config changed WS notifications', () => { diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index e7f7fe769d8..9484facf1ff 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -345,17 +345,6 @@ describe('KimiHarness config API', () => { enabled: false, source: 'default', }, - { - id: 'secondary-model', - title: 'Secondary model for subagents', - description: - 'Let newly spawned subagents use a separately configured secondary model by default, with an explicit primary-model override for quality-sensitive tasks.', - surface: 'core', - env: 'KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', - defaultEnabled: true, - enabled: true, - source: 'default', - }, ]); }); From 844e536d570f25af80c54b89d5a86641dd812980 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 28 Aug 2026 18:11:33 +0800 Subject: [PATCH 03/12] fix(secondary-model): honor the v2 default_model key on the legacy engine --- .changeset/secondary-model-default-on.md | 2 +- packages/agent-core/src/agent/tool/index.ts | 14 +++++++---- .../agent-core/src/config/secondary-model.ts | 14 ++++++++++- packages/agent-core/src/session/index.ts | 6 +++-- .../src/session/subagent-binding.ts | 10 ++++---- packages/agent-core/test/agent/tool.test.ts | 23 +++++++++++++++++++ .../test/session/subagent-host.test.ts | 11 +++++++++ 7 files changed, 68 insertions(+), 12 deletions(-) diff --git a/.changeset/secondary-model-default-on.md b/.changeset/secondary-model-default-on.md index 7e9c028af31..7e2b071d77e 100644 --- a/.changeset/secondary-model-default-on.md +++ b/.changeset/secondary-model-default-on.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -The subagent model pool (`[secondary_model]`) is now a permanent feature rather than an experiment: subagents bind the pool's `default_model`, and the `Agent` / `AgentSwarm` tools expose the `model` parameter when a pool is configured. The `secondary-model` experiment and its `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` env var are removed and now have no effect. +The subagent model pool (`[secondary_model]`) now works without the removed `secondary-model` experiment — subagents bind the pool's `default_model`, and the `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` env var no longer has any effect. diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index f578200521b..47bf0015621 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -15,7 +15,11 @@ import { mcpResultToExecutableOutput } from '../../mcp/output'; import { isMcpToolName, qualifyMcpToolName } from '../../mcp/tool-naming'; import type { MCPClient, MCPToolDefinition } from '../../mcp/types'; import { resolveSubagentTimeoutMs } from '../../session/subagent-host'; -import { buildSubagentModelDescriptions, resolveSecondaryModel } from '../../session/subagent-binding'; +import { + buildSubagentModelDescriptions, + resolveSecondaryModel, +} from '../../session/subagent-binding'; +import { secondaryModelAlias } from '../../config/secondary-model'; import { extendWorkspaceWithSkillRoots } from '../../skill'; import { fingerprint } from '../llm-request-logger'; import * as b from '../../tools/builtin'; @@ -849,8 +853,10 @@ export class ToolManager { allowBackground, log: this.agent.log, subagentTimeoutMs: resolveSubagentTimeoutMs(this.agent.kimiConfig?.subagent?.timeoutMs), - showModelPreferences: resolveSecondaryModel(this.agent.kimiConfig) !== undefined, - modelChoiceEnabled: resolveSecondaryModel(this.agent.kimiConfig) !== undefined, + showModelPreferences: + secondaryModelAlias(resolveSecondaryModel(this.agent.kimiConfig)) !== undefined, + modelChoiceEnabled: + secondaryModelAlias(resolveSecondaryModel(this.agent.kimiConfig)) !== undefined, subagentModelDescription: buildSubagentModelDescriptions( this.agent.kimiConfig, this.agent.config.modelAlias, @@ -866,7 +872,7 @@ export class ToolManager { this.agent.kimiConfig, this.agent.config.modelAlias, ), - resolveSecondaryModel(this.agent.kimiConfig) !== undefined, + secondaryModelAlias(resolveSecondaryModel(this.agent.kimiConfig)) !== undefined, ), toolServices?.webSearcher && new b.WebSearchTool(toolServices.webSearcher), toolServices?.urlFetcher && new b.FetchURLTool(toolServices.urlFetcher), diff --git a/packages/agent-core/src/config/secondary-model.ts b/packages/agent-core/src/config/secondary-model.ts index e353fbcd0d9..b5eaeaed30a 100644 --- a/packages/agent-core/src/config/secondary-model.ts +++ b/packages/agent-core/src/config/secondary-model.ts @@ -29,6 +29,18 @@ export const SECONDARY_DERIVED_MODEL_ALIAS = '__secondary__'; export const SECONDARY_MODEL_ENV = 'KIMI_SECONDARY_MODEL'; export const SECONDARY_MODEL_EFFORT_ENV = 'KIMI_SECONDARY_EFFORT'; +/** + * The model pointer of the recipe, accepting the v2 engine's `default_model` + * as an alias for the legacy `model` key (`model` wins when both are set). + * Use this instead of reading `.model` directly anywhere a binding or a + * schema/warning decision needs "which model did the user configure". + */ +export function secondaryModelAlias( + secondary: SecondaryModelConfig | undefined, +): string | undefined { + return secondary?.model ?? secondary?.defaultModel; +} + type Env = Readonly>; function trimmed(value: string | undefined): string | undefined { @@ -82,7 +94,7 @@ export function applySecondaryModelConfig(config: KimiConfig, env: Env = process let next = secondary === config.secondaryModel ? config : { ...config, secondaryModel: secondary }; const patch = secondaryModelPatch(secondary); - const baseId = secondary?.model; + const baseId = secondaryModelAlias(secondary); if (patch === undefined || baseId === undefined || baseId === SECONDARY_DERIVED_MODEL_ALIAS) { return next; } diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 219dea7d0c7..21bd423fdf7 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -56,6 +56,7 @@ import { } from './subagent-binding'; import { SECONDARY_DERIVED_MODEL_ALIAS, + secondaryModelAlias, secondaryModelPatch, } from '../config/secondary-model'; import { @@ -954,10 +955,11 @@ export class Session { if (this.secondaryModelWarnings !== undefined) return [...this.secondaryModelWarnings]; const warnings: SessionWarning[] = []; const secondary = resolveSecondaryModel(this.kimiConfig); - if (secondary?.model !== undefined) { + const secondaryAlias = secondaryModelAlias(secondary); + if (secondary !== undefined && secondaryAlias !== undefined) { const boundAlias = secondaryModelPatch(secondary) === undefined - ? secondary.model + ? secondaryAlias : SECONDARY_DERIVED_MODEL_ALIAS; try { const resolved = this.options.providerManager?.resolveProviderConfig(boundAlias); diff --git a/packages/agent-core/src/session/subagent-binding.ts b/packages/agent-core/src/session/subagent-binding.ts index 01fc935c794..a8000b67a63 100644 --- a/packages/agent-core/src/session/subagent-binding.ts +++ b/packages/agent-core/src/session/subagent-binding.ts @@ -1,6 +1,7 @@ import { SECONDARY_DERIVED_MODEL_ALIAS, SECONDARY_MODEL_ENV, + secondaryModelAlias, secondaryModelPatch, type KimiConfig, type SecondaryModelConfig, @@ -49,13 +50,14 @@ export function resolveSubagentBinding( requested?: SubagentModelChoice, ): SubagentModelBinding { const secondary = resolveSecondaryModel(config); - if (requested !== 'primary' && secondary?.model !== undefined) { + const secondaryAlias = secondaryModelAlias(secondary); + if (requested !== 'primary' && secondaryAlias !== undefined) { return { modelAlias: secondaryModelPatch(secondary) === undefined - ? secondary.model + ? secondaryAlias : SECONDARY_DERIVED_MODEL_ALIAS, - thinkingEffort: secondary.defaultEffort, + thinkingEffort: secondary?.defaultEffort, }; } return { modelAlias: own.modelAlias, thinkingEffort: own.thinkingEffort }; @@ -70,7 +72,7 @@ export function buildSubagentModelDescriptions( config: KimiConfig | undefined, callerModelAlias: string | undefined, ): string | undefined { - const secondaryModel = resolveSecondaryModel(config)?.model; + const secondaryModel = secondaryModelAlias(resolveSecondaryModel(config)); if (secondaryModel === undefined || callerModelAlias === undefined) return undefined; return [ 'Available models (pass via model):', diff --git a/packages/agent-core/test/agent/tool.test.ts b/packages/agent-core/test/agent/tool.test.ts index 7be6565deae..26ab3eb52da 100644 --- a/packages/agent-core/test/agent/tool.test.ts +++ b/packages/agent-core/test/agent/tool.test.ts @@ -442,6 +442,29 @@ describe('Agent tools', () => { expect(description).toContain('- coder: General coding.\n Model preference: primary'); }); + it('shows the model preference when only the v2-style default_model is configured', () => { + const subagentHost = { + delegatableSubagents: vi.fn(() => ({ + coder: { + name: 'coder', + description: 'General coding.', + systemPrompt: () => 'coder prompt', + tools: ['Read'], + modelPreference: 'primary' as const, + }, + })), + } as unknown as SessionSubagentHost; + const ctx = testAgent({ + subagentHost, + initialConfig: { providers: {}, secondaryModel: { defaultModel: 'cheap-model' } }, + }); + ctx.configure({ tools: ['Agent'] }); + + const description = ctx.agent.tools.loopTools.find((tool) => tool.name === 'Agent')?.description; + + expect(description).toContain('- coder: General coding.\n Model preference: primary'); + }); + it('hides model preferences when no secondary model is configured', () => { const subagentHost = { delegatableSubagents: vi.fn(() => ({ diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 89fa696c57e..5468ccf8c04 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -1215,6 +1215,17 @@ describe('SessionSubagentHost', () => { expect(child.agent.config.modelAlias).not.toBe(parent.agent.config.modelAlias); }); + it('binds the v2-style default_model when the legacy model key is unset', async () => { + const { parent, child } = await spawnChild({ + config: { + providers: {}, + secondaryModel: { defaultModel: 'cheap-model' }, + }, + }); + expect(child.agent.config.modelAlias).toBe('cheap-model'); + expect(child.agent.config.modelAlias).not.toBe(parent.agent.config.modelAlias); + }); + it('binds the derived entry when the recipe carries patch fields', async () => { const { child } = await spawnChild({ config: { From b86cd72ff3b0564ee48c957e29d2884cec78c53e Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 28 Aug 2026 18:18:44 +0800 Subject: [PATCH 04/12] fix(secondary-model): enforce forced subagent pools on the legacy engine --- packages/agent-core/src/agent/tool/index.ts | 13 +++-- .../src/session/subagent-binding.ts | 6 ++- .../agent-core/src/session/subagent-host.ts | 49 +++++++++++++++++-- packages/agent-core/test/agent/tool.test.ts | 26 ++++++++++ .../test/session/subagent-host.test.ts | 32 ++++++++++++ 5 files changed, 115 insertions(+), 11 deletions(-) diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index 47bf0015621..08806081a06 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -784,6 +784,11 @@ export class ToolManager { background, } = this.agent; const videoUploader = this.createVideoUploader(provider); + const secondaryModel = resolveSecondaryModel(this.agent.kimiConfig); + // Mirrors the v2 exposesSubagentModelChoice rule: a configured model + // unlocks the choice, while `force` takes it away from the main agent. + const exposeModelChoice = + secondaryModelAlias(secondaryModel) !== undefined && secondaryModel?.force !== true; const workspace = extendWorkspaceWithSkillRoots( { workspaceDir: cwd, @@ -853,10 +858,8 @@ export class ToolManager { allowBackground, log: this.agent.log, subagentTimeoutMs: resolveSubagentTimeoutMs(this.agent.kimiConfig?.subagent?.timeoutMs), - showModelPreferences: - secondaryModelAlias(resolveSecondaryModel(this.agent.kimiConfig)) !== undefined, - modelChoiceEnabled: - secondaryModelAlias(resolveSecondaryModel(this.agent.kimiConfig)) !== undefined, + showModelPreferences: exposeModelChoice, + modelChoiceEnabled: exposeModelChoice, subagentModelDescription: buildSubagentModelDescriptions( this.agent.kimiConfig, this.agent.config.modelAlias, @@ -872,7 +875,7 @@ export class ToolManager { this.agent.kimiConfig, this.agent.config.modelAlias, ), - secondaryModelAlias(resolveSecondaryModel(this.agent.kimiConfig)) !== undefined, + exposeModelChoice, ), toolServices?.webSearcher && new b.WebSearchTool(toolServices.webSearcher), toolServices?.urlFetcher && new b.FetchURLTool(toolServices.urlFetcher), diff --git a/packages/agent-core/src/session/subagent-binding.ts b/packages/agent-core/src/session/subagent-binding.ts index a8000b67a63..f0d18ae8a7a 100644 --- a/packages/agent-core/src/session/subagent-binding.ts +++ b/packages/agent-core/src/session/subagent-binding.ts @@ -72,8 +72,10 @@ export function buildSubagentModelDescriptions( config: KimiConfig | undefined, callerModelAlias: string | undefined, ): string | undefined { - const secondaryModel = secondaryModelAlias(resolveSecondaryModel(config)); - if (secondaryModel === undefined || callerModelAlias === undefined) return undefined; + const secondary = resolveSecondaryModel(config); + const secondaryModel = secondaryModelAlias(secondary); + if (secondaryModel === undefined || callerModelAlias === undefined || secondary?.force === true) + return undefined; return [ 'Available models (pass via model):', `- secondary: ${secondaryModel} (default) — the configured secondary model; prefer it for routine subagent tasks`, diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index f95ce094384..4a79be49b92 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -6,7 +6,7 @@ import { import type { Agent } from '../agent'; import type { PromptOrigin } from '../agent/context'; -import { ErrorCodes } from '../errors'; +import { ErrorCodes, KimiError } from '../errors'; import { DenyAllPermissionPolicy } from '../agent/permission/policies/deny-all'; import { InMemoryAgentRecordPersistence } from '../agent/records'; import { isAbortError } from '../loop/errors'; @@ -26,6 +26,12 @@ import { type SubagentModelBinding, type SubagentModelChoice, } from './subagent-binding'; +import { resolveSecondaryModel } from './subagent-binding'; +import { + SECONDARY_DERIVED_MODEL_ALIAS, + secondaryModelAlias, + secondaryModelPatch, +} from '../config/secondary-model'; import { SubagentBatch, resolveSwarmMaxConcurrency, @@ -462,15 +468,50 @@ export class SessionSubagentHost { /** * The model a newly spawned subagent binds to: the configured secondary * model by default (when `[secondary_model]` is set), otherwise the parent's - * model and effort, inherited as before. The bound alias is validated up - * front so a dangling `[secondary_model]` pointer fails the spawn with a - * wrapped, actionable error instead of a mid-turn provider failure. + * model and effort, inherited as before. With `force` set the choice is + * taken away — an explicit `model: primary` rejection mirrors the v2 error; + * a profile-level preference is overridden silently. The bound alias is + * validated up front so a dangling `[secondary_model]` pointer fails the + * spawn with a wrapped, actionable error instead of a mid-turn provider + * failure. */ private resolveSpawnBinding( parent: Agent, profile: ResolvedAgentProfile, modelChoice?: SubagentModelChoice, ): SubagentModelBinding { + const secondary = resolveSecondaryModel(this.session.kimiConfig); + const secondaryAlias = secondaryModelAlias(secondary); + if (secondary?.force === true) { + if (secondaryAlias === undefined) { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + '[secondary_model].default_model is required when [secondary_model].force is set', + ); + } + if (modelChoice === 'primary') { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + `[secondary_model].force is set, so every subagent binds "${secondaryAlias}" (omit the model parameter).`, + ); + } + const binding: SubagentModelBinding = { + modelAlias: + secondaryModelPatch(secondary) === undefined + ? secondaryAlias + : SECONDARY_DERIVED_MODEL_ALIAS, + thinkingEffort: secondary.defaultEffort, + }; + if (binding.modelAlias !== undefined) { + const providerManager = this.session.options.providerManager; + try { + providerManager?.resolveProviderConfig(binding.modelAlias); + } catch (error) { + throw wrapSubagentModelError(error, binding.modelAlias, parent.config.modelAlias); + } + } + return binding; + } const binding = resolveSubagentBinding( this.session.kimiConfig, { modelAlias: parent.config.modelAlias, thinkingEffort: parent.config.thinkingEffort }, diff --git a/packages/agent-core/test/agent/tool.test.ts b/packages/agent-core/test/agent/tool.test.ts index 26ab3eb52da..1498f9ab739 100644 --- a/packages/agent-core/test/agent/tool.test.ts +++ b/packages/agent-core/test/agent/tool.test.ts @@ -488,6 +488,32 @@ describe('Agent tools', () => { expect(description).not.toContain('Model preference:'); }); + it('hides model preferences when force is set', () => { + const subagentHost = { + delegatableSubagents: vi.fn(() => ({ + coder: { + name: 'coder', + description: 'General coding.', + systemPrompt: () => 'coder prompt', + tools: ['Read'], + modelPreference: 'primary' as const, + }, + })), + } as unknown as SessionSubagentHost; + const ctx = testAgent({ + subagentHost, + initialConfig: { + providers: {}, + secondaryModel: { defaultModel: 'cheap-model', force: true }, + }, + }); + ctx.configure({ tools: ['Agent'] }); + + const description = ctx.agent.tools.loopTools.find((tool) => tool.name === 'Agent')?.description; + + expect(description).not.toContain('Model preference:'); + }); + it('self-heals the builtin tool table when the provider becomes resolvable after construction', () => { // The ProviderManager reads this live config; it starts with no model or // provider, so hasProvider is false at Agent construction and diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 5468ccf8c04..0fe92e8773c 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -1261,6 +1261,38 @@ describe('SessionSubagentHost', () => { expect(child.agent.config.modelAlias).toBe(parent.agent.config.modelAlias); }); + it('binds the forced model and rejects an explicit model: primary choice', async () => { + await expect( + spawnChild({ + config: { + providers: {}, + secondaryModel: { defaultModel: 'cheap-model', force: true }, + }, + modelChoice: 'primary', + }), + ).rejects.toThrow(/\[secondary_model\]\.force is set/); + }); + + it('silently overrides a profile model_preference when force is set', async () => { + const { parent, child } = await spawnChild({ + config: { + providers: {}, + secondaryModel: { defaultModel: 'cheap-model', force: true }, + }, + profilePreference: 'primary', + }); + expect(child.agent.config.modelAlias).toBe('cheap-model'); + expect(child.agent.config.modelAlias).not.toBe(parent.agent.config.modelAlias); + }); + + it('fails the spawn when force is set without a default model', async () => { + await expect( + spawnChild({ + config: { providers: {}, secondaryModel: { force: true } }, + }), + ).rejects.toThrow(/default_model is required when \[secondary_model\]\.force is set/); + }); + it('fails the spawn with a wrapped error when the secondary model does not resolve', async () => { const parent = testAgent(); parent.configure(); From 224fde3aa78a2d70d91591b5dcf4496c881cc18c Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 28 Aug 2026 18:28:31 +0800 Subject: [PATCH 05/12] fix(tower): keep reviewers on primary model --- .../injection/tower-mode-full-reminder.md | 2 +- .../features/tower/tools/spawn/spawnTool.ts | 11 +++++----- .../features/tower/tools/spawnTool.test.ts | 21 ++++++++++++++++++- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md b/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md index 423613aeb8f..a68eff70218 100644 --- a/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md +++ b/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md @@ -25,7 +25,7 @@ Working principles: 1. **Init** — `TowerInit`. It creates `.tower/`, enables the tower tool set, and records the base branch. Workers and reviewers never prompt for tool approvals — they are pinned to the auto permission mode at spawn, whatever the session's mode. Your own orchestration calls still follow the session mode, so if it would interrupt you with constant prompts, tell the human once that a more autonomous mode fits tower better — then proceed regardless. When `TowerInit` reports carried-over open missions from a previous session, settle them **before planning**: continue the ones that belong to the current objective with fresh workers, and abandon the unrelated ones (`TowerMission status=abandoned`) — missions that are neither merged nor abandoned keep their scopes reserved, so `TowerPlan` rejects any new mission overlapping them. 2. **Plan** — break the objective into 2–4 missions and call `TowerPlan` with each mission's title, **disjoint** scope globs (picomatch: `**` crosses directories), tasks, and dependencies. Mark read-only investigation missions `kind: "survey"`: a survey's scope is informational (it reserves nothing, so surveys and builds may overlap the same paths), the worker must not change code, and it closes with a zero-diff `TowerMerge` — no reviewer needed. Shared files (lockfiles, central configs) belong to exactly one build mission or to your own integration work. Post the plan to the human in one compact message and launch immediately — their words are plan changes, never a gate. -3. **Spawn** — one `TowerSpawn` per mission (`kind: "worker"`, background, code-built briefing), and **spawn every dependency-unblocked mission right away**: fire the `TowerSpawn` calls back to back, never trickle them out one at a time and never wait for one worker before launching the next — the fleet exists to run in parallel. The tool refuses duplicate names — resume the existing agent with the `Agent` tool instead. Workers commit on their branch; their completion wakes you. Once the batch is running, **end your turn**: completions and inbox traffic arrive as notifications, so never poll `TowerInbox`/`TowerStatus` in a loop and never sit synchronously waiting on a worker. Workers bind the configured secondary model when the secondary-model experiment is on (they inherit your model otherwise); reviewers always bind your primary model — review quality is not where you save. The resolved model is shown in the spawn output and the `spawn` line of `activity.log`. +3. **Spawn** — one `TowerSpawn` per mission (`kind: "worker"`, background, code-built briefing), and **spawn every dependency-unblocked mission right away**: fire the `TowerSpawn` calls back to back, never trickle them out one at a time and never wait for one worker before launching the next — the fleet exists to run in parallel. The tool refuses duplicate names — resume the existing agent with the `Agent` tool instead. Workers commit on their branch; their completion wakes you. Once the batch is running, **end your turn**: completions and inbox traffic arrive as notifications, so never poll `TowerInbox`/`TowerStatus` in a loop and never sit synchronously waiting on a worker. Workers use the configured secondary model when `[secondary_model]` provides one; otherwise they inherit your model. Reviewers always bind your primary model — review quality is not where you save. The resolved model is shown in the spawn output and the `spawn` line of `activity.log`. 4. **Supervise** — on every wake (worker completion, human message): `TowerInbox` and `TowerStatus`, then act: - Review request → `TowerSpawn` a reviewer (`kind: "reviewer"`, `review_target` the branch). Do not review mission code yourself. Survey missions skip review — close them with `TowerMerge` once their summary lands. - Review verdict not clean → resume the author (Agent tool) pointing at the review file; the author fixes, pushes, and requests re-review. Round cap: at 5 rounds, or when two consecutive rounds report the same findings, stop the loop, inform the human, and redirect (reassign, split, descope). 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 a23aab2d8f4..5a395b9d81d 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 @@ -160,11 +160,12 @@ export class TowerSpawnTool implements ITowerSpawnTool { const binding = own.modelAlias === undefined ? undefined - : resolveSubagentBinding( - this.config, - { modelAlias: own.modelAlias, thinkingLevel: own.thinkingLevel }, - args.kind === 'reviewer' ? 'primary' : undefined, - ); + : args.kind === 'reviewer' + ? { model: own.modelAlias, thinking: own.thinkingLevel } + : resolveSubagentBinding(this.config, { + modelAlias: own.modelAlias, + thinkingLevel: own.thinkingLevel, + }); let handle: SubagentHandle; try { handle = await this.launch(prompt, description, toolCallId, controller, binding); 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 9e64124089b..d4dbb039178 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 @@ -74,7 +74,9 @@ describe('TowerSpawnTool', () => { let runAgent: Mock; let registerTask: Mock; let completion: Deferred<{ readonly summary: string }>; - let secondaryModel: { readonly model: string; readonly defaultEffort?: string } | undefined; + let secondaryModel: + | { readonly model: string; readonly defaultEffort?: string; readonly force?: boolean } + | undefined; let thinkingEnabled: boolean | undefined; let modelMeta: Record>; let createdSetMode: Mock<(mode: PermissionMode) => void>; @@ -398,6 +400,23 @@ describe('TowerSpawnTool', () => { }); }); + it('binds reviewers to the tower model when the secondary model is forced', async () => { + secondaryModel = { model: 'cheap/fast', force: true }; + + const result = await execute({ + name: 'reviewer-a', + kind: 'reviewer', + review_target: 'feat/build-gemm', + }); + + expect(result.isError).toBeUndefined(); + expect(result.output).toContain('model: kimi-code'); + expect(createAgent).toHaveBeenCalledWith({ + binding: { profile: 'tower-worker', model: 'kimi-code', thinking: 'off' }, + labels: { parentAgentId: 'main' }, + }); + }); + it('registers a reviewer without a worktree', async () => { const result = await execute({ name: 'reviewer-a', From ebbfdc13d82664c7199279d6d605080c743130c0 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 31 Aug 2026 11:12:59 +0800 Subject: [PATCH 06/12] fix(secondary-model): live-apply picker saves and replace the legacy model key --- apps/kimi-code/src/tui/commands/config.ts | 15 +++++--- .../test/tui/commands/secondary-model.test.ts | 4 +- packages/agent-core/src/rpc/core-impl.ts | 11 ++++++ .../agent-core/test/harness/runtime.test.ts | 37 +++++++++++++++++++ 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 003ec54b7f3..b477f3dbe0d 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -648,21 +648,26 @@ function showSecondaryModelPicker( } /** - * Persists `[secondary_model] default_model`. When a + * Persists `[secondary_model] default_model` (and the legacy `model` key the + * v1 engine binds from — same alias, so both engines pick it up). 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. + * single-entry pool, so nothing else is written. The v2 engine resolves the + * pool per spawn, and the v1 core live-applies the saved snapshot to every + * live session on config write, so the next subagent dispatch picks the new + * value up on its own. */ async function performSecondaryModelSave(host: SlashCommandHost, alias: string): Promise { const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]); try { const config = await host.harness.getConfig({ reload: true }); const existing = config.secondaryModel?.models; - const patch: { defaultModel: string; models?: Record } = { + // Write both keys: the v1 legacy engine binds `.model`, the v2 pool reads + // `defaultModel` — keeping only one would leave the other engine stale. + const patch: { defaultModel: string; model: string; models?: Record } = { defaultModel: alias, + model: alias, }; if (existing !== undefined) { patch.models = { ...existing, [alias]: existing[alias] ?? '' }; 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..927b8571143 100644 --- a/apps/kimi-code/test/tui/commands/secondary-model.test.ts +++ b/apps/kimi-code/test/tui/commands/secondary-model.test.ts @@ -112,7 +112,7 @@ describe('handleSecondaryModelCommand', () => { expect(host.showStatus).toHaveBeenCalled(); }); expect(host.harness.setConfig).toHaveBeenCalledWith({ - secondaryModel: { defaultModel: 'k2' }, + secondaryModel: { defaultModel: 'k2', model: 'k2' }, }); expect(host.showError).not.toHaveBeenCalled(); }); @@ -134,6 +134,7 @@ describe('handleSecondaryModelCommand', () => { expect(host.harness.setConfig).toHaveBeenCalledWith({ secondaryModel: { defaultModel: 'k2', + model: 'k2', models: { cheap: 'fast and cheap', k2: '' }, }, }); @@ -156,6 +157,7 @@ describe('handleSecondaryModelCommand', () => { expect(host.harness.setConfig).toHaveBeenCalledWith({ secondaryModel: { defaultModel: 'k2', + model: 'k2', models: { cheap: 'fast and cheap', k2: 'hard tasks' }, }, }); diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 34c505f700c..e8a82555b51 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -72,6 +72,7 @@ import { ProviderManager, type BearerTokenProvider, type OAuthTokenProviderResolver } from '../session/provider-manager'; +import { resolveSecondaryModel } from '../session/subagent-binding'; import { SessionAPIImpl } from '../session/rpc'; import { normalizeWorkDir, SessionStore } from '../session/store/index'; import { touchWorkspaceRegistry } from '../session/store/workspace-registry-file'; @@ -2014,6 +2015,16 @@ export class KimiCore implements PromisableMethods { this.config = config; this.experimentalFlags.setConfigOverrides(config.experimental); this.imageLimits.setConfig(config.image); + // Live-apply the secondary-model snapshot to every live session, so a + // config save (e.g. a `/secondary-model` pick) takes effect immediately + // instead of waiting for the next session create. Skip when nothing is + // configured: the snapshot setter only accepts a persisted recipe. + const sessionConfig = this.withPrintModeDefaults(config); + if (resolveSecondaryModel(sessionConfig) !== undefined) { + for (const session of this.sessions.values()) { + session.setSecondaryModelConfig(sessionConfig); + } + } return this.config; } diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts index 65341584eb9..ae2b5540a41 100644 --- a/packages/agent-core/test/harness/runtime.test.ts +++ b/packages/agent-core/test/harness/runtime.test.ts @@ -266,6 +266,43 @@ micro_compaction = false expect(config?.models?.['__secondary__']?.overrides?.maxContextSize).toBe(65_536); }); + it('live-applies the persisted secondary recipe on config write without an explicit apply', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + const created = await rpc.createSession({ + id: 'ses_runtime_secondary_auto_apply', + workDir, + model: 'default-mock', + }); + + await rpc.setKimiConfig({ + secondaryModel: { + model: 'default-mock', + maxContextSize: 65_536, + }, + }); + + const config = core.sessions.get(created.id)?.getReadyAgent('main')?.kimiConfig; + expect(config?.secondaryModel).toEqual({ + model: 'default-mock', + maxContextSize: 65_536, + }); + expect(config?.models?.['__secondary__']?.overrides?.maxContextSize).toBe(65_536); + }); + // Regression for https://github.com/MoonshotAI/kimi-code/issues/988: during // ACP `session/new` the tool kaos is the reverse-RPC bridge and the client // does not know the session yet, so reading `.kimi-code/local.toml` through From cac5fccdd61abd6c1a03696d5bc6d287e2219f5e Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 31 Aug 2026 11:20:31 +0800 Subject: [PATCH 07/12] fix(secondary-model): accept default_model in the live-apply setter and clear on removal --- packages/agent-core/src/rpc/core-impl.ts | 11 ++- packages/agent-core/src/session/index.ts | 28 +++++--- .../agent-core/test/harness/runtime.test.ts | 69 +++++++++++++++++++ packages/agent-core/test/session/init.test.ts | 9 ++- 4 files changed, 96 insertions(+), 21 deletions(-) diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index e8a82555b51..cbceb6d4b7a 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -72,7 +72,6 @@ import { ProviderManager, type BearerTokenProvider, type OAuthTokenProviderResolver } from '../session/provider-manager'; -import { resolveSecondaryModel } from '../session/subagent-binding'; import { SessionAPIImpl } from '../session/rpc'; import { normalizeWorkDir, SessionStore } from '../session/store/index'; import { touchWorkspaceRegistry } from '../session/store/workspace-registry-file'; @@ -2017,13 +2016,11 @@ export class KimiCore implements PromisableMethods { this.imageLimits.setConfig(config.image); // Live-apply the secondary-model snapshot to every live session, so a // config save (e.g. a `/secondary-model` pick) takes effect immediately - // instead of waiting for the next session create. Skip when nothing is - // configured: the snapshot setter only accepts a persisted recipe. + // instead of waiting for the next session create. The setter also clears + // the snapshot when the section is removed. const sessionConfig = this.withPrintModeDefaults(config); - if (resolveSecondaryModel(sessionConfig) !== undefined) { - for (const session of this.sessions.values()) { - session.setSecondaryModelConfig(sessionConfig); - } + for (const session of this.sessions.values()) { + session.setSecondaryModelConfig(sessionConfig); } return this.config; } diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 21bd423fdf7..d03d0284e9f 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -900,7 +900,9 @@ export class Session { * for the next subagent spawn without recreating the session. The core owns * config reload, environment overlays, and derived-model synthesis. Copying * that complete recipe and its model entries keeps spawn binding and provider - * resolution aligned without live-applying unrelated session settings. + * resolution aligned without live-applying unrelated session settings. An + * absent `[secondary_model]` clears the snapshot, so removing the section + * disables the pool for live sessions too. */ setSecondaryModelConfig(config: KimiConfig): void { const base = this.runtimeConfig; @@ -911,23 +913,27 @@ export class Session { ); } const secondary = config.secondaryModel; - if (secondary?.model === undefined) { - throw new KimiError( + const secondaryAlias = secondaryModelAlias(secondary); + if (secondary !== undefined && secondaryAlias === undefined) { throw new KimiError( ErrorCodes.CONFIG_INVALID, 'Cannot set the secondary model: persist its recipe before applying it to a session.', ); } - try { - this.options.providerManager?.resolveProviderConfig(secondary.model); - } catch (error) { - throw wrapSubagentModelError(error, secondary.model, undefined); + if (secondaryAlias !== undefined) { + try { + this.options.providerManager?.resolveProviderConfig(secondaryAlias); + } catch (error) { + throw wrapSubagentModelError(error, secondaryAlias, undefined); + } } const models = { ...base.models }; delete models[SECONDARY_DERIVED_MODEL_ALIAS]; - const pointedModel = config.models?.[secondary.model]; - if (pointedModel !== undefined) models[secondary.model] = pointedModel; - const derivedModel = config.models?.[SECONDARY_DERIVED_MODEL_ALIAS]; - if (derivedModel !== undefined) models[SECONDARY_DERIVED_MODEL_ALIAS] = derivedModel; + if (secondaryAlias !== undefined) { + const pointedModel = config.models?.[secondaryAlias]; + if (pointedModel !== undefined) models[secondaryAlias] = pointedModel; + const derivedModel = config.models?.[SECONDARY_DERIVED_MODEL_ALIAS]; + if (derivedModel !== undefined) models[SECONDARY_DERIVED_MODEL_ALIAS] = derivedModel; + } const next = { ...base, models, secondaryModel: secondary }; this.runtimeConfig = next; this.secondaryModelWarnings = undefined; diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts index ae2b5540a41..b36171b6f7f 100644 --- a/packages/agent-core/test/harness/runtime.test.ts +++ b/packages/agent-core/test/harness/runtime.test.ts @@ -303,6 +303,75 @@ micro_compaction = false expect(config?.models?.['__secondary__']?.overrides?.maxContextSize).toBe(65_536); }); + it('live-applies a v2-style default_model-only recipe on config write', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + const created = await rpc.createSession({ + id: 'ses_runtime_secondary_default_model', + workDir, + model: 'default-mock', + }); + + await rpc.setKimiConfig({ + secondaryModel: { defaultModel: 'default-mock' }, + }); + + const config = core.sessions.get(created.id)?.getReadyAgent('main')?.kimiConfig; + expect(config?.secondaryModel).toEqual({ defaultModel: 'default-mock' }); + }); + + it('clears the live secondary model when the section is removed on reload', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + const created = await rpc.createSession({ + id: 'ses_runtime_secondary_clear', + workDir, + model: 'default-mock', + }); + + await rpc.setKimiConfig({ + secondaryModel: { defaultModel: 'default-mock' }, + }); + expect( + core.sessions.get(created.id)?.getReadyAgent('main')?.kimiConfig?.secondaryModel, + ).toEqual({ defaultModel: 'default-mock' }); + + // The user removes [secondary_model] from config.toml directly, then the + // next reload clears it from the live session too. + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + await rpc.getKimiConfig({ reload: true }); + + expect( + core.sessions.get(created.id)?.getReadyAgent('main')?.kimiConfig?.secondaryModel, + ).toBeUndefined(); + }); + // Regression for https://github.com/MoonshotAI/kimi-code/issues/988: during // ACP `session/new` the tool kaos is the reverse-RPC bridge and the client // does not know the session yet, so reading `.kimi-code/local.toml` through diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index 6738b27315e..e15addad54d 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -871,10 +871,13 @@ describe('Session secondary-model live config', () => { } }); - it('rejects when the complete config has no persisted secondary recipe', async () => { - const session = await makeSession(SECONDARY_BASE_CONFIG); + it('clears the snapshot when the complete config has no persisted secondary recipe', async () => { + const session = await makeSession(SECONDARY_POINTER_CONFIG); try { - expect(() => session.setSecondaryModelConfig(SECONDARY_BASE_CONFIG)).toThrow(/persist/); + session.setSecondaryModelConfig(SECONDARY_POINTER_CONFIG); + expect(session.kimiConfig?.secondaryModel).toEqual({ model: MOCK_PROVIDER.model }); + session.setSecondaryModelConfig(SECONDARY_BASE_CONFIG); + expect(session.kimiConfig?.secondaryModel).toBeUndefined(); } finally { await session.close(); } From 77c04fb386aefff3adf43d7af3cddd8c5ceb2b2d Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 31 Aug 2026 11:56:16 +0800 Subject: [PATCH 08/12] fix(secondary-model): revert the legacy v1 engine, keep the v2 pool opt-out --- .changeset/secondary-model-default-on.md | 2 +- apps/kimi-code/src/tui/commands/config.ts | 15 +-- apps/kimi-code/src/tui/commands/registry.ts | 1 + .../test/tui/commands/registry.test.ts | 4 +- .../test/tui/commands/secondary-model.test.ts | 4 +- docs/en/configuration/config-files.md | 2 + docs/en/configuration/env-vars.md | 1 + docs/en/reference/slash-commands.md | 2 +- docs/zh/configuration/config-files.md | 2 + docs/zh/configuration/env-vars.md | 1 + docs/zh/reference/slash-commands.md | 2 +- .../src/agent/tools/agent/agentTool.ts | 3 +- .../skill/catalog/builtin/update-config.md | 2 +- .../swarm/tools/agent-swarm/agentSwarmTool.ts | 3 +- .../features/tower/tools/spawn/spawnTool.ts | 11 +- .../src/session/subagent/configSection.ts | 20 +++- .../src/session/subagent/flag.ts | 15 +++ .../subagentModelsValidationService.ts | 4 +- .../src/session/subagent/subagentService.ts | 3 + .../sessionLifecycleService.ts | 4 +- .../workspaceInstanceManagerService.ts | 3 + .../test/app/config/config.test.ts | 33 +++++- .../test/features/swarm/swarm.test.ts | 1 + .../features/tower/tools/spawnTool.test.ts | 2 + .../test/session/subagent/spawn.test.ts | 1 + .../subagent/subagentModelsValidation.test.ts | 9 +- .../workspaceInstanceManager.test.ts | 4 +- packages/agent-core/src/agent/tool/index.ts | 19 +--- .../agent-core/src/config/secondary-model.ts | 14 +-- packages/agent-core/src/flags/registry.ts | 9 ++ packages/agent-core/src/rpc/core-impl.ts | 8 -- packages/agent-core/src/session/index.ts | 36 +++--- .../src/session/subagent-binding.ts | 47 ++++---- .../agent-core/src/session/subagent-host.ts | 66 ++++------- .../builtin/collaboration/agent-swarm.ts | 6 +- .../src/tools/builtin/collaboration/agent.ts | 6 +- packages/agent-core/test/agent/basic.test.ts | 4 +- packages/agent-core/test/agent/tool.test.ts | 57 +--------- .../agent-core/test/harness/runtime.test.ts | 107 +----------------- packages/agent-core/test/session/init.test.ts | 13 +-- .../test/session/subagent-host.test.ts | 107 ++++++++++-------- packages/node-sdk/test/config.test.ts | 11 ++ 42 files changed, 282 insertions(+), 382 deletions(-) diff --git a/.changeset/secondary-model-default-on.md b/.changeset/secondary-model-default-on.md index 7e2b071d77e..4e635966e1f 100644 --- a/.changeset/secondary-model-default-on.md +++ b/.changeset/secondary-model-default-on.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -The subagent model pool (`[secondary_model]`) now works without the removed `secondary-model` experiment — subagents bind the pool's `default_model`, and the `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` env var no longer has any effect. +The subagent model pool (`[secondary_model]`) is enabled by default in every launch mode and remains opt-out via `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=0` or `[experimental] secondary-model = false`. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index b477f3dbe0d..003ec54b7f3 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -648,26 +648,21 @@ function showSecondaryModelPicker( } /** - * Persists `[secondary_model] default_model` (and the legacy `model` key the - * v1 engine binds from — same alias, so both engines pick it up). When a + * 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. The v2 engine resolves the - * pool per spawn, and the v1 core live-applies the saved snapshot to every - * live session on config write, so the next subagent dispatch picks the new - * value up on its own. + * 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. */ async function performSecondaryModelSave(host: SlashCommandHost, alias: string): Promise { const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]); try { const config = await host.harness.getConfig({ reload: true }); const existing = config.secondaryModel?.models; - // Write both keys: the v1 legacy engine binds `.model`, the v2 pool reads - // `defaultModel` — keeping only one would leave the other engine stale. - const patch: { defaultModel: string; model: string; models?: Record } = { + const patch: { defaultModel: string; models?: Record } = { defaultModel: alias, - model: alias, }; if (existing !== undefined) { patch.models = { ...existing, [alias]: existing[alias] ?? '' }; diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 0bc558299dd..67e7cd74d94 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -216,6 +216,7 @@ export const BUILTIN_SLASH_COMMANDS = [ description: 'Configure the secondary model for subagents', priority: 90, availability: 'always', + experimentalFlag: 'secondary-model', }, { name: 'effort', diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index 4e7ee590136..1113a5998e3 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -209,10 +209,10 @@ describe('built-in slash command registry', () => { expect(resolveSlashCommandAvailability(reloadTui!, '')).toBe('always'); }); - it('registers secondary-model unconditionally, always available', () => { + it('gates secondary-model behind the secondary-model experiment, always available', () => { const command = findBuiltInSlashCommand('secondary-model'); expect(command).toBeDefined(); - expect((command as KimiSlashCommand).experimentalFlag).toBeUndefined(); + expect((command as KimiSlashCommand).experimentalFlag).toBe('secondary-model'); expect(resolveSlashCommandAvailability(command!, '')).toBe('always'); }); 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 927b8571143..9bce58d4c0f 100644 --- a/apps/kimi-code/test/tui/commands/secondary-model.test.ts +++ b/apps/kimi-code/test/tui/commands/secondary-model.test.ts @@ -112,7 +112,7 @@ describe('handleSecondaryModelCommand', () => { expect(host.showStatus).toHaveBeenCalled(); }); expect(host.harness.setConfig).toHaveBeenCalledWith({ - secondaryModel: { defaultModel: 'k2', model: 'k2' }, + secondaryModel: { defaultModel: 'k2' }, }); expect(host.showError).not.toHaveBeenCalled(); }); @@ -134,7 +134,6 @@ describe('handleSecondaryModelCommand', () => { expect(host.harness.setConfig).toHaveBeenCalledWith({ secondaryModel: { defaultModel: 'k2', - model: 'k2', models: { cheap: 'fast and cheap', k2: '' }, }, }); @@ -157,7 +156,6 @@ describe('handleSecondaryModelCommand', () => { expect(host.harness.setConfig).toHaveBeenCalledWith({ secondaryModel: { defaultModel: 'k2', - model: 'k2', models: { cheap: 'fast and cheap', k2: 'hard tasks' }, }, }); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index eb85e53fc96..e10a736b1c8 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -198,6 +198,8 @@ Subagents inherit the model the main agent is running by default. The `[secondar Configured values take effect in every launch mode, including the interactive TUI. +The pool is enabled by default in every launch mode, including the interactive TUI. To disable it, set `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=0` (or `secondary-model = false` under `[experimental]` in `config.toml`); while disabled, the pool keys stay inert: subagents inherit the caller's model and session startup skips the pool validation. + The minimal configuration is one line — a lone `default_model` is a pool with a single entry: ```toml diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 76b642cb21f..a45f60ac978 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -150,6 +150,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_CODE_IDENTITY_SLUG` | Protocol identifier for the `User-Agent` product token sent to third-party providers and the MCP client name; takes higher priority than `[identity] slug`. Derived from the name when unset | Any non-empty string; normalized to lowercase with non-alphanumeric runs folded to `-` | | `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` | Whether the built-in skills documenting Kimi Code itself are offered to the model; takes higher priority than `builtin_product_skills` in `config.toml` (default enabled) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_TUI_FULL_SCREEN` | Enable the experimental fullscreen alternate-screen UI: scrollable transcript viewport, mouse text selection, clickable links, and Ctrl-Shift-F transcript search | `1` enables it; anything else keeps the regular inline UI | +| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | The [subagent model pool](./config-files.md#subagent-model-pool) is enabled by default in every launch mode, including the interactive TUI; set a falsy value to disable it; the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` force-enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK` | Enable the experimental `fork` parameter on the `Agent` and `AgentSwarm` tools, letting the model start a subagent with a snapshot of the calling agent's conversation history instead of an empty context; the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | Global default connection timeout (ms) for all MCP servers; takes higher priority than `[mcp] startup_timeout_ms` in `config.toml`, but a per-server `startupTimeoutMs` in `mcp.json` still wins (default `30000`) | Integer from `1` to `2147483647`; invalid values are ignored | | `KIMI_MCP_TOOL_TIMEOUT_MS` | Global default single tool-call timeout (ms) for all MCP servers; takes higher priority than `[mcp] tool_timeout_ms` in `config.toml`, but a per-server `toolTimeoutMs` in `mcp.json` still wins (default `60000`) | Integer from `1` to `2147483647`; invalid values are ignored | diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 860e75ff06f..b137b1a0396 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)) | 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 | | `/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 3c81c0624e6..c09289c0218 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -198,6 +198,8 @@ subagent 默认继承 main agent 正在运行的模型。`[secondary_model]` 节 配置后在包括交互式 TUI 在内的所有启动方式下生效。 +模型池默认启用,在包括交互式 TUI 在内的所有启动方式下生效。如需禁用,设置 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=0`(或在 `config.toml` 的 `[experimental]` 下配置 `secondary-model = false`);禁用期间模型池配置不生效:subagent 继承调用方模型,会话启动也会跳过池校验。 + 最小配置只有一行——单独写下的 `default_model` 就是只含一个条目的模型池: ```toml diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 4ff69fc3226..20b5e890da8 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -150,6 +150,7 @@ kimi | `KIMI_CODE_IDENTITY_SLUG` | 协议标识,用于发给第三方 provider 的 `User-Agent` 产品名和 MCP 客户端名,优先级高于 `[identity] slug`。未设置时由名称派生 | 任意非空字符串;会转小写并将连续非字母数字字符折叠为 `-` | | `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` | 是否向模型提供介绍 Kimi Code 自身的内置 Skills,优先级高于 `config.toml` 的 `builtin_product_skills`(默认开启) | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_TUI_FULL_SCREEN` | 启用实验性的 fullscreen alternate-screen 界面:可滚动的 transcript 视口、鼠标选择文本、可点击链接、Ctrl-Shift-F 搜索 | `1` 开启;其他值保持常规内联界面 | +| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | [subagent 模型池](./config-files.md#subagent-模型池)默认启用,在包括交互式 TUI 在内的所有启动方式下生效;设为假值可禁用;master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 会强制启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK` | 在 `Agent` 和 `AgentSwarm` 工具上启用实验性的 `fork` 参数,让模型可以以调用方 Agent 对话历史的快照而不是空上下文启动 subagent;master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | 所有 MCP server 的全局默认连接超时(毫秒);优先级高于 `config.toml` 的 `[mcp] startup_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `startupTimeoutMs`(默认 `30000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | | `KIMI_MCP_TOOL_TIMEOUT_MS` | 所有 MCP server 的全局默认单次工具调用超时(毫秒);优先级高于 `config.toml` 的 `[mcp] tool_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `toolTimeoutMs`(默认 `60000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 3dd5a3944bd..a3a99ae25d1 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-模型池)) | 是 | +| `/secondary-model` | `/subagent-model` | 选择 subagent 的默认模型(写入 `[secondary_model] default_model`,详见[subagent 模型池](../configuration/config-files.md#subagent-模型池))。subagent 模型池被禁用时不显示 | 是 | | `/settings` | `/config` | 打开 TUI 内的设置面板 | 是 | | `/experiments` | `/experimental` | 打开实验功能面板 | 是 | | `/permission` | — | 选择权限模式 | 是 | 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 13522486561..b68ad9fb712 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -84,7 +84,7 @@ export class SubagentTool implements ISubagentTool { readonly name: string = 'Agent'; get parameters(): Record { - const parameters = exposesSubagentModelChoice(this.config) + const parameters = exposesSubagentModelChoice(this.config, this.flags) ? SUBAGENT_TOOL_PARAMETERS : SUBAGENT_TOOL_PARAMETERS_NO_MODEL; return this.flags.enabled(SUBAGENT_FORK_FLAG_ID) @@ -148,6 +148,7 @@ export class SubagentTool implements ISubagentTool { } const modelLines = buildSubagentModelDescriptions( this.config, + this.flags, this.profile.data().modelAlias, ); if (modelLines !== undefined) { diff --git a/packages/agent-core-v2/src/features/skill/catalog/builtin/update-config.md b/packages/agent-core-v2/src/features/skill/catalog/builtin/update-config.md index 749c7c438d9..b58eefb99f9 100644 --- a/packages/agent-core-v2/src/features/skill/catalog/builtin/update-config.md +++ b/packages/agent-core-v2/src/features/skill/catalog/builtin/update-config.md @@ -20,7 +20,7 @@ echo "$HOME/.kimi-code" Use the first line when it is non-empty; otherwise use the second line. In the rest of this skill, `` means that resolved root — **never assume `~/.kimi-code`**. -- **`config.toml`** — agent / runtime settings: `default_model`, `[secondary_model]` (subagent model pool: `default_model` / `[secondary_model.models]` / `force` to pin subagents to `default_model`; a lone legacy v1 `model` key is honored as a fallback default), `[subagent]` (`timeout_ms`), `providers`, `models`, `thinking`, `permission`, `hooks`, `loop_control`, etc. +- **`config.toml`** — agent / runtime settings: `default_model`, `[secondary_model]` (subagent model pool: `default_model` / `[secondary_model.models]` / `force` to pin subagents to `default_model`; enabled by default, disable with `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=0`; a lone legacy v1 `model` key is honored as a fallback default), `[subagent]` (`timeout_ms`), `providers`, `models`, `thinking`, `permission`, `hooks`, `loop_control`, etc. - **`tui.toml`** — terminal-UI / client preferences: `theme`, `[editor].command`, `[notifications]`, `[upgrade].auto_install` (auto-update). These can usually also be changed with the interactive commands `/config`, `/theme`, `/editor`, which is easier — prefer pointing the user at those. The "read → copy → Edit → validate → back up → overwrite" flow below applies to both files; only **which reload command applies** differs (see Capability 4). 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 d530dbdf788..997fd27549e 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,7 @@ export class AgentSwarmTool implements IAgentSwarmTool { readonly name = 'AgentSwarm' as const; get parameters(): Record { - const parameters = exposesSubagentModelChoice(this.config) + const parameters = exposesSubagentModelChoice(this.config, this.flags) ? AGENT_SWARM_PARAMETERS : AGENT_SWARM_PARAMETERS_NO_MODEL; return this.flags.enabled(SUBAGENT_FORK_FLAG_ID) @@ -102,6 +102,7 @@ export class AgentSwarmTool implements IAgentSwarmTool { } const modelLines = buildSubagentModelDescriptions( this.config, + this.flags, this.profile.data().modelAlias, ); 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 5a395b9d81d..4931ea7fd3a 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 @@ -21,6 +21,7 @@ import { import { IAgentTowerService, TOWER_WORKER_PROFILE } from '#/features/tower/tower'; import { ITowerRateLimitService } from '#/features/tower/towerRateLimit'; import { IConfigService } from '#/app/config/config'; +import { IFlagService } from '#/app/flag/flag'; import { IModelCatalog } from '#/kosong/model/catalog'; import { toInputJsonSchema } from '#/tool/input-schema'; import { @@ -66,6 +67,7 @@ export class TowerSpawnTool implements ITowerSpawnTool { @IAgentTaskService private readonly tasks: IAgentTaskService, @IAgentProfileService private readonly profile: IAgentProfileService, @IConfigService private readonly config: IConfigService, + @IFlagService private readonly flags: IFlagService, @IModelCatalog private readonly modelCatalog: IModelCatalog, ) { this.callerAgentId = scopeContext.agentId; @@ -162,10 +164,11 @@ export class TowerSpawnTool implements ITowerSpawnTool { ? undefined : args.kind === 'reviewer' ? { model: own.modelAlias, thinking: own.thinkingLevel } - : resolveSubagentBinding(this.config, { - modelAlias: own.modelAlias, - thinkingLevel: own.thinkingLevel, - }); + : resolveSubagentBinding( + this.config, + this.flags, + { modelAlias: own.modelAlias, thinkingLevel: own.thinkingLevel }, + ); let handle: SubagentHandle; try { handle = await this.launch(prompt, description, toolCallId, controller, binding); diff --git a/packages/agent-core-v2/src/session/subagent/configSection.ts b/packages/agent-core-v2/src/session/subagent/configSection.ts index 739d37b6466..d453e933fee 100644 --- a/packages/agent-core-v2/src/session/subagent/configSection.ts +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { Error2, ErrorCodes, isError2 } from '#/errors'; import { isPlainObject } from '#/app/config/toml'; +import type { IFlagService } from '#/app/flag/flag'; import { type EnvBindings, envBindings, @@ -16,6 +17,8 @@ import { type ThinkingConfig, } from '#/kosong/model/thinking'; +import { SECONDARY_MODEL_FLAG_ID } from './flag'; + export const SUBAGENT_SECTION = 'subagent'; export const SECONDARY_MODEL_SECTION = 'secondaryModel'; @@ -108,7 +111,11 @@ export function isSubagentModelForced(config: IConfigService): boolean { return config.get(SECONDARY_MODEL_SECTION)?.force === true; } -export function exposesSubagentModelChoice(config: IConfigService): boolean { +export function exposesSubagentModelChoice( + config: IConfigService, + flags: IFlagService, +): boolean { + if (!flags.enabled(SECONDARY_MODEL_FLAG_ID)) return false; if (isSubagentModelForced(config)) return false; return resolveSubagentModelPool(config) !== undefined; } @@ -159,8 +166,10 @@ export function assertValidSubagentModelPool( export function assertValidSubagentModelConfig( config: IConfigService, + flags: IFlagService, modelCatalog: IModelCatalog, ): void { + if (!flags.enabled(SECONDARY_MODEL_FLAG_ID)) return; const section = config.get(SECONDARY_MODEL_SECTION); if (section?.force === true) { if (section.models !== undefined) { @@ -180,11 +189,13 @@ export function assertValidSubagentModelConfig( export function resolveSubagentBinding( config: IConfigService, + flags: IFlagService, own: { modelAlias: string; thinkingLevel: string }, requested?: string, ): { model: string; thinking?: string } { const section = config.get(SECONDARY_MODEL_SECTION); - if (section?.force === true) { + const enabled = flags.enabled(SECONDARY_MODEL_FLAG_ID); + if (enabled && section?.force === true) { if (section.models !== undefined) { throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_FORCE_EXCLUDES_MODELS_MESSAGE, { details: { section: SECONDARY_MODEL_SECTION, field: 'force' }, @@ -208,7 +219,7 @@ export function resolveSubagentBinding( if (requested === PRIMARY_SUBAGENT_MODEL_CHOICE) { return { model: own.modelAlias, thinking: own.thinkingLevel }; } - const pool = resolveSubagentModelPool(config); + const pool = enabled ? resolveSubagentModelPool(config) : undefined; if (pool === undefined) { if (requested !== undefined) { throw new Error2( @@ -257,9 +268,10 @@ export function resolveSubagentThinking( export function buildSubagentModelDescriptions( config: IConfigService, + flags: IFlagService, callerModelAlias: string | undefined, ): string | undefined { - if (!exposesSubagentModelChoice(config)) return undefined; + if (!exposesSubagentModelChoice(config, flags)) return undefined; const pool = resolveSubagentModelPool(config)!; const lines = ['Available models (pass via model):']; const defaultModel = pool.defaultModel; diff --git a/packages/agent-core-v2/src/session/subagent/flag.ts b/packages/agent-core-v2/src/session/subagent/flag.ts index 1bcbc8f741b..e5a805e1eb6 100644 --- a/packages/agent-core-v2/src/session/subagent/flag.ts +++ b/packages/agent-core-v2/src/session/subagent/flag.ts @@ -1,5 +1,20 @@ import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; +export const SECONDARY_MODEL_FLAG_ID = 'secondary-model'; +export const SECONDARY_MODEL_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL'; + +export const secondaryModelFlag: FlagDefinitionInput = { + id: SECONDARY_MODEL_FLAG_ID, + title: 'Secondary model for subagents', + description: + 'Let newly spawned subagents use a separately configured secondary model by default, with an explicit primary-model override for quality-sensitive tasks.', + env: SECONDARY_MODEL_FLAG_ENV, + default: true, + surface: 'core', +}; + +registerFlagDefinition(secondaryModelFlag); + export const SUBAGENT_FORK_FLAG_ID = 'subagent_fork'; export const SUBAGENT_FORK_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK'; diff --git a/packages/agent-core-v2/src/session/subagent/subagentModelsValidationService.ts b/packages/agent-core-v2/src/session/subagent/subagentModelsValidationService.ts index a23cf0b97dc..cd19ad03951 100644 --- a/packages/agent-core-v2/src/session/subagent/subagentModelsValidationService.ts +++ b/packages/agent-core-v2/src/session/subagent/subagentModelsValidationService.ts @@ -1,6 +1,7 @@ import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IConfigService } from '#/app/config/config'; +import { IFlagService } from '#/app/flag/flag'; import { IModelCatalog } from '#/kosong/model/catalog'; import { assertValidSubagentModelConfig } from './configSection'; @@ -13,9 +14,10 @@ export class SessionSubagentModelsValidationService constructor( @IConfigService config: IConfigService, + @IFlagService flags: IFlagService, @IModelCatalog modelCatalog: IModelCatalog, ) { - assertValidSubagentModelConfig(config, modelCatalog); + assertValidSubagentModelConfig(config, flags, modelCatalog); } } diff --git a/packages/agent-core-v2/src/session/subagent/subagentService.ts b/packages/agent-core-v2/src/session/subagent/subagentService.ts index 881fd441425..f97b6c71866 100644 --- a/packages/agent-core-v2/src/session/subagent/subagentService.ts +++ b/packages/agent-core-v2/src/session/subagent/subagentService.ts @@ -23,6 +23,7 @@ import { IAgentUserToolService } from '#/agent/userTool/userTool'; import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; import type { Runtime } from '#/runtime/runtime'; import { IConfigService } from '#/app/config/config'; +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'; @@ -70,6 +71,7 @@ export class SessionSubagentService extends Service implements ISessionSubagentS @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, @ISessionAgentProfileCatalog private readonly catalog: ISessionAgentProfileCatalog, @IConfigService private readonly configService: IConfigService, + @IFlagService private readonly flags: IFlagService, @IModelCatalog private readonly modelCatalog: IModelCatalog, @ISessionContext private readonly sessionContext: ISessionContext, @ILogService private readonly log: ILogService, @@ -131,6 +133,7 @@ export class SessionSubagentService extends Service implements ISessionSubagentS ? { model: own.modelAlias, thinking: own.thinkingLevel } : resolveSubagentBinding( this.configService, + this.flags, { modelAlias: own.modelAlias, thinkingLevel: own.thinkingLevel }, input.model, ); diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 24a90f2df48..a6b2ac436e3 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -60,6 +60,7 @@ import { repairWireJournal } from '#/wire/repair'; import { IModelCatalog } from '#/kosong/model/catalog'; import { IModelService } from '#/kosong/model/model'; import { IProviderService } from '#/kosong/provider/provider'; +import { IFlagService } from '#/app/flag/flag'; import { assertValidSubagentModelConfig } from '#/session/subagent/configSection'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; @@ -172,6 +173,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec @IModelCatalog private readonly modelCatalog: IModelCatalog, @IModelService private readonly models: IModelService, @IProviderService private readonly providers: IProviderService, + @IFlagService private readonly flags: IFlagService, onDispose?: () => void, ) { super(); @@ -224,7 +226,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private async assertSubagentModelPoolPreFlight(): Promise { await Promise.all([this.config.ready, this.models.ready, this.providers.ready]); - assertValidSubagentModelConfig(this.config, this.modelCatalog); + assertValidSubagentModelConfig(this.config, this.flags, this.modelCatalog); } private async materializeSession(opts: MaterializeSessionOptions): Promise { diff --git a/packages/agent-core-v2/src/workspace/workspaceInstance/workspaceInstanceManagerService.ts b/packages/agent-core-v2/src/workspace/workspaceInstance/workspaceInstanceManagerService.ts index ec4c3a13562..8c4466fa05d 100644 --- a/packages/agent-core-v2/src/workspace/workspaceInstance/workspaceInstanceManagerService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceInstance/workspaceInstanceManagerService.ts @@ -8,6 +8,7 @@ import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileReg import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { IEventService } from '#/app/event/event'; +import { IFlagService } from '#/app/flag/flag'; import { IGitService } from '#/app/git/git'; import { IMcpOAuthService } from '#/app/mcpConfig/oauthService'; import type { McpOAuthService } from '#/mcpCore/oauth/service'; @@ -57,6 +58,7 @@ export class WorkspaceInstanceManager implements IWorkspaceInstanceManager { @IAppStateService private readonly appState: IAppStateService, @IConfigService private readonly config: IConfigService, @IEventService private readonly event: IEventService, + @IFlagService private readonly flags: IFlagService, @ref(IGitService) private readonly git: LiveRef, @IAgentIdentity private readonly identity: IAgentIdentity, @ISessionIndex private readonly index: ISessionIndex, @@ -242,6 +244,7 @@ export class WorkspaceInstanceManager implements IWorkspaceInstanceManager { this.modelCatalog, this.models, this.modelProviders, + this.flags, input.onDispose, ), }, diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index cf560dd236e..439b6be3f1c 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -83,7 +83,7 @@ import { applyPrintModeConfigDefaults } from '#/agent/task/printDefaults'; import '#/session/subagent/configSection'; import { DEFAULT_SUBAGENT_TIMEOUT_MS, - resolveSubagentBinding, + resolveSubagentBinding as resolveSubagentBindingWithFlags, resolveSubagentModelPool, resolveSubagentTimeoutMs, SECONDARY_MODEL_SECTION, @@ -93,6 +93,7 @@ import { type SubagentConfig, wrapSubagentModelError, } from '#/session/subagent/configSection'; +import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; import { DEFAULT_SWARM_TIMEOUT_MS, resolveSwarmTimeoutMs, @@ -125,6 +126,18 @@ import { stubBootstrap } from '../bootstrap/stubs'; import { stubLog } from '../../_base/log/stubs'; import { stubFlag } from '../flag/stubs'; +function secondaryModelFlags(enabled = true) { + return stubFlag((id) => enabled && id === SECONDARY_MODEL_FLAG_ID); +} + +function resolveSubagentBinding( + config: IConfigService, + own: { modelAlias: string; thinkingLevel: string }, + requested?: string, +) { + return resolveSubagentBindingWithFlags(config, secondaryModelFlags(), own, requested); +} + const TEST_OS_ENV = { osKind: 'Linux', osArch: 'x86_64', @@ -1895,6 +1908,24 @@ describe('subagent config section', () => { pool.disposables.dispose(); }); + it('keeps the configured pool inert when secondary-model is disabled', async () => { + const own = { modelAlias: 'provider/main', thinkingLevel: 'medium' }; + const { config, disposables } = await createConfig( + {}, + '[secondary_model]\ndefault_model = "provider/fast"\nforce = true\n\n[secondary_model.models]\n"provider/fast" = "fast and cheap"\n', + ); + + expect(resolveSubagentBindingWithFlags(config, secondaryModelFlags(false), own)).toEqual({ + model: 'provider/main', + thinking: 'medium', + }); + expect(() => + resolveSubagentBindingWithFlags(config, secondaryModelFlags(false), own, 'provider/fast'), + ).toThrow(/no \[secondary_model\.models\] pool is configured/); + + disposables.dispose(); + }); + it('treats a pool-less default_model as an implicit single-entry pool', async () => { const own = { modelAlias: 'provider/main', thinkingLevel: 'medium' }; const { config, disposables } = await createConfig( 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 17ef064444e..5d39562d66b 100644 --- a/packages/agent-core-v2/test/features/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/features/swarm/swarm.test.ts @@ -266,6 +266,7 @@ function realSubagents( agentLifecycle, catalog, config, + stubFlag(true), modelCatalog, sessionContext, stubLog(), 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 d4dbb039178..c8e668ff449 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 @@ -41,6 +41,7 @@ import type { ExecutableToolResult } from '#/tool/toolContract'; import { executeTool } from '../../../tools/fixtures/execute-tool'; import { stubAgentContext } from '../../../agent/agentContext/stubs'; +import { stubFlag } from '../../../app/flag/stubs'; const execFileAsync = promisify(execFile); const signal = new AbortController().signal; @@ -118,6 +119,7 @@ describe('TowerSpawnTool', () => { disposables = new DisposableStore(); ix = disposables.add(new TestInstantiationService()); + ix.stub(IFlagService, stubFlag(true)); ix.set(IEventBus, new SyncDescriptor(EventBusService)); ix.stub(IAgentTowerService, { get isActive() { 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 7a2b8768d4c..a8b500e7ad9 100644 --- a/packages/agent-core-v2/test/session/subagent/spawn.test.ts +++ b/packages/agent-core-v2/test/session/subagent/spawn.test.ts @@ -101,6 +101,7 @@ describe('SessionSubagentService planSpawn and spawn', () => { beforeEach(() => { disposables = new DisposableStore(); ix = disposables.add(new TestInstantiationService()); + ix.stub(IFlagService, stubFlag(true)); callerData = { profileName: 'orchestrator', modelAlias: 'main-model', diff --git a/packages/agent-core-v2/test/session/subagent/subagentModelsValidation.test.ts b/packages/agent-core-v2/test/session/subagent/subagentModelsValidation.test.ts index 2e1073ea0e1..17e0d789049 100644 --- a/packages/agent-core-v2/test/session/subagent/subagentModelsValidation.test.ts +++ b/packages/agent-core-v2/test/session/subagent/subagentModelsValidation.test.ts @@ -11,6 +11,7 @@ import { SECONDARY_MODEL_SECTION, SUBAGENT_SECTION, } from '#/session/subagent/configSection'; +import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; import { ISessionSubagentModelsValidationService } from '#/session/subagent/subagentModelsValidation'; import { SessionSubagentModelsValidationService } from '#/session/subagent/subagentModelsValidationService'; @@ -31,8 +32,9 @@ describe('SessionSubagentModelsValidationService', () => { disposables.dispose(); }); - function setup(configValues: Record): void { + function setup(configValues: Record, flagEnabled = true): void { ix.stub(IConfigService, new StubConfigService(configValues)); + ix.stub(IFlagService, stubFlag((id) => flagEnabled && id === SECONDARY_MODEL_FLAG_ID)); ix.stub(IModelCatalog, { _serviceBrand: undefined, get: (id: string) => { @@ -71,6 +73,11 @@ describe('SessionSubagentModelsValidationService', () => { expect(resolve()).toBeUndefined(); }); + it('skips a broken pool when secondary-model is disabled', () => { + setup({ [SECONDARY_MODEL_SECTION]: { defaultModel: 'provider/typo' } }, false); + expect(resolve()).toBeUndefined(); + }); + it('constructs fine when default_model alone forms an implicit single-entry pool', () => { modelIds.add('provider/fast'); setup({ [SECONDARY_MODEL_SECTION]: { defaultModel: 'provider/fast' } }); diff --git a/packages/agent-core-v2/test/workspace/workspaceInstance/workspaceInstanceManager.test.ts b/packages/agent-core-v2/test/workspace/workspaceInstance/workspaceInstanceManager.test.ts index 4fb7aee46c1..d71c698e7fc 100644 --- a/packages/agent-core-v2/test/workspace/workspaceInstance/workspaceInstanceManager.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceInstance/workspaceInstanceManager.test.ts @@ -139,10 +139,10 @@ function manager( { scope: () => 'sessions' }, workspaces, { ready }, - ...Array.from({ length: 22 }, () => undefined), + ...Array.from({ length: 23 }, () => undefined), new TestRuntimeUnitHostFactory(), ]; - args[19] = { entries: () => [] }; + args[20] = { entries: () => [] }; const value = Reflect.construct(WorkspaceInstanceManager, args) as WorkspaceInstanceManager; const providers = (value as unknown as { providers: Map }).providers; providers.clear(); diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index 08806081a06..5dfe3f171a7 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -15,11 +15,7 @@ import { mcpResultToExecutableOutput } from '../../mcp/output'; import { isMcpToolName, qualifyMcpToolName } from '../../mcp/tool-naming'; import type { MCPClient, MCPToolDefinition } from '../../mcp/types'; import { resolveSubagentTimeoutMs } from '../../session/subagent-host'; -import { - buildSubagentModelDescriptions, - resolveSecondaryModel, -} from '../../session/subagent-binding'; -import { secondaryModelAlias } from '../../config/secondary-model'; +import { buildSubagentModelDescriptions } from '../../session/subagent-binding'; import { extendWorkspaceWithSkillRoots } from '../../skill'; import { fingerprint } from '../llm-request-logger'; import * as b from '../../tools/builtin'; @@ -784,11 +780,6 @@ export class ToolManager { background, } = this.agent; const videoUploader = this.createVideoUploader(provider); - const secondaryModel = resolveSecondaryModel(this.agent.kimiConfig); - // Mirrors the v2 exposesSubagentModelChoice rule: a configured model - // unlocks the choice, while `force` takes it away from the main agent. - const exposeModelChoice = - secondaryModelAlias(secondaryModel) !== undefined && secondaryModel?.force !== true; const workspace = extendWorkspaceWithSkillRoots( { workspaceDir: cwd, @@ -858,10 +849,11 @@ export class ToolManager { allowBackground, log: this.agent.log, subagentTimeoutMs: resolveSubagentTimeoutMs(this.agent.kimiConfig?.subagent?.timeoutMs), - showModelPreferences: exposeModelChoice, - modelChoiceEnabled: exposeModelChoice, + showModelPreferences: this.agent.experimentalFlags.enabled('secondary-model'), + modelChoiceEnabled: this.agent.experimentalFlags.enabled('secondary-model'), subagentModelDescription: buildSubagentModelDescriptions( this.agent.kimiConfig, + this.agent.experimentalFlags, this.agent.config.modelAlias, ), }, @@ -873,9 +865,10 @@ export class ToolManager { resolveSubagentTimeoutMs(this.agent.kimiConfig?.subagent?.timeoutMs), buildSubagentModelDescriptions( this.agent.kimiConfig, + this.agent.experimentalFlags, this.agent.config.modelAlias, ), - exposeModelChoice, + this.agent.experimentalFlags.enabled('secondary-model'), ), toolServices?.webSearcher && new b.WebSearchTool(toolServices.webSearcher), toolServices?.urlFetcher && new b.FetchURLTool(toolServices.urlFetcher), diff --git a/packages/agent-core/src/config/secondary-model.ts b/packages/agent-core/src/config/secondary-model.ts index b5eaeaed30a..e353fbcd0d9 100644 --- a/packages/agent-core/src/config/secondary-model.ts +++ b/packages/agent-core/src/config/secondary-model.ts @@ -29,18 +29,6 @@ export const SECONDARY_DERIVED_MODEL_ALIAS = '__secondary__'; export const SECONDARY_MODEL_ENV = 'KIMI_SECONDARY_MODEL'; export const SECONDARY_MODEL_EFFORT_ENV = 'KIMI_SECONDARY_EFFORT'; -/** - * The model pointer of the recipe, accepting the v2 engine's `default_model` - * as an alias for the legacy `model` key (`model` wins when both are set). - * Use this instead of reading `.model` directly anywhere a binding or a - * schema/warning decision needs "which model did the user configure". - */ -export function secondaryModelAlias( - secondary: SecondaryModelConfig | undefined, -): string | undefined { - return secondary?.model ?? secondary?.defaultModel; -} - type Env = Readonly>; function trimmed(value: string | undefined): string | undefined { @@ -94,7 +82,7 @@ export function applySecondaryModelConfig(config: KimiConfig, env: Env = process let next = secondary === config.secondaryModel ? config : { ...config, secondaryModel: secondary }; const patch = secondaryModelPatch(secondary); - const baseId = secondaryModelAlias(secondary); + const baseId = secondary?.model; if (patch === undefined || baseId === undefined || baseId === SECONDARY_DERIVED_MODEL_ALIAS) { return next; } diff --git a/packages/agent-core/src/flags/registry.ts b/packages/agent-core/src/flags/registry.ts index dbec75b80b5..55903c6d00b 100644 --- a/packages/agent-core/src/flags/registry.ts +++ b/packages/agent-core/src/flags/registry.ts @@ -32,6 +32,15 @@ export const FLAG_DEFINITIONS = [ default: false, surface: 'core', }, + { + id: 'secondary-model', + title: 'Secondary model for subagents', + description: + 'Let newly spawned subagents use a separately configured secondary model by default, with an explicit primary-model override for quality-sensitive tasks.', + env: 'KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', + default: false, + surface: 'core', + }, ] as const satisfies readonly FlagDefinitionInput[]; /** Literal union of registered flag ids. */ diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index cbceb6d4b7a..34c505f700c 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -2014,14 +2014,6 @@ export class KimiCore implements PromisableMethods { this.config = config; this.experimentalFlags.setConfigOverrides(config.experimental); this.imageLimits.setConfig(config.image); - // Live-apply the secondary-model snapshot to every live session, so a - // config save (e.g. a `/secondary-model` pick) takes effect immediately - // instead of waiting for the next session create. The setter also clears - // the snapshot when the section is removed. - const sessionConfig = this.withPrintModeDefaults(config); - for (const session of this.sessions.values()) { - session.setSecondaryModelConfig(sessionConfig); - } return this.config; } diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index d03d0284e9f..08a6667d4fd 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -56,7 +56,6 @@ import { } from './subagent-binding'; import { SECONDARY_DERIVED_MODEL_ALIAS, - secondaryModelAlias, secondaryModelPatch, } from '../config/secondary-model'; import { @@ -900,9 +899,7 @@ export class Session { * for the next subagent spawn without recreating the session. The core owns * config reload, environment overlays, and derived-model synthesis. Copying * that complete recipe and its model entries keeps spawn binding and provider - * resolution aligned without live-applying unrelated session settings. An - * absent `[secondary_model]` clears the snapshot, so removing the section - * disables the pool for live sessions too. + * resolution aligned without live-applying unrelated session settings. */ setSecondaryModelConfig(config: KimiConfig): void { const base = this.runtimeConfig; @@ -913,27 +910,23 @@ export class Session { ); } const secondary = config.secondaryModel; - const secondaryAlias = secondaryModelAlias(secondary); - if (secondary !== undefined && secondaryAlias === undefined) { throw new KimiError( + if (secondary?.model === undefined) { + throw new KimiError( ErrorCodes.CONFIG_INVALID, 'Cannot set the secondary model: persist its recipe before applying it to a session.', ); } - if (secondaryAlias !== undefined) { - try { - this.options.providerManager?.resolveProviderConfig(secondaryAlias); - } catch (error) { - throw wrapSubagentModelError(error, secondaryAlias, undefined); - } + try { + this.options.providerManager?.resolveProviderConfig(secondary.model); + } catch (error) { + throw wrapSubagentModelError(error, secondary.model, undefined); } const models = { ...base.models }; delete models[SECONDARY_DERIVED_MODEL_ALIAS]; - if (secondaryAlias !== undefined) { - const pointedModel = config.models?.[secondaryAlias]; - if (pointedModel !== undefined) models[secondaryAlias] = pointedModel; - const derivedModel = config.models?.[SECONDARY_DERIVED_MODEL_ALIAS]; - if (derivedModel !== undefined) models[SECONDARY_DERIVED_MODEL_ALIAS] = derivedModel; - } + const pointedModel = config.models?.[secondary.model]; + if (pointedModel !== undefined) models[secondary.model] = pointedModel; + const derivedModel = config.models?.[SECONDARY_DERIVED_MODEL_ALIAS]; + if (derivedModel !== undefined) models[SECONDARY_DERIVED_MODEL_ALIAS] = derivedModel; const next = { ...base, models, secondaryModel: secondary }; this.runtimeConfig = next; this.secondaryModelWarnings = undefined; @@ -960,12 +953,11 @@ export class Session { private computeSecondaryModelWarnings(): SessionWarning[] { if (this.secondaryModelWarnings !== undefined) return [...this.secondaryModelWarnings]; const warnings: SessionWarning[] = []; - const secondary = resolveSecondaryModel(this.kimiConfig); - const secondaryAlias = secondaryModelAlias(secondary); - if (secondary !== undefined && secondaryAlias !== undefined) { + const secondary = resolveSecondaryModel(this.kimiConfig, this.experimentalFlags); + if (secondary?.model !== undefined) { const boundAlias = secondaryModelPatch(secondary) === undefined - ? secondaryAlias + ? secondary.model : SECONDARY_DERIVED_MODEL_ALIAS; try { const resolved = this.options.providerManager?.resolveProviderConfig(boundAlias); diff --git a/packages/agent-core/src/session/subagent-binding.ts b/packages/agent-core/src/session/subagent-binding.ts index f0d18ae8a7a..c0bf7b1fc82 100644 --- a/packages/agent-core/src/session/subagent-binding.ts +++ b/packages/agent-core/src/session/subagent-binding.ts @@ -1,29 +1,29 @@ import { SECONDARY_DERIVED_MODEL_ALIAS, SECONDARY_MODEL_ENV, - secondaryModelAlias, secondaryModelPatch, type KimiConfig, type SecondaryModelConfig, } from '../config'; import { ErrorCodes, KimiError } from '../errors'; +import type { ExperimentalFlagResolver } from '../flags'; import type { AgentModelPreference } from '../profile'; /** * Subagent model binding — the secondary-model half of the spawn decision. * - * When `[secondary_model]` is configured, newly spawned subagents bind to it - * by default instead of inheriting the caller's model. The caller (the parent - * model, through the `Agent` / `AgentSwarm` tool `model` parameter) or the - * spawned profile (via `model_preference`) can force `primary`. A recipe - * with patch fields binds the synthesized derived entry - * ({@link SECONDARY_DERIVED_MODEL_ALIAS}, materialized by - * `applySecondaryModelConfig`); a pointer-only recipe binds the pointed - * entry directly. `default_effort` is passed as the explicit subagent - * thinking effort; without it the child resolves thinking naturally (global - * thinking config → the bound model's default effort) rather than inheriting - * the caller's level. When unset, spawning behavior is unchanged: subagents - * inherit the caller's model and effort. + * When the `secondary-model` experiment is enabled and `[secondary_model]` is + * configured, newly spawned subagents bind to it by default instead of + * inheriting the caller's model. The caller (the parent model, through the + * `Agent` / `AgentSwarm` tool `model` parameter) or the spawned profile (via + * `model_preference`) can force `primary`. A recipe with patch fields binds + * the synthesized derived entry ({@link SECONDARY_DERIVED_MODEL_ALIAS}, + * materialized by `applySecondaryModelConfig`); a pointer-only recipe binds + * the pointed entry directly. `default_effort` is passed as the explicit + * subagent thinking effort; without it the child resolves thinking naturally + * (global thinking config → the bound model's default effort) rather than + * inheriting the caller's level. When unset, spawning behavior is unchanged: + * subagents inherit the caller's model and effort. */ export type SubagentModelChoice = AgentModelPreference; @@ -35,7 +35,9 @@ export interface SubagentModelBinding { export function resolveSecondaryModel( config: KimiConfig | undefined, + flags: ExperimentalFlagResolver, ): SecondaryModelConfig | undefined { + if (!flags.enabled('secondary-model')) return undefined; return config?.secondaryModel; } @@ -46,18 +48,18 @@ export function resolveSecondaryModel( */ export function resolveSubagentBinding( config: KimiConfig | undefined, + flags: ExperimentalFlagResolver, own: { readonly modelAlias: string | undefined; readonly thinkingEffort: string }, requested?: SubagentModelChoice, ): SubagentModelBinding { - const secondary = resolveSecondaryModel(config); - const secondaryAlias = secondaryModelAlias(secondary); - if (requested !== 'primary' && secondaryAlias !== undefined) { + const secondary = resolveSecondaryModel(config, flags); + if (requested !== 'primary' && secondary?.model !== undefined) { return { modelAlias: secondaryModelPatch(secondary) === undefined - ? secondaryAlias + ? secondary.model : SECONDARY_DERIVED_MODEL_ALIAS, - thinkingEffort: secondary?.defaultEffort, + thinkingEffort: secondary.defaultEffort, }; } return { modelAlias: own.modelAlias, thinkingEffort: own.thinkingEffort }; @@ -70,12 +72,11 @@ export function resolveSubagentBinding( */ export function buildSubagentModelDescriptions( config: KimiConfig | undefined, + flags: ExperimentalFlagResolver, callerModelAlias: string | undefined, ): string | undefined { - const secondary = resolveSecondaryModel(config); - const secondaryModel = secondaryModelAlias(secondary); - if (secondaryModel === undefined || callerModelAlias === undefined || secondary?.force === true) - return undefined; + const secondaryModel = resolveSecondaryModel(config, flags)?.model; + if (secondaryModel === undefined || callerModelAlias === undefined) return undefined; return [ 'Available models (pass via model):', `- secondary: ${secondaryModel} (default) — the configured secondary model; prefer it for routine subagent tasks`, @@ -85,7 +86,7 @@ export function buildSubagentModelDescriptions( /** * Strip the `model` property from a subagent collaboration tool's advertised - * JSON schema. When no secondary model is configured the parameter would be + * JSON schema. While the `secondary-model` experiment is off the parameter is * a silent no-op, so the schema the model sees (and the args validator * compiled from the same advertised schema) drops it entirely — the * secondary-model concept never enters the prompt, and a stray `model` diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 4a79be49b92..790e1b4d080 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -6,7 +6,7 @@ import { import type { Agent } from '../agent'; import type { PromptOrigin } from '../agent/context'; -import { ErrorCodes, KimiError } from '../errors'; +import { ErrorCodes } from '../errors'; import { DenyAllPermissionPolicy } from '../agent/permission/policies/deny-all'; import { InMemoryAgentRecordPersistence } from '../agent/records'; import { isAbortError } from '../loop/errors'; @@ -26,12 +26,6 @@ import { type SubagentModelBinding, type SubagentModelChoice, } from './subagent-binding'; -import { resolveSecondaryModel } from './subagent-binding'; -import { - SECONDARY_DERIVED_MODEL_ALIAS, - secondaryModelAlias, - secondaryModelPatch, -} from '../config/secondary-model'; import { SubagentBatch, resolveSwarmMaxConcurrency, @@ -202,6 +196,7 @@ export class SessionSubagentHost { const completion = this.runWithActiveChild(agentId, options, async (runOptions) => { this.emitSubagentSpawned(parent, agentId, profileName, runOptions); try { + this.reInheritParentModel(parent, child); return await this.runPromptTurn(parent, agentId, child, profileName, runOptions); } catch (error) { this.emitSubagentFailed(parent, agentId, runOptions, error); @@ -217,6 +212,7 @@ export class SessionSubagentHost { const completion = this.runWithActiveChild(agentId, options, async (runOptions) => { try { runOptions.signal.throwIfAborted(); + this.reInheritParentModel(parent, child); this.emitSubagentStarted(parent, agentId); const turnId = child.turn.retry('agent-host'); if (turnId === null) { @@ -467,53 +463,19 @@ export class SessionSubagentHost { /** * The model a newly spawned subagent binds to: the configured secondary - * model by default (when `[secondary_model]` is set), otherwise the parent's - * model and effort, inherited as before. With `force` set the choice is - * taken away — an explicit `model: primary` rejection mirrors the v2 error; - * a profile-level preference is overridden silently. The bound alias is - * validated up front so a dangling `[secondary_model]` pointer fails the - * spawn with a wrapped, actionable error instead of a mid-turn provider - * failure. + * model by default (when the experiment is on), otherwise the parent's + * model and effort, inherited as before. The bound alias is validated up + * front so a dangling `[secondary_model]` pointer fails the spawn with a + * wrapped, actionable error instead of a mid-turn provider failure. */ private resolveSpawnBinding( parent: Agent, profile: ResolvedAgentProfile, modelChoice?: SubagentModelChoice, ): SubagentModelBinding { - const secondary = resolveSecondaryModel(this.session.kimiConfig); - const secondaryAlias = secondaryModelAlias(secondary); - if (secondary?.force === true) { - if (secondaryAlias === undefined) { - throw new KimiError( - ErrorCodes.CONFIG_INVALID, - '[secondary_model].default_model is required when [secondary_model].force is set', - ); - } - if (modelChoice === 'primary') { - throw new KimiError( - ErrorCodes.CONFIG_INVALID, - `[secondary_model].force is set, so every subagent binds "${secondaryAlias}" (omit the model parameter).`, - ); - } - const binding: SubagentModelBinding = { - modelAlias: - secondaryModelPatch(secondary) === undefined - ? secondaryAlias - : SECONDARY_DERIVED_MODEL_ALIAS, - thinkingEffort: secondary.defaultEffort, - }; - if (binding.modelAlias !== undefined) { - const providerManager = this.session.options.providerManager; - try { - providerManager?.resolveProviderConfig(binding.modelAlias); - } catch (error) { - throw wrapSubagentModelError(error, binding.modelAlias, parent.config.modelAlias); - } - } - return binding; - } const binding = resolveSubagentBinding( this.session.kimiConfig, + this.session.experimentalFlags, { modelAlias: parent.config.modelAlias, thinkingEffort: parent.config.thinkingEffort }, modelChoice ?? profile.modelPreference, ); @@ -528,6 +490,18 @@ export class SessionSubagentHost { return binding; } + /** + * Resume/retry historically re-synced the child to the parent's current + * model so subagents follow mid-session `/model` switches. With the + * `secondary-model` experiment on, a resumed subagent instead keeps the + * model it was bound to at spawn (v2 semantics: no child-follows-parent + * invariant). + */ + private reInheritParentModel(parent: Agent, child: Agent): void { + if (this.session.experimentalFlags.enabled('secondary-model')) return; + child.config.update({ modelAlias: parent.config.modelAlias }); + } + /** * Hold the run open until the child agent's background tasks (background * Bash, nested background agents) settle — the print-mode (`kimi -p`) diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts index 6aac790a626..06a320ff550 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts @@ -105,9 +105,9 @@ export class AgentSwarmTool implements BuiltinTool { // SubagentBatch arms no timer for non-positive timeouts. private readonly subagentTimeoutMs?: number, subagentModelDescription?: string, - // When no secondary model is configured, the no-op `model` parameter is - // stripped from the advertised schema so the secondary-model concept - // never enters the prompt. + // Mirrors the `secondary-model` experiment: off (the default), the no-op + // `model` parameter is stripped from the advertised schema so the + // secondary-model concept never enters the prompt. modelChoiceEnabled = false, ) { this.description = diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.ts b/packages/agent-core/src/tools/builtin/collaboration/agent.ts index ca9a4127c4d..f5d8e0f9483 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.ts @@ -128,9 +128,9 @@ export class AgentTool implements BuiltinTool { subagentTimeoutMs?: number | undefined; subagentModelDescription?: string; showModelPreferences?: boolean; - // When no secondary model is configured, the no-op `model` parameter is - // stripped from the advertised schema so the secondary-model concept - // never enters the prompt. + // Mirrors the `secondary-model` experiment: off (the default), the + // no-op `model` parameter is stripped from the advertised schema so the + // secondary-model concept never enters the prompt. modelChoiceEnabled?: boolean; }, ) { diff --git a/packages/agent-core/test/agent/basic.test.ts b/packages/agent-core/test/agent/basic.test.ts index 4600608188a..1df4648ecf7 100644 --- a/packages/agent-core/test/agent/basic.test.ts +++ b/packages/agent-core/test/agent/basic.test.ts @@ -15,8 +15,8 @@ it('creates an independent agent with a scoped experimental flag resolver', () = experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS), }); - // The scoped resolver reflects the registry defaults on its own: every - // registered flag defaults off, so none report enabled. + // No experimental flags are currently registered, so the scoped resolver + // reports none enabled. expect(ctx.agent.experimentalFlags.enabledIds()).toEqual([]); }); diff --git a/packages/agent-core/test/agent/tool.test.ts b/packages/agent-core/test/agent/tool.test.ts index 1498f9ab739..a0d885d14c5 100644 --- a/packages/agent-core/test/agent/tool.test.ts +++ b/packages/agent-core/test/agent/tool.test.ts @@ -419,7 +419,7 @@ describe('Agent tools', () => { expect(ctx.agent.tools.loopTools.some((tool) => tool.name === 'AgentSwarm')).toBe(true); }); - it('shows the model preference for a subagent type when a secondary model is configured', () => { + it('shows the model preference for a subagent type when the experiment is enabled', () => { const subagentHost = { delegatableSubagents: vi.fn(() => ({ coder: { @@ -433,7 +433,7 @@ describe('Agent tools', () => { } as unknown as SessionSubagentHost; const ctx = testAgent({ subagentHost, - initialConfig: { providers: {}, secondaryModel: { model: 'cheap-model' } }, + experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS, { 'secondary-model': true }), }); ctx.configure({ tools: ['Agent'] }); @@ -442,7 +442,7 @@ describe('Agent tools', () => { expect(description).toContain('- coder: General coding.\n Model preference: primary'); }); - it('shows the model preference when only the v2-style default_model is configured', () => { + it('hides model preferences when the experiment is disabled', () => { const subagentHost = { delegatableSubagents: vi.fn(() => ({ coder: { @@ -456,56 +456,7 @@ describe('Agent tools', () => { } as unknown as SessionSubagentHost; const ctx = testAgent({ subagentHost, - initialConfig: { providers: {}, secondaryModel: { defaultModel: 'cheap-model' } }, - }); - ctx.configure({ tools: ['Agent'] }); - - const description = ctx.agent.tools.loopTools.find((tool) => tool.name === 'Agent')?.description; - - expect(description).toContain('- coder: General coding.\n Model preference: primary'); - }); - - it('hides model preferences when no secondary model is configured', () => { - const subagentHost = { - delegatableSubagents: vi.fn(() => ({ - coder: { - name: 'coder', - description: 'General coding.', - systemPrompt: () => 'coder prompt', - tools: ['Read'], - modelPreference: 'primary' as const, - }, - })), - } as unknown as SessionSubagentHost; - const ctx = testAgent({ - subagentHost, - initialConfig: { providers: {} }, - }); - ctx.configure({ tools: ['Agent'] }); - - const description = ctx.agent.tools.loopTools.find((tool) => tool.name === 'Agent')?.description; - - expect(description).not.toContain('Model preference:'); - }); - - it('hides model preferences when force is set', () => { - const subagentHost = { - delegatableSubagents: vi.fn(() => ({ - coder: { - name: 'coder', - description: 'General coding.', - systemPrompt: () => 'coder prompt', - tools: ['Read'], - modelPreference: 'primary' as const, - }, - })), - } as unknown as SessionSubagentHost; - const ctx = testAgent({ - subagentHost, - initialConfig: { - providers: {}, - secondaryModel: { defaultModel: 'cheap-model', force: true }, - }, + experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS), }); ctx.configure({ tools: ['Agent'] }); diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts index b36171b6f7f..912a6b5df27 100644 --- a/packages/agent-core/test/harness/runtime.test.ts +++ b/packages/agent-core/test/harness/runtime.test.ts @@ -235,6 +235,7 @@ micro_compaction = false await mkdir(homeDir, { recursive: true }); await mkdir(workDir, { recursive: true }); await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', '1'); const [coreRpc, sdkRpc] = createRPC(); const core = new KimiCore(coreRpc, { homeDir }); @@ -266,112 +267,6 @@ micro_compaction = false expect(config?.models?.['__secondary__']?.overrides?.maxContextSize).toBe(65_536); }); - it('live-applies the persisted secondary recipe on config write without an explicit apply', async () => { - tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); - const homeDir = join(tmp, 'home'); - const workDir = join(tmp, 'work'); - await mkdir(homeDir, { recursive: true }); - await mkdir(workDir, { recursive: true }); - await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); - - const [coreRpc, sdkRpc] = createRPC(); - const core = new KimiCore(coreRpc, { homeDir }); - const rpc = await sdkRpc({ - emitEvent: vi.fn(), - requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), - requestQuestion: vi.fn(async () => null), - toolCall: vi.fn(async () => ({ output: '' })), - }); - const created = await rpc.createSession({ - id: 'ses_runtime_secondary_auto_apply', - workDir, - model: 'default-mock', - }); - - await rpc.setKimiConfig({ - secondaryModel: { - model: 'default-mock', - maxContextSize: 65_536, - }, - }); - - const config = core.sessions.get(created.id)?.getReadyAgent('main')?.kimiConfig; - expect(config?.secondaryModel).toEqual({ - model: 'default-mock', - maxContextSize: 65_536, - }); - expect(config?.models?.['__secondary__']?.overrides?.maxContextSize).toBe(65_536); - }); - - it('live-applies a v2-style default_model-only recipe on config write', async () => { - tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); - const homeDir = join(tmp, 'home'); - const workDir = join(tmp, 'work'); - await mkdir(homeDir, { recursive: true }); - await mkdir(workDir, { recursive: true }); - await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); - - const [coreRpc, sdkRpc] = createRPC(); - const core = new KimiCore(coreRpc, { homeDir }); - const rpc = await sdkRpc({ - emitEvent: vi.fn(), - requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), - requestQuestion: vi.fn(async () => null), - toolCall: vi.fn(async () => ({ output: '' })), - }); - const created = await rpc.createSession({ - id: 'ses_runtime_secondary_default_model', - workDir, - model: 'default-mock', - }); - - await rpc.setKimiConfig({ - secondaryModel: { defaultModel: 'default-mock' }, - }); - - const config = core.sessions.get(created.id)?.getReadyAgent('main')?.kimiConfig; - expect(config?.secondaryModel).toEqual({ defaultModel: 'default-mock' }); - }); - - it('clears the live secondary model when the section is removed on reload', async () => { - tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); - const homeDir = join(tmp, 'home'); - const workDir = join(tmp, 'work'); - await mkdir(homeDir, { recursive: true }); - await mkdir(workDir, { recursive: true }); - await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); - - const [coreRpc, sdkRpc] = createRPC(); - const core = new KimiCore(coreRpc, { homeDir }); - const rpc = await sdkRpc({ - emitEvent: vi.fn(), - requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), - requestQuestion: vi.fn(async () => null), - toolCall: vi.fn(async () => ({ output: '' })), - }); - const created = await rpc.createSession({ - id: 'ses_runtime_secondary_clear', - workDir, - model: 'default-mock', - }); - - await rpc.setKimiConfig({ - secondaryModel: { defaultModel: 'default-mock' }, - }); - expect( - core.sessions.get(created.id)?.getReadyAgent('main')?.kimiConfig?.secondaryModel, - ).toEqual({ defaultModel: 'default-mock' }); - - // The user removes [secondary_model] from config.toml directly, then the - // next reload clears it from the live session too. - await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); - await rpc.getKimiConfig({ reload: true }); - - expect( - core.sessions.get(created.id)?.getReadyAgent('main')?.kimiConfig?.secondaryModel, - ).toBeUndefined(); - }); - // Regression for https://github.com/MoonshotAI/kimi-code/issues/988: during // ACP `session/new` the tool kaos is the reverse-RPC bridge and the client // does not know the session yet, so reading `.kimi-code/local.toml` through diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index e15addad54d..a215e9bb826 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -766,7 +766,9 @@ describe('Session secondary-model live config', () => { rpc: createSessionRpc([]), skills: { explicitDirs: [join(workDir, 'missing-skills')] }, providerManager: testProviderManager(), - experimentalFlags: new FlagResolver({}), + experimentalFlags: new FlagResolver({ + KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL: '1', + }), config, }); } @@ -871,13 +873,10 @@ describe('Session secondary-model live config', () => { } }); - it('clears the snapshot when the complete config has no persisted secondary recipe', async () => { - const session = await makeSession(SECONDARY_POINTER_CONFIG); + it('rejects when the complete config has no persisted secondary recipe', async () => { + const session = await makeSession(SECONDARY_BASE_CONFIG); try { - session.setSecondaryModelConfig(SECONDARY_POINTER_CONFIG); - expect(session.kimiConfig?.secondaryModel).toEqual({ model: MOCK_PROVIDER.model }); - session.setSecondaryModelConfig(SECONDARY_BASE_CONFIG); - expect(session.kimiConfig?.secondaryModel).toBeUndefined(); + expect(() => session.setSecondaryModelConfig(SECONDARY_BASE_CONFIG)).toThrow(/persist/); } finally { await session.close(); } diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 0fe92e8773c..52edef997fd 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -1134,7 +1134,52 @@ describe('SessionSubagentHost', () => { expect(userTextMessages(histories[1] ?? [])).toEqual(['Implement the retry-safe change']); }); + it('realigns a resumed subagent to the parent agent current model', async () => { + const parent = testAgent(); + parent.configure(); + parent.agent.permission.setMode('yolo'); + + const child = testAgent(); + child.configure({ tools: ['Read'] }); + // The child was originally spawned with a model that no longer matches the + // parent agent's current model (as if the parent ran setModel afterwards). + child.agent.config.update({ modelAlias: 'stale-model-from-initial-spawn' }); + child.agent.useProfile( + profile({ name: 'explore', tools: ['Read'], systemPrompt: 'explore prompt' }), + ); + child.agent.context.appendUserMessage([{ type: 'text', text: 'Earlier context' }]); + child.mockNextResponse({ + type: 'text', + text: 'Resumed the subagent from its earlier context and carried the task through to completion, then reported a full and detailed technical summary so the parent agent can continue without repeating prior work.', + }); + + const session = fakeSession(parent.agent, child.agent, { + 'agent-0': { + homedir: '/tmp/kimi-session/agents/agent-0', + type: 'sub', + parentAgentId: 'main', + }, + }); + const host = new SessionSubagentHost(session, 'main'); + + const handle = await host.resume('agent-0', { + parentToolCallId: 'call_agent', + prompt: 'Continue from context', + description: 'Continue work', + runInBackground: false, + signal, + }); + + await handle.completion; + // resume must realign the child to the parent agent's current model rather + // than leave it on the stale model from its initial spawn. + expect(child.agent.config.modelAlias).toBe(parent.agent.config.modelAlias); + expect(child.agent.config.modelAlias).not.toBe('stale-model-from-initial-spawn'); + }); + describe('secondary model binding', () => { + const secondaryFlags = () => + new FlagResolver({ KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL: '1' }); const LONG_SUMMARY = 'Completed the delegated task end to end and reported a technically complete summary so the parent agent can continue without repeating prior work. ' + 'The report covers the investigation, the changes made, and the verification results in enough detail for the caller to act on directly.'; @@ -1161,6 +1206,7 @@ describe('SessionSubagentHost', () => { async function spawnChild(options: { config?: KimiConfig; + experimentalFlags?: FlagResolver; providerManager?: Session['options']['providerManager']; modelChoice?: 'primary' | 'secondary'; profilePreference?: 'primary' | 'secondary'; @@ -1173,6 +1219,7 @@ describe('SessionSubagentHost', () => { const session = fakeSession(parent.agent, child.agent, {}, { config: options.config, + experimentalFlags: options.experimentalFlags, providerManager: options.providerManager, }); const host = new SessionSubagentHost(session, 'main'); @@ -1206,6 +1253,7 @@ describe('SessionSubagentHost', () => { it('binds the secondary model when configured', async () => { const { parent, child } = await spawnChild({ + experimentalFlags: secondaryFlags(), config: { providers: {}, secondaryModel: { model: 'cheap-model' }, @@ -1215,19 +1263,9 @@ describe('SessionSubagentHost', () => { expect(child.agent.config.modelAlias).not.toBe(parent.agent.config.modelAlias); }); - it('binds the v2-style default_model when the legacy model key is unset', async () => { - const { parent, child } = await spawnChild({ - config: { - providers: {}, - secondaryModel: { defaultModel: 'cheap-model' }, - }, - }); - expect(child.agent.config.modelAlias).toBe('cheap-model'); - expect(child.agent.config.modelAlias).not.toBe(parent.agent.config.modelAlias); - }); - it('binds the derived entry when the recipe carries patch fields', async () => { const { child } = await spawnChild({ + experimentalFlags: secondaryFlags(), config: { providers: {}, secondaryModel: { model: 'cheap-model', defaultEffort: 'low' }, @@ -1238,15 +1276,16 @@ describe('SessionSubagentHost', () => { expect(child.agent.config.modelAlias).toBe('__secondary__'); }); - it('inherits the parent model when no secondary model is configured', async () => { + it('inherits the parent model when the experiment is off', async () => { const { parent, child } = await spawnChild({ - config: { providers: {} }, + config: { providers: {}, secondaryModel: { model: 'cheap-model' } }, }); expect(child.agent.config.modelAlias).toBe(parent.agent.config.modelAlias); }); it('inherits the parent model for an explicit model: primary choice', async () => { const { parent, child } = await spawnChild({ + experimentalFlags: secondaryFlags(), config: { providers: {}, secondaryModel: { model: 'cheap-model' } }, modelChoice: 'primary', }); @@ -1255,44 +1294,13 @@ describe('SessionSubagentHost', () => { it('honors the profile model_preference over the configured secondary model', async () => { const { parent, child } = await spawnChild({ + experimentalFlags: secondaryFlags(), config: { providers: {}, secondaryModel: { model: 'cheap-model' } }, profilePreference: 'primary', }); expect(child.agent.config.modelAlias).toBe(parent.agent.config.modelAlias); }); - it('binds the forced model and rejects an explicit model: primary choice', async () => { - await expect( - spawnChild({ - config: { - providers: {}, - secondaryModel: { defaultModel: 'cheap-model', force: true }, - }, - modelChoice: 'primary', - }), - ).rejects.toThrow(/\[secondary_model\]\.force is set/); - }); - - it('silently overrides a profile model_preference when force is set', async () => { - const { parent, child } = await spawnChild({ - config: { - providers: {}, - secondaryModel: { defaultModel: 'cheap-model', force: true }, - }, - profilePreference: 'primary', - }); - expect(child.agent.config.modelAlias).toBe('cheap-model'); - expect(child.agent.config.modelAlias).not.toBe(parent.agent.config.modelAlias); - }); - - it('fails the spawn when force is set without a default model', async () => { - await expect( - spawnChild({ - config: { providers: {}, secondaryModel: { force: true } }, - }), - ).rejects.toThrow(/default_model is required when \[secondary_model\]\.force is set/); - }); - it('fails the spawn with a wrapped error when the secondary model does not resolve', async () => { const parent = testAgent(); parent.configure(); @@ -1303,6 +1311,7 @@ describe('SessionSubagentHost', () => { secondaryModel: { model: 'missing-model' }, }; const session = fakeSession(parent.agent, child.agent, {}, { + experimentalFlags: secondaryFlags(), config, providerManager: new ProviderManager({ config }), }); @@ -1337,6 +1346,7 @@ describe('SessionSubagentHost', () => { secondaryModel: { model: 'cheap-model' }, }; const session = fakeSession(parent.agent, child.agent, {}, { + experimentalFlags: secondaryFlags(), config, providerManager: new ProviderManager({ config }), }); @@ -1356,7 +1366,7 @@ describe('SessionSubagentHost', () => { }); }); - it('keeps the spawned model on resume', async () => { + it('keeps the spawned model on resume when the experiment is on', async () => { const parent = testAgent(); parent.configure(); parent.agent.permission.setMode('yolo'); @@ -1377,6 +1387,7 @@ describe('SessionSubagentHost', () => { parentAgentId: 'main', }, }, { + experimentalFlags: secondaryFlags(), config: { providers: {}, secondaryModel: { model: 'cheap-model' } }, }); const host = new SessionSubagentHost(session, 'main'); @@ -1389,8 +1400,8 @@ describe('SessionSubagentHost', () => { signal, }); await handle.completion; - // Resume no longer realigns the child to the parent's model: the - // subagent keeps the model it was bound to at spawn. + // With the experiment on, resume no longer realigns the child to the + // parent's model: the subagent keeps the model it was bound to at spawn. expect(child.agent.config.modelAlias).toBe('cheap-model'); }); }); diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index 9484facf1ff..3fae01040de 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -345,6 +345,17 @@ describe('KimiHarness config API', () => { enabled: false, source: 'default', }, + { + id: 'secondary-model', + title: 'Secondary model for subagents', + description: + 'Let newly spawned subagents use a separately configured secondary model by default, with an explicit primary-model override for quality-sensitive tasks.', + surface: 'core', + env: 'KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', + defaultEnabled: false, + enabled: false, + source: 'default', + }, ]); }); From 9015af0fbd143d3b2b8ccf08a2bc301a97d867c7 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 31 Aug 2026 12:17:21 +0800 Subject: [PATCH 09/12] feat(secondary-model): graduate pool with model source telemetry --- .../src/agent/tools/agent/agentTool.ts | 5 +++++ .../src/agent/tools/agent/subagent-task.ts | 2 ++ .../agent-core-v2/src/app/telemetry/events.ts | 3 +++ .../swarm/session/sessionSwarmService.ts | 1 + .../features/tower/tools/spawn/spawnTool.ts | 4 +++- .../src/session/subagent/configSection.ts | 12 +++++----- .../src/session/subagent/mirrorAgentRun.ts | 3 +++ .../src/session/subagent/spawn.ts | 4 +++- .../src/session/subagent/subagentService.ts | 4 +++- .../test/app/config/config.test.ts | 14 ++++++++++++ .../test/features/swarm/swarm.test.ts | 18 +++++++-------- .../test/session/subagent/spawn.test.ts | 12 +++++++--- packages/agent-core-v2/test/tool/tool.test.ts | 18 ++++++++------- packages/agent-core/src/flags/registry.ts | 2 +- packages/agent-core/test/agent/basic.test.ts | 6 ++--- packages/agent-core/test/agent/tool.test.ts | 2 +- .../test/session/subagent-host.test.ts | 22 +++++++++++++------ packages/node-sdk/test/config.test.ts | 4 ++-- 18 files changed, 94 insertions(+), 42 deletions(-) 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 b68ad9fb712..1eedfdf1505 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -57,6 +57,7 @@ import { resolveSubagentTimeoutMs, stripSubagentForkParameter, stripSubagentModelParameter, + type SubagentModelSource, } from '#/session/subagent/configSection'; import { BACKGROUND_AGENT_UNAVAILABLE, @@ -276,6 +277,7 @@ export class SubagentTool implements ISubagentTool { let agentId: string; let profileName: string; let displayModel: string | undefined; + let displayModelSource: SubagentModelSource | undefined; let promptText = args.prompt; if (isResume) { const target = this.agentLifecycle.handleOf(resumeAgentId); @@ -305,6 +307,7 @@ export class SubagentTool implements ISubagentTool { agentId = spawned.agentId; profileName = spawned.profileName; displayModel = spawned.model; + displayModelSource = spawned.modelSource; promptText = spawned.promptText; } @@ -329,6 +332,7 @@ export class SubagentTool implements ISubagentTool { profileName, parentToolCallId: toolCallId, model: displayModel, + modelSource: displayModelSource, thinkingEffort: this.agentLifecycle.handleOf(agentId) ?.accessor.get(IAgentProfileService) .getEffectiveThinkingLevel(), @@ -458,6 +462,7 @@ export class SubagentTool implements ISubagentTool { runInBackground, fork: args.fork === true, model: handle.model, + modelSource: handle.modelSource, taskId, }); void requester.accessor diff --git a/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts b/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts index 863647c6924..4bc915a9b75 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts @@ -1,4 +1,5 @@ import type { TokenUsage } from '#/kosong/contract/usage'; +import type { SubagentModelSource } from '#/session/subagent/configSection'; import { isAbortError } from '#/_base/utils/abort'; import { @@ -17,6 +18,7 @@ export type SubagentHandle = { readonly profileName: string; readonly parentToolCallId?: string; readonly model?: string; + readonly modelSource?: SubagentModelSource; readonly thinkingEffort?: string; readonly completion: Promise; }; diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index b5f32663e08..4cc18048738 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -368,6 +368,7 @@ export interface SubagentCreatedEvent { parent_agent_id: string; parent_tool_call_id: string; model?: string; + model_source?: 'forced' | 'primary_override' | 'inherited' | 'secondary_pool'; } export interface McpConnectedEvent { @@ -909,6 +910,8 @@ export const telemetryEventDefinitions = { parent_agent_id: 'Parent (caller) agent id', parent_tool_call_id: "Tool call id of the launching call in the parent agent; '' when not launched from a tool call", model: 'Model alias the subagent binds to (secondary-model choice or inherited caller model); omitted when no binding was resolved', + model_source: + "How the bound model was chosen: 'forced' = [secondary_model].force, 'primary_override' = explicit \"primary\" request, 'inherited' = caller's own model (no pool or fork), 'secondary_pool' = [secondary_model.models] pool pick; omitted when no binding resolution happened (e.g. resume)", }, }), mcp_connected: defineTelemetryEvent({ diff --git a/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts b/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts index a8c93c244b1..74ae9af37dc 100644 --- a/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts @@ -126,6 +126,7 @@ export class SessionSwarmService implements ISessionSwarmService { runInBackground: options.runInBackground, fork: plan.fork, model: plan.model, + modelSource: plan.modelSource, }); const child = this.requireHandle(spawned.agentId, 'Agent instance'); return this.observe( 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 4931ea7fd3a..e9eb3984062 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 @@ -163,7 +163,7 @@ export class TowerSpawnTool implements ITowerSpawnTool { own.modelAlias === undefined ? undefined : args.kind === 'reviewer' - ? { model: own.modelAlias, thinking: own.thinkingLevel } + ? { model: own.modelAlias, thinking: own.thinkingLevel, modelSource: 'inherited' as const } : resolveSubagentBinding( this.config, this.flags, @@ -304,6 +304,8 @@ export class TowerSpawnTool implements ITowerSpawnTool { parentToolCallId: toolCallId, description, runInBackground: true, + model: binding?.model, + modelSource: binding?.modelSource, }); const run = await this.subagents.run( diff --git a/packages/agent-core-v2/src/session/subagent/configSection.ts b/packages/agent-core-v2/src/session/subagent/configSection.ts index d453e933fee..f71ed8e5155 100644 --- a/packages/agent-core-v2/src/session/subagent/configSection.ts +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -187,12 +187,14 @@ export function assertValidSubagentModelConfig( if (pool !== undefined) assertValidSubagentModelPool(pool, modelCatalog); } +export type SubagentModelSource = 'forced' | 'primary_override' | 'inherited' | 'secondary_pool'; + export function resolveSubagentBinding( config: IConfigService, flags: IFlagService, own: { modelAlias: string; thinkingLevel: string }, requested?: string, -): { model: string; thinking?: string } { +): { model: string; thinking?: string; modelSource: SubagentModelSource } { const section = config.get(SECONDARY_MODEL_SECTION); const enabled = flags.enabled(SECONDARY_MODEL_FLAG_ID); if (enabled && section?.force === true) { @@ -214,10 +216,10 @@ export function resolveSubagentBinding( { details: { model: requested } }, ); } - return { model: forcedModel, thinking: section.defaultEffort }; + return { model: forcedModel, thinking: section.defaultEffort, modelSource: 'forced' }; } if (requested === PRIMARY_SUBAGENT_MODEL_CHOICE) { - return { model: own.modelAlias, thinking: own.thinkingLevel }; + return { model: own.modelAlias, thinking: own.thinkingLevel, modelSource: 'primary_override' }; } const pool = enabled ? resolveSubagentModelPool(config) : undefined; if (pool === undefined) { @@ -228,7 +230,7 @@ export function resolveSubagentBinding( { details: { model: requested } }, ); } - return { model: own.modelAlias, thinking: own.thinkingLevel }; + return { model: own.modelAlias, thinking: own.thinkingLevel, modelSource: 'inherited' }; } if (Object.hasOwn(pool.models, PRIMARY_SUBAGENT_MODEL_CHOICE)) { throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_PRIMARY_MODEL_RESERVED_MESSAGE, { @@ -253,7 +255,7 @@ export function resolveSubagentBinding( { details: { model: choice, availableModels: available } }, ); } - return { model: choice, thinking: section?.defaultEffort }; + return { model: choice, thinking: section?.defaultEffort, modelSource: 'secondary_pool' }; } export function resolveSubagentThinking( diff --git a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts index 77fcdd0010a..2a4e98d54a1 100644 --- a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts +++ b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts @@ -14,6 +14,7 @@ import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle' import { IEventDispatcher } from '#/state/eventDispatcher'; import { type AgentRunHandle, ISessionSubagentService } from './subagent'; +import type { SubagentModelSource } from './configSection'; export interface SubagentSpawnedPayload { readonly subagentId: string; @@ -79,6 +80,7 @@ export interface AgentRunSpawnedMeta { readonly runInBackground?: boolean; readonly fork?: boolean; readonly model?: string; + readonly modelSource?: SubagentModelSource; readonly taskId?: string; } @@ -125,6 +127,7 @@ export function emitAgentRunSpawned( parent_agent_id: requester.id, parent_tool_call_id: meta.parentToolCallId ?? '', model: meta.model, + model_source: meta.modelSource, }; requester.accessor.get(ITelemetryService)?.track2('subagent_created', telemetryEvent); } diff --git a/packages/agent-core-v2/src/session/subagent/spawn.ts b/packages/agent-core-v2/src/session/subagent/spawn.ts index 38aba01af98..6fc2c71eb31 100644 --- a/packages/agent-core-v2/src/session/subagent/spawn.ts +++ b/packages/agent-core-v2/src/session/subagent/spawn.ts @@ -1,4 +1,4 @@ -import { PRIMARY_SUBAGENT_MODEL_CHOICE } from './configSection'; +import { PRIMARY_SUBAGENT_MODEL_CHOICE, type SubagentModelSource } from './configSection'; export const DEFAULT_PROFILE_NAME = 'coder'; @@ -54,6 +54,7 @@ export interface SubagentSpawnPlanInput { export interface SubagentSpawnPlan { readonly profileName: string; readonly model: string; + readonly modelSource?: SubagentModelSource; readonly thinking?: string; readonly fork: boolean; } @@ -69,5 +70,6 @@ export interface SpawnedSubagent { readonly agentId: string; readonly profileName: string; readonly model: string; + readonly modelSource?: SubagentModelSource; readonly promptText: string; } diff --git a/packages/agent-core-v2/src/session/subagent/subagentService.ts b/packages/agent-core-v2/src/session/subagent/subagentService.ts index f97b6c71866..a1f5e81e2a8 100644 --- a/packages/agent-core-v2/src/session/subagent/subagentService.ts +++ b/packages/agent-core-v2/src/session/subagent/subagentService.ts @@ -130,7 +130,7 @@ export class SessionSubagentService extends Service implements ISessionSubagentS }); } const binding = fork - ? { model: own.modelAlias, thinking: own.thinkingLevel } + ? { model: own.modelAlias, thinking: own.thinkingLevel, modelSource: 'inherited' as const } : resolveSubagentBinding( this.configService, this.flags, @@ -146,6 +146,7 @@ export class SessionSubagentService extends Service implements ISessionSubagentS return { profileName: profile?.name ?? requestedProfileName, model: binding.model, + modelSource: binding.modelSource, thinking: resolveSubagentThinking(this.configService, model, binding.thinking), fork, }; @@ -202,6 +203,7 @@ export class SessionSubagentService extends Service implements ISessionSubagentS agentId: created.id, profileName: plan.profileName, model: plan.model, + modelSource: plan.modelSource, promptText, }; } finally { diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index 439b6be3f1c..ab870e0fd2a 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -1882,10 +1882,12 @@ describe('subagent config section', () => { expect(resolveSubagentBinding(noPool.config, own)).toEqual({ model: 'provider/main', thinking: 'medium', + modelSource: 'inherited', }); expect(resolveSubagentBinding(noPool.config, own, 'primary')).toEqual({ model: 'provider/main', thinking: 'medium', + modelSource: 'primary_override', }); noPool.disposables.dispose(); @@ -1896,14 +1898,17 @@ describe('subagent config section', () => { expect(resolveSubagentBinding(pool.config, own)).toEqual({ model: 'provider/fast', thinking: undefined, + modelSource: 'secondary_pool', }); expect(resolveSubagentBinding(pool.config, own, 'provider/smart')).toEqual({ model: 'provider/smart', thinking: undefined, + modelSource: 'secondary_pool', }); expect(resolveSubagentBinding(pool.config, own, 'primary')).toEqual({ model: 'provider/main', thinking: 'medium', + modelSource: 'primary_override', }); pool.disposables.dispose(); }); @@ -1918,6 +1923,7 @@ describe('subagent config section', () => { expect(resolveSubagentBindingWithFlags(config, secondaryModelFlags(false), own)).toEqual({ model: 'provider/main', thinking: 'medium', + modelSource: 'inherited', }); expect(() => resolveSubagentBindingWithFlags(config, secondaryModelFlags(false), own, 'provider/fast'), @@ -1936,10 +1942,12 @@ describe('subagent config section', () => { expect(resolveSubagentBinding(config, own)).toEqual({ model: 'provider/fast', thinking: undefined, + modelSource: 'secondary_pool', }); expect(resolveSubagentBinding(config, own, 'primary')).toEqual({ model: 'provider/main', thinking: 'medium', + modelSource: 'primary_override', }); expect(() => resolveSubagentBinding(config, own, 'provider/smart')).toThrow( /Invalid model "provider\/smart"\. Available models: provider\/fast, primary\./, @@ -1966,6 +1974,7 @@ describe('subagent config section', () => { expect(resolveSubagentBinding(config, own)).toEqual({ model: 'provider/fast', thinking: 'low', + modelSource: 'secondary_pool', }); expect(() => resolveSubagentBinding(config, own, 'provider/smart')).toThrow( /Invalid model "provider\/smart"\. Available models: provider\/fast, primary\./, @@ -1984,6 +1993,7 @@ describe('subagent config section', () => { expect(resolveSubagentBinding(config, own)).toEqual({ model: 'provider/fast', thinking: undefined, + modelSource: 'secondary_pool', }); disposables.dispose(); @@ -2013,6 +2023,7 @@ describe('subagent config section', () => { expect(resolveSubagentBinding(config, own)).toEqual({ model: 'provider/fast', thinking: undefined, + modelSource: 'forced', }); expect(() => resolveSubagentBinding(config, own, 'primary')).toThrow( /Invalid model "primary": \[secondary_model\]\.force is set/, @@ -2055,10 +2066,12 @@ describe('subagent config section', () => { expect(resolveSubagentBinding(config, own)).toEqual({ model: 'provider/fast', thinking: 'max', + modelSource: 'secondary_pool', }); expect(resolveSubagentBinding(config, own, 'primary')).toEqual({ model: 'provider/main', thinking: 'medium', + modelSource: 'primary_override', }); disposables.dispose(); @@ -2078,6 +2091,7 @@ describe('subagent config section', () => { expect(resolveSubagentBinding(config, own)).toEqual({ model: 'provider/fast', thinking: undefined, + modelSource: 'forced', }); expect(() => resolveSubagentBinding(config, own, 'primary')).toThrow( /Invalid model "primary": \[secondary_model\]\.force is set/, 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 5d39562d66b..f5fe2970033 100644 --- a/packages/agent-core-v2/test/features/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/features/swarm/swarm.test.ts @@ -766,7 +766,7 @@ describe('AgentSwarmTool', () => { runInBackground: false, signal, timeout: DEFAULT_SWARM_TIMEOUT_MS, - plan: { profileName: 'explore', model: 'provider/fast', thinking: undefined, fork: false }, + plan: { profileName: 'explore', model: 'provider/fast', modelSource: 'secondary_pool', thinking: undefined, fork: false }, }, { kind: 'spawn', @@ -785,7 +785,7 @@ describe('AgentSwarmTool', () => { runInBackground: false, signal, timeout: DEFAULT_SWARM_TIMEOUT_MS, - plan: { profileName: 'explore', model: 'provider/fast', thinking: undefined, fork: false }, + plan: { profileName: 'explore', model: 'provider/fast', modelSource: 'secondary_pool', thinking: undefined, fork: false }, }, ] })); expect(result.output).toBe( @@ -1025,7 +1025,7 @@ describe('AgentSwarmTool', () => { runInBackground: false, signal, timeout: DEFAULT_SWARM_TIMEOUT_MS, - plan: { profileName: 'explore', model: 'mock-model', thinking: 'off', fork: false }, + plan: { profileName: 'explore', model: 'mock-model', modelSource: 'inherited', thinking: 'off', fork: false }, }, ] })); expect(result.output).toBe( @@ -1213,10 +1213,10 @@ describe('AgentSwarmTool', () => { expect.objectContaining({ tasks: [ expect.objectContaining({ - plan: { profileName: 'coder', model: 'provider/fast', thinking: undefined, fork: false }, + plan: { profileName: 'coder', model: 'provider/fast', modelSource: 'secondary_pool', thinking: undefined, fork: false }, }), expect.objectContaining({ - plan: { profileName: 'coder', model: 'provider/fast', thinking: undefined, fork: false }, + plan: { profileName: 'coder', model: 'provider/fast', modelSource: 'secondary_pool', thinking: undefined, fork: false }, }), ], }), @@ -1241,10 +1241,10 @@ describe('AgentSwarmTool', () => { expect.objectContaining({ tasks: [ expect.objectContaining({ - plan: { profileName: 'coder', model: 'main-model', thinking: 'high', fork: false }, + plan: { profileName: 'coder', model: 'main-model', modelSource: 'primary_override', thinking: 'high', fork: false }, }), expect.objectContaining({ - plan: { profileName: 'coder', model: 'main-model', thinking: 'high', fork: false }, + plan: { profileName: 'coder', model: 'main-model', modelSource: 'primary_override', thinking: 'high', fork: false }, }), ], }), @@ -1451,10 +1451,10 @@ describe('AgentSwarmTool', () => { expect.objectContaining({ tasks: [ expect.objectContaining({ - plan: { profileName: 'orchestrator', model: 'main-model', thinking: 'high', fork: true }, + plan: { profileName: 'orchestrator', model: 'main-model', modelSource: 'inherited', thinking: 'high', fork: true }, }), expect.objectContaining({ - plan: { profileName: 'orchestrator', model: 'main-model', thinking: 'high', fork: true }, + plan: { profileName: 'orchestrator', model: 'main-model', modelSource: 'inherited', thinking: 'high', fork: true }, }), ], }), 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 a8b500e7ad9..1fbc273bd78 100644 --- a/packages/agent-core-v2/test/session/subagent/spawn.test.ts +++ b/packages/agent-core-v2/test/session/subagent/spawn.test.ts @@ -251,7 +251,7 @@ describe('SessionSubagentService planSpawn and spawn', () => { function spawnNonForkChild(svc: ISessionSubagentService): Promise { return svc.spawn({ callerAgentId: CALLER_ID, - plan: { profileName: 'coder', model: 'provider/fast', thinking: 'low', fork: false }, + plan: { profileName: 'coder', model: 'provider/fast', modelSource: 'secondary_pool', thinking: 'low', fork: false }, labels: { parentAgentId: 'main' }, prompt: 'Review the file', }); @@ -260,7 +260,7 @@ describe('SessionSubagentService planSpawn and spawn', () => { function spawnForkChild(svc: ISessionSubagentService): Promise { return svc.spawn({ callerAgentId: CALLER_ID, - plan: { profileName: 'orchestrator', model: 'main-model', thinking: 'high', fork: true }, + plan: { profileName: 'orchestrator', model: 'main-model', modelSource: 'inherited', thinking: 'high', fork: true }, labels: { parentAgentId: 'main' }, prompt: 'Continue the analysis', }); @@ -332,6 +332,7 @@ describe('SessionSubagentService planSpawn and spawn', () => { expect(plan).toEqual({ profileName: 'coder', model: 'provider/fast', + modelSource: 'secondary_pool', thinking: 'max', fork: false, }); @@ -440,6 +441,7 @@ describe('SessionSubagentService planSpawn and spawn', () => { expect(plan).toEqual({ profileName: 'coder', model: 'provider/fast', + modelSource: 'forced', thinking: 'max', fork: false, }); @@ -453,6 +455,7 @@ describe('SessionSubagentService planSpawn and spawn', () => { expect(plan).toEqual({ profileName: 'coder', model: 'main-model', + modelSource: 'inherited', thinking: 'high', fork: false, }); @@ -484,6 +487,7 @@ describe('SessionSubagentService planSpawn and spawn', () => { expect(plan).toEqual({ profileName: 'orchestrator', model: 'main-model', + modelSource: 'inherited', thinking: 'high', fork: true, }); @@ -546,6 +550,7 @@ describe('SessionSubagentService planSpawn and spawn', () => { agentId: 'agent-child', profileName: 'coder', model: 'provider/fast', + modelSource: 'secondary_pool', promptText: 'FIXED-PREFIX\n\nReview the file', }); }); @@ -591,6 +596,7 @@ describe('SessionSubagentService planSpawn and spawn', () => { agentId: 'agent-fork', profileName: 'orchestrator', model: 'main-model', + modelSource: 'inherited', promptText: `${FORK_CONTEXT_NOTICE}\n\nContinue the analysis`, }); }); @@ -622,7 +628,7 @@ describe('SessionSubagentService planSpawn and spawn', () => { const error = await spawnError(svc, { callerAgentId: CALLER_ID, - plan: { profileName: 'coder', model: 'provider/bad', thinking: 'low', fork: false }, + plan: { profileName: 'coder', model: 'provider/bad', modelSource: 'secondary_pool', thinking: 'low', fork: false }, prompt: 'Review the file', }); diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 2b832df77b3..9a89224db86 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -1633,7 +1633,7 @@ describe('Agent tool execution contract', () => { const created = telemetryRecords.filter((record) => record.event === 'subagent_created'); expect(created.length).toBeGreaterThan(0); for (const record of created) { - expect(record.properties).toMatchObject({ fork: true }); + expect(record.properties).toMatchObject({ fork: true, model_source: 'inherited' }); } }); @@ -2023,6 +2023,7 @@ describe('Agent tool execution contract', () => { parentToolCallId: 'call_agent', runInBackground: false, model: 'provider/secondary', + modelSource: 'secondary_pool', }); await mirrorAgentRun( requester, @@ -2052,6 +2053,7 @@ describe('Agent tool execution contract', () => { fork: false, agent_id: 'agent-child', model: 'provider/secondary', + model_source: 'secondary_pool', parent_agent_id: 'main', parent_tool_call_id: 'call_agent', }, @@ -2956,7 +2958,7 @@ describe('AgentSwarm tool execution contract', () => { runInBackground: false, signal, timeout: DEFAULT_SUBAGENT_TIMEOUT_MS, - plan: { profileName: 'explore', model: 'mock-model', thinking: 'off', fork: false }, + plan: { profileName: 'explore', model: 'mock-model', modelSource: 'inherited', thinking: 'off', fork: false }, }, { kind: 'spawn', @@ -2970,7 +2972,7 @@ describe('AgentSwarm tool execution contract', () => { runInBackground: false, signal, timeout: DEFAULT_SUBAGENT_TIMEOUT_MS, - plan: { profileName: 'explore', model: 'mock-model', thinking: 'off', fork: false }, + plan: { profileName: 'explore', model: 'mock-model', modelSource: 'inherited', thinking: 'off', fork: false }, }, ], }); @@ -3033,11 +3035,11 @@ describe('AgentSwarm tool execution contract', () => { tasks: [ expect.objectContaining({ kind: 'spawn', - plan: { profileName: 'explore', model: 'provider/fast', thinking: undefined, fork: false }, + plan: { profileName: 'explore', model: 'provider/fast', modelSource: 'secondary_pool', thinking: undefined, fork: false }, }), expect.objectContaining({ kind: 'spawn', - plan: { profileName: 'explore', model: 'provider/fast', thinking: undefined, fork: false }, + plan: { profileName: 'explore', model: 'provider/fast', modelSource: 'secondary_pool', thinking: undefined, fork: false }, }), ], }), @@ -3094,11 +3096,11 @@ describe('AgentSwarm tool execution contract', () => { tasks: [ expect.objectContaining({ kind: 'spawn', - plan: { profileName: 'explore', model: 'mock-model', thinking: 'off', fork: false }, + plan: { profileName: 'explore', model: 'mock-model', modelSource: 'primary_override', thinking: 'off', fork: false }, }), expect.objectContaining({ kind: 'spawn', - plan: { profileName: 'explore', model: 'mock-model', thinking: 'off', fork: false }, + plan: { profileName: 'explore', model: 'mock-model', modelSource: 'primary_override', thinking: 'off', fork: false }, }), ], }), @@ -3217,7 +3219,7 @@ describe('AgentSwarm tool execution contract', () => { runInBackground: false, signal, timeout: DEFAULT_SUBAGENT_TIMEOUT_MS, - plan: { profileName: 'explore', model: 'mock-model', thinking: 'off', fork: false }, + plan: { profileName: 'explore', model: 'mock-model', modelSource: 'inherited', thinking: 'off', fork: false }, }, ], }); diff --git a/packages/agent-core/src/flags/registry.ts b/packages/agent-core/src/flags/registry.ts index 55903c6d00b..70b86a38dac 100644 --- a/packages/agent-core/src/flags/registry.ts +++ b/packages/agent-core/src/flags/registry.ts @@ -38,7 +38,7 @@ export const FLAG_DEFINITIONS = [ description: 'Let newly spawned subagents use a separately configured secondary model by default, with an explicit primary-model override for quality-sensitive tasks.', env: 'KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', - default: false, + default: true, surface: 'core', }, ] as const satisfies readonly FlagDefinitionInput[]; diff --git a/packages/agent-core/test/agent/basic.test.ts b/packages/agent-core/test/agent/basic.test.ts index 1df4648ecf7..e3c0d0488b5 100644 --- a/packages/agent-core/test/agent/basic.test.ts +++ b/packages/agent-core/test/agent/basic.test.ts @@ -15,9 +15,9 @@ it('creates an independent agent with a scoped experimental flag resolver', () = experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS), }); - // No experimental flags are currently registered, so the scoped resolver - // reports none enabled. - expect(ctx.agent.experimentalFlags.enabledIds()).toEqual([]); + // The secondary-model flag is graduated and enabled by default; other + // registered experimental flags remain disabled. + expect(ctx.agent.experimentalFlags.enabledIds()).toEqual(['secondary-model']); }); it('runs a text-only agent turn from prompt to completion', async () => { diff --git a/packages/agent-core/test/agent/tool.test.ts b/packages/agent-core/test/agent/tool.test.ts index a0d885d14c5..d02690f9187 100644 --- a/packages/agent-core/test/agent/tool.test.ts +++ b/packages/agent-core/test/agent/tool.test.ts @@ -456,7 +456,7 @@ describe('Agent tools', () => { } as unknown as SessionSubagentHost; const ctx = testAgent({ subagentHost, - experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS), + experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS, { 'secondary-model': false }), }); ctx.configure({ tools: ['Agent'] }); diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 52edef997fd..5bf94b07616 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -10,7 +10,7 @@ import type { Agent, AgentOptions } from '../../src/agent'; import { AGENT_WIRE_PROTOCOL_VERSION } from '../../src/agent/records'; import type { KimiConfig } from '../../src/config'; import { ErrorCodes, KimiError } from '../../src/errors'; -import { FlagResolver } from '../../src/flags'; +import { FLAG_DEFINITIONS, FlagResolver } from '../../src/flags'; import { SessionAgentProfileCatalog, type ResolvedAgentProfile } from '../../src/profile'; import type { SDKSessionRPC } from '../../src/rpc'; import { Session } from '../../src/session'; @@ -1153,13 +1153,18 @@ describe('SessionSubagentHost', () => { text: 'Resumed the subagent from its earlier context and carried the task through to completion, then reported a full and detailed technical summary so the parent agent can continue without repeating prior work.', }); - const session = fakeSession(parent.agent, child.agent, { - 'agent-0': { - homedir: '/tmp/kimi-session/agents/agent-0', - type: 'sub', - parentAgentId: 'main', + const session = fakeSession( + parent.agent, + child.agent, + { + 'agent-0': { + homedir: '/tmp/kimi-session/agents/agent-0', + type: 'sub', + parentAgentId: 'main', + }, }, - }); + { experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS, { 'secondary-model': false }) }, + ); const host = new SessionSubagentHost(session, 'main'); const handle = await host.resume('agent-0', { @@ -1180,6 +1185,8 @@ describe('SessionSubagentHost', () => { describe('secondary model binding', () => { const secondaryFlags = () => new FlagResolver({ KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL: '1' }); + const disabledSecondaryFlags = () => + new FlagResolver({}, FLAG_DEFINITIONS, { 'secondary-model': false }); const LONG_SUMMARY = 'Completed the delegated task end to end and reported a technically complete summary so the parent agent can continue without repeating prior work. ' + 'The report covers the investigation, the changes made, and the verification results in enough detail for the caller to act on directly.'; @@ -1278,6 +1285,7 @@ describe('SessionSubagentHost', () => { it('inherits the parent model when the experiment is off', async () => { const { parent, child } = await spawnChild({ + experimentalFlags: disabledSecondaryFlags(), config: { providers: {}, secondaryModel: { model: 'cheap-model' } }, }); expect(child.agent.config.modelAlias).toBe(parent.agent.config.modelAlias); diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index 3fae01040de..e7f7fe769d8 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -352,8 +352,8 @@ describe('KimiHarness config API', () => { 'Let newly spawned subagents use a separately configured secondary model by default, with an explicit primary-model override for quality-sensitive tasks.', surface: 'core', env: 'KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', - defaultEnabled: false, - enabled: false, + defaultEnabled: true, + enabled: true, source: 'default', }, ]); From a9597c92e8000beef75547ae47d4fe659377ed29 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 31 Aug 2026 13:56:50 +0800 Subject: [PATCH 10/12] fix(secondary-model): keep legacy engine opt-in --- packages/agent-core/src/flags/registry.ts | 2 +- packages/agent-core/test/agent/basic.test.ts | 5 ++--- packages/agent-core/test/session/subagent-host.test.ts | 8 ++++++++ packages/node-sdk/test/config.test.ts | 4 ++-- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/agent-core/src/flags/registry.ts b/packages/agent-core/src/flags/registry.ts index 70b86a38dac..55903c6d00b 100644 --- a/packages/agent-core/src/flags/registry.ts +++ b/packages/agent-core/src/flags/registry.ts @@ -38,7 +38,7 @@ export const FLAG_DEFINITIONS = [ description: 'Let newly spawned subagents use a separately configured secondary model by default, with an explicit primary-model override for quality-sensitive tasks.', env: 'KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', - default: true, + default: false, surface: 'core', }, ] as const satisfies readonly FlagDefinitionInput[]; diff --git a/packages/agent-core/test/agent/basic.test.ts b/packages/agent-core/test/agent/basic.test.ts index e3c0d0488b5..e08e52bb6f6 100644 --- a/packages/agent-core/test/agent/basic.test.ts +++ b/packages/agent-core/test/agent/basic.test.ts @@ -15,9 +15,8 @@ it('creates an independent agent with a scoped experimental flag resolver', () = experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS), }); - // The secondary-model flag is graduated and enabled by default; other - // registered experimental flags remain disabled. - expect(ctx.agent.experimentalFlags.enabledIds()).toEqual(['secondary-model']); + // Legacy agent-core keeps the secondary-model experiment disabled by default. + expect(ctx.agent.experimentalFlags.enabledIds()).toEqual([]); }); it('runs a text-only agent turn from prompt to completion', async () => { diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 5bf94b07616..3f903701774 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -1291,6 +1291,14 @@ describe('SessionSubagentHost', () => { expect(child.agent.config.modelAlias).toBe(parent.agent.config.modelAlias); }); + it('keeps the secondary model opt-in by default', async () => { + const { parent, child } = await spawnChild({ + experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS), + config: { providers: {}, secondaryModel: { model: 'cheap-model' } }, + }); + expect(child.agent.config.modelAlias).toBe(parent.agent.config.modelAlias); + }); + it('inherits the parent model for an explicit model: primary choice', async () => { const { parent, child } = await spawnChild({ experimentalFlags: secondaryFlags(), diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index e7f7fe769d8..3fae01040de 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -352,8 +352,8 @@ describe('KimiHarness config API', () => { 'Let newly spawned subagents use a separately configured secondary model by default, with an explicit primary-model override for quality-sensitive tasks.', surface: 'core', env: 'KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', - defaultEnabled: true, - enabled: true, + defaultEnabled: false, + enabled: false, source: 'default', }, ]); From 3bd516a988ffe0ce1e65495547b2c1103fe87d39 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 31 Aug 2026 14:07:14 +0800 Subject: [PATCH 11/12] test(sdk): pin secondary-model engine divergence --- packages/node-sdk/test/v1-v2-parity.test.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/node-sdk/test/v1-v2-parity.test.ts b/packages/node-sdk/test/v1-v2-parity.test.ts index c4be746e495..17d97f2cb00 100644 --- a/packages/node-sdk/test/v1-v2-parity.test.ts +++ b/packages/node-sdk/test/v1-v2-parity.test.ts @@ -39,6 +39,7 @@ import { type BackgroundTaskInfo, type ConfigDiagnostics, type Event, + type ExperimentalFeatureState, type ExportSessionResult, type GoalSnapshot, type GoalToolResult, @@ -189,13 +190,20 @@ const KNOWN_DIFFS = { // v2's flag registry is per-domain and already carries flags v1 does not // have (minidb backend, subagent); v1-only flags would be the symmetric // case. Parity is enforced on the intersection of ids until the registries - // are unified. + // are unified. The secondary-model flag is intentionally default-on only + // in v2, so its resolved and default state are projected away. getExperimentalFeatures: ( - features: readonly { id: string }[], - other: readonly { id: string }[], - ): readonly { id: string }[] => { + features: readonly ExperimentalFeatureState[], + other: readonly ExperimentalFeatureState[], + ): readonly unknown[] => { const otherIds = new Set(other.map((feature) => feature.id)); - return features.filter((feature) => otherIds.has(feature.id)); + return features.filter((feature) => otherIds.has(feature.id)).map((feature) => { + if (feature.id !== 'secondary-model') return feature; + const projected: Record = { ...feature }; + delete projected['defaultEnabled']; + delete projected['enabled']; + return projected; + }); }, // `raw`: the v1 write path carries a passthrough clone of the original // TOML document inside the returned config; the v2 engine keeps the same From 04729c8cbd4ac9709f72f101d1ea5d1684433499 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 31 Aug 2026 15:20:39 +0800 Subject: [PATCH 12/12] fix(tower): honor forced model for reviewers --- docs/zh/configuration/env-vars.md | 2 +- .../src/features/tower/tools/spawn/spawnTool.ts | 16 +++++++++------- .../test/features/tower/tools/spawnTool.test.ts | 6 +++--- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 20b5e890da8..55e9046ae77 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -150,7 +150,7 @@ kimi | `KIMI_CODE_IDENTITY_SLUG` | 协议标识,用于发给第三方 provider 的 `User-Agent` 产品名和 MCP 客户端名,优先级高于 `[identity] slug`。未设置时由名称派生 | 任意非空字符串;会转小写并将连续非字母数字字符折叠为 `-` | | `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` | 是否向模型提供介绍 Kimi Code 自身的内置 Skills,优先级高于 `config.toml` 的 `builtin_product_skills`(默认开启) | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_TUI_FULL_SCREEN` | 启用实验性的 fullscreen alternate-screen 界面:可滚动的 transcript 视口、鼠标选择文本、可点击链接、Ctrl-Shift-F 搜索 | `1` 开启;其他值保持常规内联界面 | -| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | [subagent 模型池](./config-files.md#subagent-模型池)默认启用,在包括交互式 TUI 在内的所有启动方式下生效;设为假值可禁用;master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 会强制启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | +| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | [subagent 模型池](./config-files.md#subagent-模型池) 默认启用,在包括交互式 TUI 在内的所有启动方式下生效;设为假值可禁用;master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 会强制启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK` | 在 `Agent` 和 `AgentSwarm` 工具上启用实验性的 `fork` 参数,让模型可以以调用方 Agent 对话历史的快照而不是空上下文启动 subagent;master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | 所有 MCP server 的全局默认连接超时(毫秒);优先级高于 `config.toml` 的 `[mcp] startup_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `startupTimeoutMs`(默认 `30000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | | `KIMI_MCP_TOOL_TIMEOUT_MS` | 所有 MCP server 的全局默认单次工具调用超时(毫秒);优先级高于 `config.toml` 的 `[mcp] tool_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `toolTimeoutMs`(默认 `60000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | 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 e9eb3984062..f6f2b001059 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 @@ -34,6 +34,7 @@ import { subagentLabels } from '#/session/agentLifecycle/subagentMetadata'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { DEFAULT_SUBAGENT_TIMEOUT_MS, + isSubagentModelForced, resolveSubagentBinding, resolveSubagentThinking, wrapSubagentModelError, @@ -162,13 +163,14 @@ export class TowerSpawnTool implements ITowerSpawnTool { const binding = own.modelAlias === undefined ? undefined - : args.kind === 'reviewer' - ? { model: own.modelAlias, thinking: own.thinkingLevel, modelSource: 'inherited' as const } - : resolveSubagentBinding( - this.config, - this.flags, - { modelAlias: own.modelAlias, thinkingLevel: own.thinkingLevel }, - ); + : resolveSubagentBinding( + this.config, + this.flags, + { modelAlias: own.modelAlias, thinkingLevel: own.thinkingLevel }, + args.kind === 'reviewer' && !isSubagentModelForced(this.config) + ? 'primary' + : undefined, + ); let handle: SubagentHandle; try { handle = await this.launch(prompt, description, toolCallId, controller, binding); 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 c8e668ff449..58047814e94 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 @@ -402,7 +402,7 @@ describe('TowerSpawnTool', () => { }); }); - it('binds reviewers to the tower model when the secondary model is forced', async () => { + it('binds reviewers to the forced secondary model when it is configured', async () => { secondaryModel = { model: 'cheap/fast', force: true }; const result = await execute({ @@ -412,9 +412,9 @@ describe('TowerSpawnTool', () => { }); expect(result.isError).toBeUndefined(); - expect(result.output).toContain('model: kimi-code'); + expect(result.output).toContain('model: cheap/fast'); expect(createAgent).toHaveBeenCalledWith({ - binding: { profile: 'tower-worker', model: 'kimi-code', thinking: 'off' }, + binding: { profile: 'tower-worker', model: 'cheap/fast', thinking: undefined }, labels: { parentAgentId: 'main' }, }); });