diff --git a/.changeset/secondary-model-default-on.md b/.changeset/secondary-model-default-on.md new file mode 100644 index 00000000000..4e635966e1f --- /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 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/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 71d802dd69c..e10a736b1c8 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -196,7 +196,9 @@ 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. +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: 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..c09289c0218 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -196,7 +196,9 @@ subagent 默认继承 main agent 正在运行的模型。`[secondary_model]` 节 ### subagent 模型池 -该功能目前是实验功能,默认关闭。通过 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1` 启用,或使用 master `KIMI_CODE_EXPERIMENTAL_FLAG=1`,在包括交互式 TUI 在内的所有启动方式下生效。实验功能关闭时模型池配置不生效:subagent 继承调用方模型,会话启动也会跳过池校验。 +配置后在包括交互式 TUI 在内的所有启动方式下生效。 + +模型池默认启用,在包括交互式 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..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` | 在包括交互式 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/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/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/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/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 a50eb5c83be..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, @@ -166,7 +167,9 @@ export class TowerSpawnTool implements ITowerSpawnTool { this.config, this.flags, { modelAlias: own.modelAlias, thinkingLevel: own.thinkingLevel }, - args.kind === 'reviewer' ? 'primary' : undefined, + args.kind === 'reviewer' && !isSubagentModelForced(this.config) + ? 'primary' + : undefined, ); let handle: SubagentHandle; try { @@ -303,6 +306,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 9e27bde6c06..f71ed8e5155 100644 --- a/packages/agent-core-v2/src/session/subagent/configSection.ts +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -111,7 +111,10 @@ export function isSubagentModelForced(config: IConfigService): boolean { return config.get(SECONDARY_MODEL_SECTION)?.force === true; } -export function exposesSubagentModelChoice(config: IConfigService, flags: IFlagService): 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; @@ -184,14 +187,16 @@ 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 } { - const enabled = flags.enabled(SECONDARY_MODEL_FLAG_ID); +): { 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) { if (section.models !== undefined) { throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_FORCE_EXCLUDES_MODELS_MESSAGE, { @@ -211,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) { @@ -225,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, { @@ -250,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/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-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 343b93a1f8e..ab870e0fd2a 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, @@ -130,6 +130,14 @@ 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', @@ -1871,13 +1879,15 @@ 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', + modelSource: 'inherited', }); - expect(resolveSubagentBinding(noPool.config, secondaryModelFlags(), own, 'primary')).toEqual({ + expect(resolveSubagentBinding(noPool.config, own, 'primary')).toEqual({ model: 'provider/main', thinking: 'medium', + modelSource: 'primary_override', }); noPool.disposables.dispose(); @@ -1885,34 +1895,38 @@ 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, + modelSource: 'secondary_pool', }); - expect(resolveSubagentBinding(pool.config, secondaryModelFlags(), own, 'provider/smart')).toEqual({ + expect(resolveSubagentBinding(pool.config, own, 'provider/smart')).toEqual({ model: 'provider/smart', thinking: undefined, + modelSource: 'secondary_pool', }); - expect(resolveSubagentBinding(pool.config, secondaryModelFlags(), own, 'primary')).toEqual({ + expect(resolveSubagentBinding(pool.config, own, 'primary')).toEqual({ model: 'provider/main', thinking: 'medium', + modelSource: 'primary_override', }); pool.disposables.dispose(); }); - it('keeps the pool inert while the secondary-model experiment is off', async () => { + 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(resolveSubagentBinding(config, secondaryModelFlags(false), own)).toEqual({ + expect(resolveSubagentBindingWithFlags(config, secondaryModelFlags(false), own)).toEqual({ model: 'provider/main', thinking: 'medium', + modelSource: 'inherited', }); expect(() => - resolveSubagentBinding(config, secondaryModelFlags(false), own, 'provider/fast'), + resolveSubagentBindingWithFlags(config, secondaryModelFlags(false), own, 'provider/fast'), ).toThrow(/no \[secondary_model\.models\] pool is configured/); disposables.dispose(); @@ -1925,15 +1939,17 @@ 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, + modelSource: 'secondary_pool', }); - expect(resolveSubagentBinding(config, secondaryModelFlags(), own, 'primary')).toEqual({ + expect(resolveSubagentBinding(config, own, 'primary')).toEqual({ model: 'provider/main', thinking: 'medium', + modelSource: 'primary_override', }); - 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 +1971,12 @@ 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', + modelSource: 'secondary_pool', }); - 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,9 +1990,10 @@ 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, + modelSource: 'secondary_pool', }); disposables.dispose(); @@ -1988,7 +2006,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 +2020,12 @@ 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, + modelSource: 'forced', }); - expect(() => resolveSubagentBinding(config, secondaryModelFlags(), own, 'primary')).toThrow( + expect(() => resolveSubagentBinding(config, own, 'primary')).toThrow( /Invalid model "primary": \[secondary_model\]\.force is set/, ); @@ -2044,13 +2063,15 @@ 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', + modelSource: 'secondary_pool', }); - expect(resolveSubagentBinding(config, secondaryModelFlags(), own, 'primary')).toEqual({ + expect(resolveSubagentBinding(config, own, 'primary')).toEqual({ model: 'provider/main', thinking: 'medium', + modelSource: 'primary_override', }); disposables.dispose(); @@ -2067,11 +2088,12 @@ 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, + modelSource: 'forced', }); - expect(() => resolveSubagentBinding(config, secondaryModelFlags(), own, 'primary')).toThrow( + expect(() => resolveSubagentBinding(config, own, 'primary')).toThrow( /Invalid model "primary": \[secondary_model\]\.force is set/, ); @@ -2085,7 +2107,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 +2123,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 +2140,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..f5fe2970033 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,7 @@ function realSubagents( agentLifecycle, catalog, config, - flags, + stubFlag(true), modelCatalog, sessionContext, stubLog(), @@ -705,7 +703,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}}', @@ -768,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', @@ -787,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( @@ -804,7 +802,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 +817,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 +838,6 @@ describe('AgentSwarmTool', () => { realSubagents( stubSwarmCatalog(caller), stubConfig(), - stubFlag(true), stubCallerProfile({ profileName: 'deleted-profile', subagents: ['explore'] }), ), stubCallerProfile({ profileName: 'deleted-profile', subagents: ['explore'] }), @@ -907,7 +904,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 +937,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', @@ -1028,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( @@ -1061,7 +1058,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 +1121,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 +1147,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 +1175,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 +1198,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, @@ -1216,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 }, }), ], }), @@ -1228,7 +1225,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, @@ -1244,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 }, }), ], }), @@ -1256,14 +1253,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 +1280,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 +1327,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 +1354,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 +1372,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 +1392,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 +1411,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 +1434,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, @@ -1454,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/features/tower/tools/spawnTool.test.ts b/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts index af01729be2b..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 @@ -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, @@ -42,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; @@ -75,8 +75,9 @@ 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 secondaryModel: + | { readonly model: string; readonly defaultEffort?: string; readonly force?: boolean } + | undefined; let thinkingEnabled: boolean | undefined; let modelMeta: Record>; let createdSetMode: Mock<(mode: PermissionMode) => void>; @@ -101,7 +102,6 @@ describe('TowerSpawnTool', () => { gate = { ok: true }; release = vi.fn(); completion = deferred(); - secondaryFlagOn = false; secondaryModel = undefined; thinkingEnabled = undefined; modelMeta = {}; @@ -119,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() { @@ -182,9 +183,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 +315,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 +330,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 +342,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 +359,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 +376,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 +386,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({ @@ -409,6 +402,23 @@ describe('TowerSpawnTool', () => { }); }); + it('binds reviewers to the forced secondary model when it is configured', 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: cheap/fast'); + expect(createAgent).toHaveBeenCalledWith({ + binding: { profile: 'tower-worker', model: 'cheap/fast', thinking: undefined }, + labels: { parentAgentId: 'main' }, + }); + }); + it('registers a reviewer without a worktree', async () => { const result = await execute({ name: 'reviewer-a', 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..1fbc273bd78 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 { @@ -102,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', @@ -216,15 +216,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); } @@ -258,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', }); @@ -267,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', }); @@ -312,7 +305,6 @@ describe('SessionSubagentService planSpawn and spawn', () => { models: { 'provider/bad': 'broken' }, }, }, - true, ); const error = await planSpawnError(svc, { callerAgentId: CALLER_ID, profileName: 'coder' }); @@ -333,7 +325,6 @@ describe('SessionSubagentService planSpawn and spawn', () => { }, thinking: { enabled: false }, }, - true, ); const plan = await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' }); @@ -341,6 +332,7 @@ describe('SessionSubagentService planSpawn and spawn', () => { expect(plan).toEqual({ profileName: 'coder', model: 'provider/fast', + modelSource: 'secondary_pool', thinking: 'max', fork: false, }); @@ -361,7 +353,6 @@ describe('SessionSubagentService planSpawn and spawn', () => { defaultEffort: 'max', }, }, - true, ); const plan = await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' }); @@ -383,7 +374,6 @@ describe('SessionSubagentService planSpawn and spawn', () => { models: { 'provider/fast': 'fast model' }, }, }, - true, ); const plan = await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' }); @@ -406,7 +396,6 @@ describe('SessionSubagentService planSpawn and spawn', () => { }, thinking: { enabled: false }, }, - true, ); const plan = await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' }); @@ -428,7 +417,6 @@ describe('SessionSubagentService planSpawn and spawn', () => { models: { 'provider/fast': 'fast model' }, }, }, - true, ); const plan = await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' }); @@ -446,7 +434,6 @@ describe('SessionSubagentService planSpawn and spawn', () => { defaultEffort: 'max', }, }, - true, ); const plan = await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' }); @@ -454,25 +441,21 @@ describe('SessionSubagentService planSpawn and spawn', () => { expect(plan).toEqual({ profileName: 'coder', model: 'provider/fast', + modelSource: 'forced', thinking: 'max', fork: false, }); }); - 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' }); expect(plan).toEqual({ profileName: 'coder', model: 'main-model', + modelSource: 'inherited', thinking: 'high', fork: false, }); @@ -504,6 +487,7 @@ describe('SessionSubagentService planSpawn and spawn', () => { expect(plan).toEqual({ profileName: 'orchestrator', model: 'main-model', + modelSource: 'inherited', thinking: 'high', fork: true, }); @@ -566,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', }); }); @@ -611,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`, }); }); @@ -642,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/session/subagent/subagentModelsValidation.test.ts b/packages/agent-core-v2/test/session/subagent/subagentModelsValidation.test.ts index b31a61a90af..17e0d789049 100644 --- a/packages/agent-core-v2/test/session/subagent/subagentModelsValidation.test.ts +++ b/packages/agent-core-v2/test/session/subagent/subagentModelsValidation.test.ts @@ -73,7 +73,7 @@ describe('SessionSubagentModelsValidationService', () => { expect(resolve()).toBeUndefined(); }); - it('is a no-op for a broken pool while the secondary-model experiment is off', () => { + it('skips a broken pool when secondary-model is disabled', () => { setup({ [SECONDARY_MODEL_SECTION]: { defaultModel: 'provider/typo' } }, false); expect(resolve()).toBeUndefined(); }); diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 64866292293..9a89224db86 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, @@ -1644,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' }); } }); @@ -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' } }, }, @@ -2035,6 +2023,7 @@ describe('Agent tool execution contract', () => { parentToolCallId: 'call_agent', runInBackground: false, model: 'provider/secondary', + modelSource: 'secondary_pool', }); await mirrorAgentRun( requester, @@ -2064,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', }, @@ -2859,7 +2849,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 +2883,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', @@ -2968,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', @@ -2982,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 }, }, ], }); @@ -3017,7 +3007,6 @@ describe('AgentSwarm tool execution contract', () => { }; ctx = createTestAgent( swarmServices(swarmService), - secondaryModelFlags(), { initialConfig: { secondaryModel: { @@ -3046,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 }, }), ], }), @@ -3078,7 +3067,6 @@ describe('AgentSwarm tool execution contract', () => { }; ctx = createTestAgent( swarmServices(swarmService), - secondaryModelFlags(), { initialConfig: { secondaryModel: { @@ -3108,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 }, }), ], }), @@ -3231,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/test/agent/basic.test.ts b/packages/agent-core/test/agent/basic.test.ts index 1df4648ecf7..e08e52bb6f6 100644 --- a/packages/agent-core/test/agent/basic.test.ts +++ b/packages/agent-core/test/agent/basic.test.ts @@ -15,8 +15,7 @@ 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. + // Legacy agent-core keeps the secondary-model experiment disabled by default. 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 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..3f903701774 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,15 @@ 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); + }); + + 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); diff --git a/packages/kap-server/test/config.test.ts b/packages/kap-server/test/config.test.ts index 61ba89503a0..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,17 +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('[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/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