From 0a8603b4addcdcf290ddb155992f03ad7efdbbea Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 22 Jul 2026 19:58:34 +0800 Subject: [PATCH 01/18] feat: support a configurable secondary model for subagents --- .changeset/subagent-secondary-model.md | 6 + docs/en/configuration/config-files.md | 4 +- docs/en/configuration/env-vars.md | 2 + docs/en/reference/tools.md | 4 +- docs/zh/configuration/config-files.md | 4 +- docs/zh/configuration/env-vars.md | 2 + docs/zh/reference/tools.md | 4 +- .../src/agent/swarm/tools/agent-swarm.ts | 42 ++- .../src/session/subagent/configSection.ts | 117 +++++++- .../src/session/subagent/tools/agent.ts | 64 ++-- .../src/session/swarm/agentRunBatch.ts | 3 + .../src/session/swarm/sessionSwarm.ts | 6 + .../src/session/swarm/sessionSwarmService.ts | 44 +-- .../test/agent/loop/loop.test.ts | 4 +- .../test/agent/swarm/swarm.test.ts | 79 ++++- .../test/app/config/config.test.ts | 84 ++++++ .../test/session/swarm/sessionSwarm.test.ts | 65 ++++- packages/agent-core-v2/test/tool/tool.test.ts | 276 +++++++++++++++++- 18 files changed, 729 insertions(+), 81 deletions(-) create mode 100644 .changeset/subagent-secondary-model.md diff --git a/.changeset/subagent-secondary-model.md b/.changeset/subagent-secondary-model.md new file mode 100644 index 0000000000..7d239424a1 --- /dev/null +++ b/.changeset/subagent-secondary-model.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core-v2": minor +"@moonshot-ai/kimi-code": minor +--- + +Add a configurable secondary model for subagents: newly spawned subagents bind to it by default instead of inheriting the main agent's model, and the main agent can opt back into the primary model per spawn. Set `[subagent] model` (and optionally `effort`) in `config.toml`, or `KIMI_SUBAGENT_MODEL` / `KIMI_SUBAGENT_EFFORT`. Resumed subagents now keep the model they were created with instead of switching to the parent agent's current model — resuming one requires that model to still be configured. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index f5f65fc190..3a8c39e265 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -241,8 +241,10 @@ In print mode (`kimi -p ""`), Kimi Code stays alive after the main agent | Field | Type | Default | Description | | --- | --- | --- | --- | | `timeout_ms` | `integer` | `7200000` (2 hours) | Maximum wall-clock time (milliseconds) a single subagent (`Agent` / `AgentSwarm`) is allowed to run before it is settled as `timed_out`. `0` means no timeout — the subagent runs until it finishes or the model stops it. This is the background-task manager's per-task timeout for each subagent task, so it applies to both foreground and background subagents. In print mode (`kimi -p`) the default is `0` unless explicitly set. Note: any value above `2147483647` (about 24.8 days) is clamped to roughly 24.8 days by the runtime | +| `model` | `string` | — | Secondary model for subagents: a model alias from your configured `[models]` (any provider, not limited to Kimi models). When set, newly spawned subagents (`Agent` / `AgentSwarm`) bind to it by default instead of inheriting the main agent's model, and the main agent is told it can pick per spawn between `"subagent"` (this model) and `"primary"` (the main model). When unset, subagents inherit the main agent's model | +| `effort` | `string` | — | Thinking effort applied when a subagent binds to the secondary `model`; has no effect on its own. Follows the main model's thinking-effort semantics: models with strict effort validation (e.g. Kimi models) fall back to their default effort for unsupported values; other providers receive the value as-is | -`timeout_ms` can be overridden by the `KIMI_SUBAGENT_TIMEOUT_MS` environment variable, which takes higher priority than `config.toml`. +`timeout_ms` can be overridden by the `KIMI_SUBAGENT_TIMEOUT_MS` environment variable, and `model` / `effort` by `KIMI_SUBAGENT_MODEL` / `KIMI_SUBAGENT_EFFORT`, which take higher priority than `config.toml`. ## `mcp` diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 1ae9413913..7676c40ac5 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -130,6 +130,8 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_SUBAGENT_TIMEOUT_MS` | Maximum wall-clock time (ms) a single subagent (`Agent` / `AgentSwarm`) may run; takes higher priority than `[subagent] timeout_ms` in `config.toml` (default `7200000`, i.e. 2 hours) | Positive integer; invalid values fall back to the config or default | | `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 | +| `KIMI_SUBAGENT_MODEL` | Secondary model for subagents (`Agent` / `AgentSwarm`); takes higher priority than `[subagent] model` in `config.toml`. Newly spawned subagents bind to it by default instead of inheriting the main agent's model | A configured model alias, e.g. `kimi-code/kimi-k2.5`; blank values are ignored | +| `KIMI_SUBAGENT_EFFORT` | Thinking effort for the secondary subagent model; takes higher priority than `[subagent] effort` in `config.toml` and only applies together with the secondary model | An effort value, e.g. `low`; blank values are ignored | | `KIMI_LOOP_MAX_STEPS_PER_TURN` | Maximum Agent steps per turn; takes higher priority than `[loop_control] max_steps_per_turn` in `config.toml` (unset or `0` means unlimited) | Non-negative integer; invalid values are ignored | | `KIMI_LOOP_MAX_RETRIES_PER_STEP` | Maximum retries after a step failure; takes higher priority than `[loop_control] max_retries_per_step` in `config.toml` (default `10`) | Non-negative integer; invalid values are ignored | | `KIMI_WEB_SEARCH_BASE_URL` | API URL of the web search (`WebSearch`) service; takes higher priority than `[services.moonshot_search] base_url` in `config.toml`, and enables the service without that config section. Persisted credentials and custom headers are not forwarded to an env-selected endpoint | Non-blank string; blank values are ignored | diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index 0a5c3ecf50..2da27663d8 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`), and `run_in_background` (defaults to false). 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` (`"subagent"` for the secondary model configured via `[subagent] model` — the default when configured — or `"primary"` for the main model; ignored when resuming). 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`. 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. 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` to run item-spawned subagents on the secondary model configured via `[subagent] model` (`"subagent"`, the default when configured) or the main model (`"primary"`); 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. 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 b135aa289b..708879e3e0 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -241,8 +241,10 @@ display_name = "Kimi for Coding (custom)" | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `timeout_ms` | `integer` | `7200000`(2 小时) | 单个子代理(`Agent` / `AgentSwarm`)允许运行的最长时间(毫秒)。超时后子代理以 `timed_out` 收尾。`0` 表示无超时——子代理一直运行到自行结束或被模型手动停止。该值是后台任务管理器对每个子代理任务的 per-task timeout,因此对前台与后台子代理同时生效。在 print 模式(`kimi -p`)下未显式设置时默认为 `0`。注意:超过 `2147483647`(约 24.8 天)的值会被运行时钳到约 24.8 天 | +| `model` | `string` | — | 子代理的次主力模型:取自已配置 `[models]` 中的模型别名(不限 kimi 模型,可用任意供应商)。设置后,新派生的子代理(`Agent` / `AgentSwarm`)默认绑定该模型,而不再继承主 Agent 的模型;主 Agent 会被告知每次派生可在 `"subagent"`(该模型)与 `"primary"`(主模型)之间选择。未设置时,子代理继承主 Agent 的模型 | +| `effort` | `string` | — | 子代理绑定次主力 `model` 时使用的 thinking effort;单独设置无效。与主模型的 thinking effort 语义一致:严格校验 effort 的模型(如 kimi 模型)在不支持该取值时回退到模型默认 effort,其他供应商的模型按原样发送给后端 | -`timeout_ms` 可被环境变量 `KIMI_SUBAGENT_TIMEOUT_MS` 覆盖,优先级高于配置文件。 +`timeout_ms` 可被环境变量 `KIMI_SUBAGENT_TIMEOUT_MS` 覆盖,`model` / `effort` 可被 `KIMI_SUBAGENT_MODEL` / `KIMI_SUBAGENT_EFFORT` 覆盖,优先级均高于配置文件。 ## `mcp` diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 1e4bba38cc..6be8baaff9 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -130,6 +130,8 @@ kimi | `KIMI_SUBAGENT_TIMEOUT_MS` | 单个子 Agent(`Agent` / `AgentSwarm`)可运行的最长时间(毫秒);优先级高于 `config.toml` 的 `[subagent] timeout_ms`(默认 `7200000`,即 2 小时) | 正整数;非法值回退到配置或默认值 | | `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` 的整数;非法值被忽略 | +| `KIMI_SUBAGENT_MODEL` | 子 Agent 的次主力模型;优先级高于 `config.toml` 的 `[subagent] model`。设置后新派生的子 Agent 默认绑定该模型,而不再继承主 Agent 的模型 | 已配置的模型别名,如 `kimi-code/kimi-k2.5`;空白值被忽略 | +| `KIMI_SUBAGENT_EFFORT` | 次主力子 Agent 模型的 thinking effort;优先级高于 `config.toml` 的 `[subagent] effort`,仅与次主力模型同时生效 | effort 取值,如 `low`;空白值被忽略 | | `KIMI_LOOP_MAX_STEPS_PER_TURN` | Agent 单轮最大步数;优先级高于 `config.toml` 的 `[loop_control] max_steps_per_turn`(不设或 `0` 表示无上限) | 非负整数;非法值被忽略 | | `KIMI_LOOP_MAX_RETRIES_PER_STEP` | 单步失败后的最大重试次数;优先级高于 `config.toml` 的 `[loop_control] max_retries_per_step`(默认 `10`) | 非负整数;非法值被忽略 | | `KIMI_WEB_SEARCH_BASE_URL` | 网页搜索(`WebSearch`)服务的 API URL;优先级高于 `config.toml` 的 `[services.moonshot_search] base_url`,未写配置段时也可启用服务。文件中持久化的凭据和自定义 header 不会发送到环境变量指定的端点 | 非空字符串;空白值被忽略 | diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 3514fda042..d28e1ee80f 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`** 将子任务委托给子 Agent 执行。必填参数:`prompt`(完整任务描述)和 `description`(3–5 个词的简短说明)。可选参数:`subagent_type`(默认 `coder`)、`resume`(恢复已有 Agent 的 ID,与 `subagent_type` 互斥)和 `run_in_background`(默认 false)。Agent 任务默认 2 小时超时,可通过 `config.toml` 的 `[subagent] timeout_ms`(`0` = 无超时,或 `KIMI_SUBAGENT_TIMEOUT_MS` 环境变量)配置,且在 print 模式(`kimi -p`)下默认无超时。前台模式下父 Agent 等待子 Agent 完成再继续;后台模式立即返回任务 ID,完成时通过合成 User 消息自动回到主 Agent。多个前台 `Agent` 调用在同一步运行时,TUI 会合并展示,并为每个子 Agent 显示运行、等待、完成或失败状态以及已耗时长。子 Agent 体系细节见 [Agent 与子 Agent](../customization/agents.md)。 +**`Agent`** 将子任务委托给子 Agent 执行。必填参数:`prompt`(完整任务描述)和 `description`(3–5 个词的简短说明)。可选参数:`subagent_type`(默认 `coder`)、`resume`(恢复已有 Agent 的 ID,与 `subagent_type` 互斥)、`run_in_background`(默认 false)和 `model`(`"subagent"` 表示 `[subagent] model` 配置的次主力模型(配置后为默认值),`"primary"` 表示主模型;resume 时无效)。Agent 任务默认 2 小时超时,可通过 `config.toml` 的 `[subagent] timeout_ms`(`0` = 无超时,或 `KIMI_SUBAGENT_TIMEOUT_MS` 环境变量)配置,且在 print 模式(`kimi -p`)下默认无超时。前台模式下父 Agent 等待子 Agent 完成再继续;后台模式立即返回任务 ID,完成时通过合成 User 消息自动回到主 Agent。多个前台 `Agent` 调用在同一步运行时,TUI 会合并展示,并为每个子 Agent 显示运行、等待、完成或失败状态以及已耗时长。子 Agent 体系细节见 [Agent 与子 Agent](../customization/agents.md)。 -**`AgentSwarm`** 可以从共享的 `prompt_template` 和 `items` 数组启动子 Agent,也可以通过 `resume_agent_ids` 恢复已有子 Agent,或在一次调用中同时使用两者。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个新的子 Agent。传入 `subagent_type` 可以指定整个 swarm 中所有新启动的子 Agent 使用的 profile;省略时默认使用 `coder`。不传 `resume_agent_ids` 时,本工具要求至少 2 个 item;传入 `resume_agent_ids` 时,可以恢复 1 个或多个已有子 Agent。本工具最多支持 128 个子 Agent,会等待全部子 Agent 完成,并返回聚合报告。在 TUI 中,前台 swarm 会在输入框上方显示实时 `Agent swarm` 进度面板。若一次模型响应调用 `AgentSwarm`,该调用必须是该响应中的唯一工具调用;如需运行多个 swarm,应先调用一个 `AgentSwarm` 并等待结果,再调用下一个,若单个模板可以覆盖这些工作,也可以合并为一个 swarm。在 `manual` 权限模式下,未处于 swarm mode 时调用 `AgentSwarm` 会触发审批,除非已有权限规则允许;swarm mode 已开启时,`AgentSwarm` 本身会自动放行。权限规则只能按工具名 `AgentSwarm` 匹配,不支持 `AgentSwarm(swarm)` 这类参数模式。默认情况下,本工具会逐步提升并发且不设上限(立即启动 5 个子 Agent,之后每 700 毫秒再启动 1 个);将 `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` 设为正整数可限制该阶段同时运行的子 Agent 数量,不设置则表示不限制。若设置为非正整数的值,本次 AgentSwarm 调用会立即失败。 +**`AgentSwarm`** 可以从共享的 `prompt_template` 和 `items` 数组启动子 Agent,也可以通过 `resume_agent_ids` 恢复已有子 Agent,或在一次调用中同时使用两者。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个新的子 Agent。传入 `subagent_type` 可以指定整个 swarm 中所有新启动的子 Agent 使用的 profile;省略时默认使用 `coder`。传入 `model` 可以让新启动的子 Agent 运行在 `[subagent] model` 配置的次主力模型(`"subagent"`,配置后为默认值)或主模型(`"primary"`)上;恢复的子 Agent 保持其原有模型。不传 `resume_agent_ids` 时,本工具要求至少 2 个 item;传入 `resume_agent_ids` 时,可以恢复 1 个或多个已有子 Agent。本工具最多支持 128 个子 Agent,会等待全部子 Agent 完成,并返回聚合报告。在 TUI 中,前台 swarm 会在输入框上方显示实时 `Agent swarm` 进度面板。若一次模型响应调用 `AgentSwarm`,该调用必须是该响应中的唯一工具调用;如需运行多个 swarm,应先调用一个 `AgentSwarm` 并等待结果,再调用下一个,若单个模板可以覆盖这些工作,也可以合并为一个 swarm。在 `manual` 权限模式下,未处于 swarm mode 时调用 `AgentSwarm` 会触发审批,除非已有权限规则允许;swarm mode 已开启时,`AgentSwarm` 本身会自动放行。权限规则只能按工具名 `AgentSwarm` 匹配,不支持 `AgentSwarm(swarm)` 这类参数模式。默认情况下,本工具会逐步提升并发且不设上限(立即启动 5 个子 Agent,之后每 700 毫秒再启动 1 个);将 `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` 设为正整数可限制该阶段同时运行的子 Agent 数量,不设置则表示不限制。若设置为非正整数的值,本次 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/swarm/tools/agent-swarm.ts b/packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts index c358dcdc13..c6d400ca0f 100644 --- a/packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts +++ b/packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts @@ -4,8 +4,11 @@ * Launches a batch of child agents (an ordinary Agent scope each) through the * session swarm coordinator and renders the per-subagent XML result. Reads * persisted swarm item labels through the Session-scoped coordinator so later - * `resume_agent_ids` calls relabel resumed subagents like v1. Pure tool — - * owns no scoped state. + * `resume_agent_ids` calls relabel resumed subagents like v1. When the caller + * has a model bound, the tool resolves the spawn binding up front via + * `resolveSubagentBinding` and threads it through the swarm tasks; otherwise + * binding is left to the service, which keeps its own "no model bound" check + * and inherit-caller fallback. Pure tool — owns no scoped state. */ import { z } from 'zod'; @@ -29,7 +32,11 @@ import { } from '#/app/agentProfileCatalog/profile-shared'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentSwarmService } from '#/agent/swarm/swarm'; -import { resolveSubagentTimeoutMs } from '#/session/subagent/configSection'; +import { + buildSubagentModelDescriptions, + resolveSubagentBinding, + resolveSubagentTimeoutMs, +} from '#/session/subagent/configSection'; import AGENT_SWARM_DESCRIPTION from './agent-swarm.md?raw'; const DEFAULT_SUBAGENT_TYPE = 'coder'; @@ -72,6 +79,12 @@ export const AgentSwarmToolInputSchema = z .describe( 'Map of existing subagent agent_id to the prompt used to resume that subagent. These resumed subagents are launched before new item-based subagents.', ), + model: z + .enum(['subagent', 'primary']) + .optional() + .describe( + 'Which model to run the item-spawned subagents on: "subagent" = the configured secondary model (cheaper, the default when configured); "primary" = the main model you are running on (for hard, quality-sensitive tasks). Only effective when a secondary model is configured; otherwise subagents inherit your model. Resumed subagents always keep their own model.', + ), }) .strict(); @@ -105,7 +118,6 @@ interface SwarmRunResult { export class AgentSwarmTool implements BuiltinTool { readonly name = 'AgentSwarm' as const; - readonly description = AGENT_SWARM_DESCRIPTION; readonly parameters: Record = toInputJsonSchema(AgentSwarmToolInputSchema); private readonly callerAgentId: string; @@ -121,6 +133,16 @@ export class AgentSwarmTool implements BuiltinTool { this.callerAgentId = scopeContext.agentId; } + get description(): string { + const modelLines = buildSubagentModelDescriptions( + this.config, + this.profile.data().modelAlias, + ); + return modelLines === undefined + ? AGENT_SWARM_DESCRIPTION + : `${AGENT_SWARM_DESCRIPTION}\n\n${modelLines}`; + } + resolveExecution(args: AgentSwarmToolInput): ToolExecution { const agentCount = (args.items?.length ?? 0) + Object.keys(args.resume_agent_ids ?? {}).length; return { @@ -160,12 +182,21 @@ export class AgentSwarmTool implements BuiltinTool { toolCallId: string, ): Promise { const profileName = normalizeOptionalString(args.subagent_type) ?? DEFAULT_SUBAGENT_TYPE; + let binding: { model: string; thinking: string } | undefined; if ((args.items?.length ?? 0) > 0) { await this.catalog.ready; - const allowlist = subagentAllowlistFor(this.catalog, this.profile.data()); + const own = this.profile.data(); + const allowlist = subagentAllowlistFor(this.catalog, own); if (allowlist !== undefined && !allowlist.includes(profileName)) { throw new Error(subagentTypeNotAllowedMessage(profileName, allowlist)); } + if (own.modelAlias !== undefined) { + binding = resolveSubagentBinding( + this.config, + { modelAlias: own.modelAlias, thinkingLevel: own.thinkingLevel }, + args.model, + ); + } } const timeoutMs = resolveSubagentTimeoutMs(this.config); const specs = await createAgentSwarmSpecs(args, (agentId) => @@ -195,6 +226,7 @@ export class AgentSwarmTool implements BuiltinTool { return { ...common, kind: 'spawn' as const, + binding, }; }); const results = await this.swarmService.run({ diff --git a/packages/agent-core-v2/src/session/subagent/configSection.ts b/packages/agent-core-v2/src/session/subagent/configSection.ts index 2178197edf..950133c263 100644 --- a/packages/agent-core-v2/src/session/subagent/configSection.ts +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -1,21 +1,33 @@ /** * `subagent` domain (L6) — subagent config-section schema, env binding, and - * timeout resolution. + * timeout / model resolution. * - * Owns the `[subagent]` configuration section (`timeout_ms` on disk) together - * with the `KIMI_SUBAGENT_TIMEOUT_MS` env override, mirroring v1's + * Owns the `[subagent]` configuration section (`timeout_ms`, `model`, `effort` + * on disk) together with the `KIMI_SUBAGENT_TIMEOUT_MS`, `KIMI_SUBAGENT_MODEL`, + * and `KIMI_SUBAGENT_EFFORT` env overrides, mirroring v1's * `resolveSubagentTimeoutMs` precedence (env > config.toml > 2h default). While - * the env var is set, `stripEnvBoundFields` restores the env-free raw value + * an env var is set, `stripEnvBoundFields` restores the env-free raw value * before persistence, so the override never leaks into `config.toml`. Both * collaboration tools — `Agent` in this domain and `AgentSwarm` in the `swarm` * domain — resolve their per-run timeout through `resolveSubagentTimeoutMs`, * and render the timeout message with `formatSubagentTimeoutDescription`. - * Self-registered at module load via `registerConfigSection`, so the `config` - * domain never imports this domain's types. + * + * The optional `model`/`effort` pair configures a secondary ("subagent") + * model: when set, newly spawned subagents bind to it by default instead of + * inheriting the caller's model, and the `Agent`/`AgentSwarm` tools let the + * parent model pick per spawn via their `model` parameter. When unset, + * spawning behavior is unchanged (subagents inherit the caller's model). + * Both tools resolve spawn bindings through `resolveSubagentBinding`, + * advertise the pair via `buildSubagentModelDescriptions`, and wrap spawn + * failures with `wrapSubagentModelError`. Self-registered at module load via + * `registerConfigSection`, so the `config` domain never imports this + * domain's types. */ import { z } from 'zod'; +import { isError2 } from '#/_base/errors/errors'; +import { ConfigErrors } from '#/app/config/errors'; import { type EnvBindings, envBindings, @@ -28,6 +40,21 @@ export const SUBAGENT_SECTION = 'subagent'; export const SubagentConfigSchema = z.object({ timeoutMs: z.number().int().min(0).optional(), + /** + * Secondary ("subagent") model alias from the model catalog. When set, newly + * spawned subagents bind to it by default instead of inheriting the caller's + * model; the parent model can still pick per spawn via the tool's `model` + * parameter. Any catalog alias is accepted, so the secondary model is not + * limited to Kimi models. + */ + model: z.string().min(1).optional(), + /** + * Thinking effort for the secondary model. Only takes effect together with + * `model`. Follows the main model's thinking-effort semantics: models with + * strict effort validation fall back to the model's own default when the + * value is unsupported; other providers receive the value as-is. + */ + effort: z.string().min(1).optional(), }); export type SubagentConfig = z.infer; @@ -36,6 +63,8 @@ export type SubagentConfig = z.infer; export const DEFAULT_SUBAGENT_TIMEOUT_MS = 2 * 60 * 60 * 1000; export const SUBAGENT_TIMEOUT_ENV = 'KIMI_SUBAGENT_TIMEOUT_MS'; +export const SUBAGENT_MODEL_ENV = 'KIMI_SUBAGENT_MODEL'; +export const SUBAGENT_EFFORT_ENV = 'KIMI_SUBAGENT_EFFORT'; /** Parse the env override; anything but a positive integer is ignored (v1 semantics). */ function parseTimeoutMsEnv(raw: string): number | undefined { @@ -43,10 +72,18 @@ function parseTimeoutMsEnv(raw: string): number | undefined { return Number.isInteger(parsed) && parsed >= 1 ? parsed : undefined; } +/** Parse an env string override; blank values are ignored. */ +function parseNonEmptyEnv(raw: string): string | undefined { + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + export const subagentEnvBindings: EnvBindings = envBindings( SubagentConfigSchema, { timeoutMs: { env: SUBAGENT_TIMEOUT_ENV, parse: parseTimeoutMsEnv }, + model: { env: SUBAGENT_MODEL_ENV, parse: parseNonEmptyEnv }, + effort: { env: SUBAGENT_EFFORT_ENV, parse: parseNonEmptyEnv }, }, ); @@ -70,6 +107,74 @@ export function resolveSubagentTimeoutMs(config: IConfigService): number { ); } +/** Model choice offered to the parent model when spawning a subagent. */ +export type SubagentModelChoice = 'subagent' | 'primary'; + +/** The secondary model configured for subagents, if any. */ +export function resolveSubagentModel(config: IConfigService): string | undefined { + return config.get(SUBAGENT_SECTION)?.model; +} + +/** + * Resolve the model/thinking a newly spawned subagent should bind to. The + * configured secondary model is the default whenever it is set; `requested` + * is the parent model's per-spawn choice ('primary' opts back into the + * caller's own model). The configured `effort` only applies together with the + * secondary model. Without a configured secondary model, spawning is + * unchanged: the subagent inherits the caller's model and thinking level. + */ +export function resolveSubagentBinding( + config: IConfigService, + own: { modelAlias: string; thinkingLevel: string }, + requested?: SubagentModelChoice, +): { model: string; thinking: string } { + const cfg = config.get(SUBAGENT_SECTION); + if (requested !== 'primary' && cfg?.model !== undefined) { + return { model: cfg.model, thinking: cfg.effort ?? own.thinkingLevel }; + } + return { model: own.modelAlias, thinking: own.thinkingLevel }; +} + +/** + * Advertise the two selectable models when a secondary model is configured. + * Returns undefined when no secondary model is configured or the caller has + * no model bound yet, so the `model` parameter stays undocumented in setups + * where it would have no effect. Shared by the `Agent` and `AgentSwarm` tool + * descriptions. + */ +export function buildSubagentModelDescriptions( + config: IConfigService, + callerModelAlias: string | undefined, +): string | undefined { + const subagentModel = resolveSubagentModel(config); + if (subagentModel === undefined || callerModelAlias === undefined) return undefined; + return [ + 'Available models (pass via model):', + `- subagent: ${subagentModel} (default) — the configured secondary model; cheaper, prefer it for routine subagent tasks`, + `- primary: ${callerModelAlias} — the main model you are running on; use it for hard, quality-sensitive subagent tasks`, + ].join('\n'); +} + +/** + * A spawn failure whose cause is an invalid model configuration almost always + * means a bad `[subagent].model` alias — point at the config knob instead of + * surfacing a bare catalog error. Other failures (MCP bootstrap, storage, + * prompt building) pass through untouched so their real cause is not buried + * under misleading advice. + */ +export function wrapSubagentModelError( + error: unknown, + boundModel: string, + callerModelAlias: string, +): unknown { + if (boundModel === callerModelAlias) return error; + if (!isError2(error) || error.code !== ConfigErrors.codes.CONFIG_INVALID) return error; + return new Error( + `${error.message} (subagent model "${boundModel}" comes from [subagent].model / ${SUBAGENT_MODEL_ENV} — check that it is a valid model alias)`, + { cause: error }, + ); +} + /** Human-readable duration for the subagent timeout message. */ export function formatSubagentTimeoutDescription(ms: number): string { if (ms % (60 * 60 * 1000) === 0) { diff --git a/packages/agent-core-v2/src/session/subagent/tools/agent.ts b/packages/agent-core-v2/src/session/subagent/tools/agent.ts index e98c270ad5..5731e7df3e 100644 --- a/packages/agent-core-v2/src/session/subagent/tools/agent.ts +++ b/packages/agent-core-v2/src/session/subagent/tools/agent.ts @@ -10,6 +10,11 @@ * under TaskList/TaskOutput/TaskStop when `run_in_background=true` or after * detach), and terminal text formatting. * + * Spawn bindings come from `resolveSubagentBinding` (secondary model when + * configured, otherwise the caller's); a resumed agent keeps the model + * recorded in its own wire journal — with per-subagent models there is no + * "child follows the parent's current model" invariant to enforce. + * * Registered via the module-level `registerTool(AgentTool)` at the bottom of * this file — the same "import = register" pattern used by every builtin tool. */ @@ -65,8 +70,11 @@ import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceCo import { emitAgentRunSpawned, mirrorAgentRun } from '../mirrorAgentRun'; import { ISessionSubagentService } from '../subagent'; import { + buildSubagentModelDescriptions, formatSubagentTimeoutDescription, + resolveSubagentBinding, resolveSubagentTimeoutMs, + wrapSubagentModelError, } from '../configSection'; import { SubagentTask, type SubagentHandle } from './subagent-task'; @@ -116,6 +124,12 @@ export const AgentToolInputSchema = z.preprocess( .describe( 'If true, return immediately without waiting for completion. Prefer false unless the task can run independently and there is a clear benefit to not waiting.', ), + model: z + .enum(['subagent', 'primary']) + .optional() + .describe( + 'Which model to run the subagent on: "subagent" = the configured secondary model (cheaper, the default when configured); "primary" = the main model you are running on (for hard, quality-sensitive tasks). Only effective when a secondary model is configured; otherwise the subagent inherits your model. Ignored when resuming — resumed subagents keep their own model.', + ), }), ); @@ -179,7 +193,7 @@ export class AgentTool implements BuiltinTool { const backgroundDescription = this.canRunInBackground() ? AGENT_BACKGROUND_DESCRIPTION : AGENT_BACKGROUND_DISABLED_DESCRIPTION; - const baseDescription = `${AGENT_DESCRIPTION_BASE}\n\n${backgroundDescription}`; + let description = `${AGENT_DESCRIPTION_BASE}\n\n${backgroundDescription}`; const allowlist = subagentAllowlistFor(this.catalog, this.profile.data()); const profiles = allowlist === undefined @@ -191,9 +205,14 @@ export class AgentTool implements BuiltinTool { (profile, name, source) => this.toolPolicy.isToolActiveForProfile(profile, name, source), ); - return typeLines - ? `${baseDescription}\n\nAvailable agent types (pass via subagent_type):\n${typeLines}` - : baseDescription; + if (typeLines) { + description += `\n\nAvailable agent types (pass via subagent_type):\n${typeLines}`; + } + const modelLines = buildSubagentModelDescriptions(this.config, this.profile.data().modelAlias); + if (modelLines !== undefined) { + description += `\n\n${modelLines}`; + } + return description; } async resolveExecution(args: AgentToolInput): Promise { @@ -256,7 +275,6 @@ export class AgentTool implements BuiltinTool { throw new Error(`Agent instance "${resumeAgentId}" does not exist`); } await this.ensureOwnedIdleSubagent(resumeAgentId, target); - this.realignChildModel(target); agentId = target.id; profileName = target.accessor.get(IAgentProfileService).data().profileName ?? RESUMED_LABEL; @@ -277,15 +295,25 @@ export class AgentTool implements BuiltinTool { if (own.modelAlias === undefined) { throw new Error('Caller agent has no model bound'); } - const created = await this.lifecycle.create({ - binding: { - profile: profile.name, - model: own.modelAlias, - thinking: own.thinkingLevel, - cwd: own.cwd, - }, - labels: subagentLabels(this.callerAgentId), - }); + const binding = resolveSubagentBinding( + this.config, + { modelAlias: own.modelAlias, thinkingLevel: own.thinkingLevel }, + args.model, + ); + let created: IAgentScopeHandle; + try { + created = await this.lifecycle.create({ + binding: { + profile: profile.name, + model: binding.model, + thinking: binding.thinking, + cwd: own.cwd, + }, + labels: subagentLabels(this.callerAgentId), + }); + } catch (error) { + throw wrapSubagentModelError(error, binding.model, own.modelAlias); + } created.accessor.get(IAgentPermissionModeService).setMode(this.permissionMode.mode); created.accessor .get(IAgentUserToolService) @@ -343,14 +371,6 @@ export class AgentTool implements BuiltinTool { } } - private realignChildModel(target: IAgentScopeHandle): void { - const modelAlias = this.profile.data().modelAlias; - if (modelAlias === undefined) { - throw new Error('Caller agent has no model bound'); - } - target.accessor.get(IAgentProfileService).update({ modelAlias }); - } - private async execution( args: AgentToolInput, { toolCallId, signal }: ExecutableToolContext, diff --git a/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts b/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts index 2488c37356..11b7553bac 100644 --- a/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts +++ b/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts @@ -31,6 +31,8 @@ export interface AgentRunAttemptOptions { export interface AgentSpawnAttemptOptions extends AgentRunAttemptOptions { readonly profileName: string; readonly swarmItem?: string; + /** Concrete model/thinking to bind; defaults to the caller agent's own. */ + readonly binding?: { readonly model: string; readonly thinking: string }; } export type AgentRunAttemptHandle = { @@ -301,6 +303,7 @@ export class AgentRunBatch { const spawnOptions: AgentSpawnAttemptOptions = { profileName: task.profileName, swarmItem: task.swarmItem, + binding: task.binding, ...runOptions, }; handle = await this.launcher.spawn(spawnOptions); diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts index 7915237aac..50f78e0edf 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts @@ -28,6 +28,12 @@ type SessionSwarmTaskBase = { export type SessionSwarmSpawnTask = SessionSwarmTaskBase & { readonly kind: 'spawn'; readonly resumeAgentId?: undefined; + /** + * Concrete model/thinking the spawned subagent should bind to, resolved by + * the caller (e.g. the `AgentSwarm` tool via `resolveSubagentBinding`). When + * omitted, the spawn inherits the caller agent's model and thinking level. + */ + readonly binding?: { readonly model: string; readonly thinking: string }; }; export type SessionSwarmResumeTask = SessionSwarmTaskBase & { diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts index 889c3fe17f..940d72b7b4 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts @@ -10,7 +10,12 @@ * carrying the swarm's tool-call context, `subagent.suspended` when a task is * requeued after a provider rate limit) are emitted here / via the * `agentLifecycle` wrapper helper `mirrorAgentRun`; the lifecycle registry - * itself stays flat. Bound at Session scope. + * itself stays flat. Spawn tasks may carry a concrete `binding` resolved by + * the caller (the `AgentSwarm` tool via `resolveSubagentBinding`); without + * one, spawns inherit the caller agent's model and thinking level. Resumed + * agents keep the model recorded in their own wire journal — with + * per-subagent models there is no "child follows the parent's current model" + * invariant to enforce. Bound at Session scope. */ import type { TokenUsage } from '#/kosong/contract/usage'; @@ -35,6 +40,7 @@ import { } from '#/session/agentLifecycle/subagentMetadata'; import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/subagent/mirrorAgentRun'; import { ISessionSubagentService } from '#/session/subagent/subagent'; +import { wrapSubagentModelError } from '#/session/subagent/configSection'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata, type AgentMeta } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionProcessRunner } from '#/session/process/processRunner'; @@ -144,15 +150,24 @@ export class SessionSwarmService implements ISessionSwarmService { if (callerData.modelAlias === undefined) { throw new Error('Caller agent has no model bound'); } - const child = await this.lifecycle.create({ - binding: { - profile: profile.name, - model: callerData.modelAlias, - thinking: callerData.thinkingLevel, - cwd: callerData.cwd, - }, - labels: subagentLabels(callerAgentId, { swarmItem: options.swarmItem }), - }); + const binding = options.binding ?? { + model: callerData.modelAlias, + thinking: callerData.thinkingLevel, + }; + let child: IAgentScopeHandle; + try { + child = await this.lifecycle.create({ + binding: { + profile: profile.name, + model: binding.model, + thinking: binding.thinking, + cwd: callerData.cwd, + }, + labels: subagentLabels(callerAgentId, { swarmItem: options.swarmItem }), + }); + } catch (error) { + throw wrapSubagentModelError(error, binding.model, callerData.modelAlias); + } child.accessor .get(IAgentPermissionModeService) .setMode(caller.accessor.get(IAgentPermissionModeService).mode); @@ -189,7 +204,6 @@ export class SessionSwarmService implements ISessionSwarmService { const caller = this.requireHandle(callerAgentId, 'Caller agent'); const child = this.requireHandle(agentId, 'Agent instance'); this.requireIdleSubagent(agentId, child); - this.realignChildModel(caller, child); const profileName = child.accessor.get(IAgentProfileService).data().profileName ?? RESUMED_PROFILE_FALLBACK; if (!retryTurn) { @@ -238,14 +252,6 @@ export class SessionSwarmService implements ISessionSwarmService { return handle; } - private realignChildModel(caller: IAgentScopeHandle, child: IAgentScopeHandle): void { - const modelAlias = caller.accessor.get(IAgentProfileService).data().modelAlias; - if (modelAlias === undefined) { - throw new Error('Caller agent has no model bound'); - } - child.accessor.get(IAgentProfileService).update({ modelAlias }); - } - private requireIdleSubagent(agentId: string, child: IAgentScopeHandle): void { if (child.accessor.get(IAgentLoopService).status().state === 'running') { throw new Error(`Agent instance "${agentId}" is already running and cannot run concurrently`); diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 3fe4f6b895..8f9ef0248a 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -104,8 +104,8 @@ describe('Agent loop', () => { [emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "" } [emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 1, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "