diff --git a/.agents/skills/agent-core-dev/config.md b/.agents/skills/agent-core-dev/config.md index 3c4d2402e8..6a801dbe5c 100644 --- a/.agents/skills/agent-core-dev/config.md +++ b/.agents/skills/agent-core-dev/config.md @@ -78,12 +78,21 @@ User config.toml (persisted user preferences) Operational env overlay (e.g. KIMI_MODEL_*, KIMI_CODE_EXPERIMENTAL_*) ↓ Memory per-run intent (CLI flags); never persisted; highest + ↓ +Derived overlays synthesize runtime-only values from the fully resolved inputs ``` `set(domain, patch, target?)` writes the `User` layer (persisted) by default; pass `ConfigTarget.Memory` for a per-run override that is never written to disk. `inspect(domain)` reports the value at each layer. +Effective overlays declare a phase. Environment overlays run before Memory so +the per-run layer remains the highest user/operator input. Derived overlays run +after Memory and may synthesize runtime-only sibling values from the fully +resolved inputs. `[secondary_model]` and `[subagent_models]` use the derived +phase so patched recipes supplied through `ConfigTarget.Memory` materialize the +same model entries as persisted recipes. + ## Layout - `src/config/config.ts` — `IConfigRegistry` / `IConfigService` tokens, `ConfigSection`, `ConfigEffectiveOverlay`, event types. @@ -232,7 +241,7 @@ This means registration order is never a correctness concern — you do not need - `rawSnake` — snake_case clone of the file; the write base, never carries the env overlay. - `raw` — camelCase, env-free; the read/set/replace base. - `validated` — validated `raw`, env-free; the base every live env re-application starts from, so a degraded or removed env value falls back to the file instead of a stale overlay. -- `effective` — `validated` plus the env overlay, recomputed on load/set; `get()`/`getAll()` re-apply the overlay on a fresh `validated` copy per read rather than caching it. +- `effective` — `validated` plus section env bindings and environment overlays, then Memory input, then derived overlays; recomputed on load/set. `get()`/`getAll()` rebuild the same sequence from a fresh `validated` copy per read rather than caching live env values. ### `KIMI_MODEL_*` env overlay @@ -270,3 +279,37 @@ The authoritative, always-current list of registered sections — rendered in th - Reading config / calling `configure(...)` / switching model at runtime must not rewrite `config.toml`; runtime state lives in memory and the session wireRecord, not the file. - Never persist env overlays (`__kimi_env__` / `__kimi_env_model__` / shell API key / experimental env); overlays live only in `effective` / `Memory`. - Registering from an Agent-scope service is fine — the late-registration mechanism keeps validation correct; do not add an eager bootstrap. + +## `[subagent_models]` — named model slots + +When model slots are configured under `[subagent_models]`, the Agent and AgentSwarm +tool schemas dynamically expose the slot names as the `model` parameter enum. +Each slot references a `[models]` alias and can layer patch fields that are +synthesised into a derived model entry (e.g. `__sm__fast`). + +```toml +[subagent_models.fast] +model = "fast-model" +description = "Fast, budget-friendly model. Best for routine / low-risk tasks." +recommended_for = ["routine", "data_processing"] + +[subagent_models.quality] +model = "quality-model" +description = "Highest reasoning quality. Best for architecture, complex logic." +recommended_for = ["architecture", "complex_code"] +default = true + +[subagent_models.large_context] +model = "large-context-model" +description = "Long-context model for codebase-wide refactors." +max_context_size = 262144 +default_effort = "medium" +``` + +Key rules: +- Slot names must match `^[a-zA-Z][a-zA-Z0-9_]*$`, must not be `"primary"` or start with `__`. +- At most one slot may have `default = true`; otherwise the first slot is the default. +- Patch fields (anything other than `model` / `description` / `recommended_for` / `default` — i.e. any ModelOverride field such as `max_output_size`, `max_context_size`, `default_effort`, `capabilities`, etc.) synthesise a derived `[models.__sm__]` entry. User-defined `[models.__sm__*]` entries collide with derived ones and are rejected. +- Memory-layer slot recipes use the same derived synthesis as persisted recipes; Memory remains above config/env input and is never written to disk. +- An explicit tool-call slot overrides the agent type's `model_preference`; the caller can still opt into `"primary"`. +- The derived model IDs are stripped from `/api/v1/models` (kap-server). diff --git a/.changeset/subagent-model-list.md b/.changeset/subagent-model-list.md new file mode 100644 index 0000000000..d630b761de --- /dev/null +++ b/.changeset/subagent-model-list.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add experimental named subagent model slots so the main agent can choose subagent models by task. Configure them under `[subagent_models.]`; effective under `kimi web` and experimental `kimi -p`. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index e8df6e99cf..e80f98fa9d 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -106,6 +106,7 @@ Fields in the config file fall into two categories: **top-level scalars** that d | `telemetry` | `boolean` | `true` | Whether anonymous telemetry is enabled; disabled only when explicitly set to `false` | | `providers` | `table` | `{}` | API provider table → [`providers`](#providers) | | `models` | `table` | — | Model alias table → [`models`](#models) | +| `subagent_models` | `table` | — | Named model slots for subagents → [`subagent_models`](#subagent_models) | | `thinking` | `table` | — | Default parameters for Thinking mode → [`thinking`](#thinking) | | `loop_control` | `table` | — | Agent loop control parameters → [`loop_control`](#loop_control) | | `background` | `table` | — | Background task runtime parameters → [`background`](#background) | @@ -115,7 +116,7 @@ Fields in the config file fall into two categories: **top-level scalars** that d | `permission` | `table` | — | Initial permission rules → [`permission`](#permission) | | `hooks` | `array` | — | Lifecycle hooks; see [Hooks](../customization/hooks.md) | -The following sections cover each of the nested tables in turn: `providers`, `models`, `thinking`, `loop_control`, `background`, `tools`, `image`, `services`, and `permission`. +The following sections cover each of the nested tables in turn: `providers`, `models`, `subagent_models`, `thinking`, `loop_control`, `background`, `tools`, `image`, `services`, and `permission`. ## `providers` @@ -188,12 +189,46 @@ display_name = "Kimi for Coding (custom)" You can also switch models temporarily without touching the config file — by setting `KIMI_MODEL_*` environment variables, the CLI synthesizes a temporary provider in memory that does not persist after restart. See [Define a model from environment variables](./env-vars.md#define-a-model-from-environment-variables-kimi_model). -## `secondary_model` +## `subagent_models` -The secondary model is a second model pointer next to the primary `default_model` — typically a cheaper model that features can bind to when they do not need the main model. Its consumer today is subagent spawning: 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 `"secondary"` (this model) and `"primary"` (the main model). When unset, subagents inherit the main agent's model. +Named subagent model slots let the main agent choose a model for each delegated task from descriptions you provide. Each `[subagent_models.""]` entry points to a configured `[models]` alias. When the experiment is enabled, `Agent` and `AgentSwarm` show the main agent every slot's name, model, description, and recommended uses, together with `primary` for the main model. This feature is experimental and disabled by default. Under `kimi web`, enable it with `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`. Under `kimi -p`, `KIMI_CODE_EXPERIMENTAL_FLAG=1` is already required to select the v2 engine and also enables this feature. The interactive TUI ignores the configuration. +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `model` | `string` | — | Required model id from your configured `[models]` | +| `description` | `string` | — | Required guidance the main agent reads when choosing a slot; describe the model's cost, speed, quality, or other relevant trade-offs | +| `recommended_for` | `string[]` | `[]` | Optional task categories shown alongside the description | +| `default` | `boolean` | `false` | Makes this slot the default when neither the tool call nor the selected agent profile chooses one. At most one slot may set it; without one, the first configured slot is the default | +| Other fields | — | — | Accepts every field of [`[models."".overrides]`](#models) (`max_context_size`, `max_output_size`, `support_efforts`, …) as a patch applied only to subagents using this slot | + +```toml +[subagent_models.fast] +model = "fast-model" +description = "Low-cost, low-latency model for routine searches and formatting." +recommended_for = ["search", "formatting"] +default = true +default_effort = "low" + +[subagent_models.quality] +model = "quality-model" +description = "Highest reasoning quality for architecture and complex code." +recommended_for = ["architecture", "complex_code"] +``` + +An explicit `model` passed to `Agent` or `AgentSwarm` takes priority over the selected agent profile's `model_preference`; the profile preference takes priority over the configured default slot. Pass `primary` to use the caller's main model. A resumed subagent always keeps its existing model. + +Every field besides `model`, `description`, `recommended_for`, and `default` forms a model patch. A patched slot receives a private derived model entry in memory, while an unpatched slot binds its `model` directly. Derived entries are never written to `config.toml` or shown in model pickers. A slot that would collide with a user-owned derived entry such as `[models.__sm__fast]` is rejected and produces a startup warning instead of overwriting or selecting that model. Invalid slots are omitted from the model choices shown to the main agent, so ordinary conversation remains available, while attempts to spawn with the invalid configuration fail closed. + +Slot names must start with a letter and contain only letters, numbers, and underscores. `primary` and names starting with `__` are reserved. + +## `secondary_model` + +The secondary model is the legacy single-slot form of [named subagent models](#subagent_models). It is used only when `[subagent_models]` is empty: newly spawned subagents bind to it by default, and the main agent can choose between `secondary` and `primary`. When neither section is configured, subagents inherit the main agent's model. + +It uses the same experimental flag and surface support described above. + | Field | Type | Default | Description | | --- | --- | --- | --- | | `model` | `string` | — | A model id from your configured `[models]` (any provider, not limited to Kimi models) | diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md index 209711bdca..2640238b6e 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -98,7 +98,7 @@ You are a strict code reviewer. Read the diff, then report findings grouped by s | `description` | yes | What the agent does. Shown to the main Agent when it picks a sub-agent, so write it to guide delegation decisions | | `whenToUse` | no | Extra hint describing when the agent should be used | | `override` | no | Whether this file may replace a same-name built-in Agent. Defaults to `false`; `--agent-file` is already explicit and does not require this field | -| `model_preference` | no | Symbolic default used when `Agent` or `AgentSwarm` spawns this profile: `primary` selects the caller's main model, while `secondary` selects `[secondary_model] model`. An explicit tool-call `model` wins; without either setting, the configured secondary model remains the default. If no secondary model is configured, the subagent inherits the caller's model | +| `model_preference` | no | Named `[subagent_models]` slot used when `Agent` or `AgentSwarm` spawns this profile. `primary` selects the caller's main model; `secondary` selects the legacy `[secondary_model]` slot when named slots are absent. An explicit tool-call `model` takes priority | | `tools` | no | Allowlist of tool names such as `Read` or `Bash`; MCP tools are matched with globs such as `mcp__github__*`. Accepts a YAML list or a comma-separated string (`tools: Read, Grep`). Omit to allow all tools; a lone `*` also allows all tools; an empty list (`tools: []`) disables all tools | | `disallowedTools` | no | Denylist with the same syntax and matching rules, applied after `tools` | | `subagents` | no | Allowlist of sub-agent names this agent may delegate to, with the same syntax as `tools` (YAML list or comma-separated string). Omit to allow every type; a lone `*` also allows all types | @@ -109,7 +109,7 @@ The body is the agent's system prompt, and it is rendered as a template each tim Unknown fields are ignored, so newer files stay readable by older versions. Fields from other agent tools (such as Claude Code's `model` or OpenCode's `mode`) are ignored the same way, the comma-separated `tools` form keeps Claude Code-style agent files loadable, and a missing `name` falls back to the file name so OpenCode-style files load too — a minimal file with `description` and a body works across tools. -`model_preference` applies only to newly spawned subagents when the secondary-model experiment is enabled. Under `kimi web`, set `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`; under experimental `kimi -p`, the required `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it. The TUI currently ignores this field. It never names a concrete model alias, and resumed subagents keep their existing model. The selected preference is shown to the main agent alongside the profile description so it can still pass an explicit `model` when a task needs a different choice. +`model_preference` applies only to newly spawned subagents when the subagent-model experiment is enabled. Under `kimi web`, set `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`; under experimental `kimi -p`, the required `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it. The TUI currently ignores this field. Use a slot name from [`[subagent_models]`](../configuration/config-files.md#subagent_models), not a concrete model alias. An explicit tool-call `model` overrides the preference; resumed subagents keep their existing model. The selected preference is shown to the main agent alongside the profile description so it can still choose a different slot when a task calls for it. A file with invalid content discovered in a directory is skipped with a warning and does not affect other files. A file passed explicitly via `--agent-file` must be valid — otherwise the CLI reports the error and exits. diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index 37972f5116..50ad8a7a97 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` (`"secondary"` for the secondary model configured via `[secondary_model] model`, or `"primary"` for the main model; ignored when resuming; available when the secondary-model experiment is enabled under `kimi web` or experimental `kimi -p`, not in the TUI). An explicit `model` overrides the selected [agent profile's `model_preference`](../customization/agents.md#agent-file-format); without either, the configured secondary model is the default, or the subagent inherits the caller's model when no secondary model is configured. 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` (a named [`[subagent_models]`](../configuration/config-files.md#subagent_models) slot, or `"primary"` for the main model; ignored when resuming; available when the subagent-model experiment is enabled under `kimi web` or experimental `kimi -p`, not in the TUI). The tool description shows each configured slot's model, description, and recommended uses so the main agent can choose according to the task. An explicit `model` overrides the selected [agent profile's `model_preference`](../customization/agents.md#agent-file-format); without either, the slot marked `default` is used, or the first slot when none is marked. If named slots are absent, the legacy `[secondary_model]` configuration supplies `"secondary"` as the default; with neither section configured, the subagent inherits 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 when the secondary-model experiment is enabled under `kimi web` or experimental `kimi -p`, not in the TUI) to run item-spawned subagents on the secondary model configured via `[secondary_model] model` (`"secondary"`) or the main model (`"primary"`). This explicit choice overrides the selected [agent profile's `model_preference`](../customization/agents.md#agent-file-format); without either, the configured secondary model is the default, or the subagent inherits the caller's model when no secondary model is configured. 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. +**`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 the subagent-model experiment is enabled under `kimi web` or experimental `kimi -p`, not in the TUI) to run item-spawned subagents on a named [`[subagent_models]`](../configuration/config-files.md#subagent_models) slot or the main model (`"primary"`). This explicit choice overrides the selected [agent profile's `model_preference`](../customization/agents.md#agent-file-format); without either, the configured default slot (or first slot) is used. If named slots are absent, the legacy `[secondary_model]` configuration supplies `"secondary"` as the default; with neither section configured, subagents 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. 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 be8757e99b..97bd619a3c 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -106,6 +106,7 @@ timeout = 5 | `telemetry` | `boolean` | `true` | 是否启用匿名遥测;显式设为 `false` 时关闭 | | `providers` | `table` | `{}` | API 供应商表 → [`providers`](#providers) | | `models` | `table` | — | 模型别名表 → [`models`](#models) | +| `subagent_models` | `table` | — | 子 Agent 的命名模型槽位 → [`subagent_models`](#subagent_models) | | `thinking` | `table` | — | Thinking 模式默认参数 → [`thinking`](#thinking) | | `loop_control` | `table` | — | Agent 循环控制参数 → [`loop_control`](#loop_control) | | `background` | `table` | — | 后台任务运行参数 → [`background`](#background) | @@ -115,7 +116,7 @@ timeout = 5 | `permission` | `table` | — | 初始权限规则 → [`permission`](#permission) | | `hooks` | `array
` | — | 生命周期 hook,详见 [Hooks](../customization/hooks.md) | -以下各节对 `providers`、`models`、`thinking`、`loop_control`、`background`、`image`、`services`、`permission` 等嵌套表逐一展开。 +以下各节对 `providers`、`models`、`subagent_models`、`thinking`、`loop_control`、`background`、`image`、`services`、`permission` 等嵌套表逐一展开。 ## `providers` @@ -188,12 +189,46 @@ display_name = "Kimi for Coding (custom)" 无需修改配置文件也可以临时切换模型——通过 `KIMI_MODEL_*` 环境变量在内存里合成一个临时供应商,详见[用环境变量定义模型](./env-vars.md#用环境变量定义模型-kimi-model)。 -## `secondary_model` +## `subagent_models` -次主力模型是主模型 `default_model` 之外的第二个模型指针——通常是一个更便宜的模型,供不需要主模型的功能绑定使用。目前的消费者是子 Agent 派生:设置后,新派生的子 Agent(`Agent` / `AgentSwarm`)默认绑定该模型,而不再继承主 Agent 的模型;主 Agent 会被告知每次派生可在 `"secondary"`(该模型)与 `"primary"`(主模型)之间选择。未设置时,子 Agent 继承主 Agent 的模型。 +命名子 Agent 模型槽位允许主 Agent 根据你提供的说明,为每项委派任务自主选择模型。每个 `[subagent_models.""]` 条目指向一个已配置的 `[models]` alias。实验功能启用后,`Agent` 与 `AgentSwarm` 会向主 Agent 展示每个槽位的名称、模型、说明和推荐用途,并同时提供代表主模型的 `primary`。 该功能目前是实验功能,默认关闭。在 `kimi web` 下,通过 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1` 启用;在 `kimi -p` 下,选择 v2 引擎本就需要 `KIMI_CODE_EXPERIMENTAL_FLAG=1`,该 master flag 也会启用本功能。交互式 TUI 会忽略该配置。 +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `model` | `string` | — | 必填,已配置 `[models]` 中的模型 id | +| `description` | `string` | — | 必填,主 Agent 选择槽位时读取的说明;应写明模型在成本、速度、质量或其他方面的取舍 | +| `recommended_for` | `string[]` | `[]` | 可选,与说明一起展示的任务类别 | +| `default` | `boolean` | `false` | 工具调用与所选 Agent profile 都未指定模型时,将此槽位设为默认值。最多一个槽位可设为 `true`;若均未设置,则以首个槽位为默认值 | +| 其他字段 | — | — | 接受 [`[models."".overrides]`](#models) 的全部字段(`max_context_size`、`max_output_size`、`support_efforts` 等),作为仅对使用该槽位的子 Agent 生效的补丁 | + +```toml +[subagent_models.fast] +model = "fast-model" +description = "适合常规搜索与格式整理的低成本、低延迟模型。" +recommended_for = ["search", "formatting"] +default = true +default_effort = "low" + +[subagent_models.quality] +model = "quality-model" +description = "适合架构与复杂代码的高推理质量模型。" +recommended_for = ["architecture", "complex_code"] +``` + +`Agent` 或 `AgentSwarm` 显式传入的 `model` 优先级最高,其次是所选 Agent profile 的 `model_preference`,最后是配置的默认槽位。传入 `primary` 可使用调用方的主模型。恢复已有子 Agent 时始终保留其原模型。 + +除 `model`、`description`、`recommended_for` 和 `default` 外的字段都会形成模型补丁。带补丁的槽位会在内存中获得一个私有派生模型条目;不带补丁的槽位直接绑定 `model`。派生条目不会写入 `config.toml`,也不会出现在模型选择列表中。如果槽位会与 `[models.__sm__fast]` 这类用户模型冲突,该槽位会被拒绝并产生启动警告,不会覆盖或选择用户模型。无效槽位不会出现在主 Agent 看到的模型选项中,因此普通对话仍可继续;使用该无效配置派生子 Agent 时则会安全拒绝。 + +槽位名称必须以字母开头,并且只能包含字母、数字和下划线。`primary` 以及以 `__` 开头的名称为保留名称。 + +## `secondary_model` + +次主力模型是[命名子 Agent 模型](#subagent_models)的旧版单槽位形式,仅在 `[subagent_models]` 为空时使用:新派生的子 Agent 默认绑定该模型,主 Agent 可在 `secondary` 与 `primary` 之间选择。两个配置段均未设置时,子 Agent 继承主 Agent 的模型。 + +该配置使用上文所述的同一实验 flag 与产品支持范围。 + | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `model` | `string` | — | 已配置 `[models]` 中的模型 id(不限 kimi 模型,可用任意供应商) | diff --git a/docs/zh/customization/agents.md b/docs/zh/customization/agents.md index 0dd0a9c8a7..3355207ddb 100644 --- a/docs/zh/customization/agents.md +++ b/docs/zh/customization/agents.md @@ -98,7 +98,7 @@ disallowedTools: | `description` | 是 | Agent 的用途。主 Agent 挑选子 Agent 时会看到,请围绕委派决策来写 | | `whenToUse` | 否 | 补充说明何时应使用该 Agent | | `override` | 否 | 是否允许覆盖同名内置 Agent,默认 `false`。`--agent-file` 属于显式启动意图,无需设置此字段 | -| `model_preference` | 否 | `Agent` 或 `AgentSwarm` 启动该 profile 时的符号默认值:`primary` 选择调用方的主模型,`secondary` 选择 `[secondary_model] model`。工具调用显式传入的 `model` 优先;两者均未设置时,已配置的次主力模型仍为默认值。未配置次主力模型时,子 Agent 继承调用方模型 | +| `model_preference` | 否 | `Agent` 或 `AgentSwarm` 启动该 profile 时使用的 `[subagent_models]` 命名槽位。`primary` 选择调用方的主模型;没有命名槽位时,`secondary` 选择旧版 `[secondary_model]` 槽位。工具调用显式传入的 `model` 优先级更高 | | `tools` | 否 | 工具名允许列表,如 `Read`、`Bash`;MCP 工具用 glob 匹配,如 `mcp__github__*`。支持 YAML 列表或逗号分隔字符串(`tools: Read, Grep`)两种写法。缺省表示允许全部工具;单独的 `*` 同样表示允许全部工具;空列表(`tools: []`)表示禁用全部工具 | | `disallowedTools` | 否 | 禁止列表,写法与匹配规则相同,在 `tools` 之后应用 | | `subagents` | 否 | 允许委派的子 Agent 名称列表,写法与 `tools` 相同(YAML 列表或逗号分隔字符串)。缺省表示可委派所有类型;单独的 `*` 同样表示全部 | @@ -109,7 +109,7 @@ disallowedTools: 未知字段会被忽略,新版本写的文件在旧版本上仍可读取。其他 Agent 工具的字段(如 Claude Code 的 `model`、OpenCode 的 `mode`)同样会被忽略;加上 `tools` 的逗号分隔写法和 `name` 缺省回退到文件名,Claude Code 与 OpenCode 风格的 Agent 文件一般可直接加载 —— 只含 `description` 和正文的最小文件可跨工具通用。 -`model_preference` 仅在次主力模型实验功能启用时对新启动的子 Agent 生效。在 `kimi web` 下,设置 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`;在实验性 `kimi -p` 下,必需的 `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用该功能。TUI 目前会忽略此字段。该字段不用于填写具体模型 alias,已恢复的子 Agent 也会保持原模型。主 Agent 会在 profile 描述中看到这项偏好,因此仍可在某项任务需要不同选择时显式传入 `model`。 +`model_preference` 仅在子 Agent 模型实验功能启用时对新启动的子 Agent 生效。在 `kimi web` 下,设置 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`;在实验性 `kimi -p` 下,必需的 `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用该功能。TUI 目前会忽略此字段。请填写 [`[subagent_models]`](../configuration/config-files.md#subagent_models) 中的槽位名称,而不是具体模型 alias。工具调用显式传入的 `model` 会覆盖该偏好;已恢复的子 Agent 保持原模型。主 Agent 会在 profile 描述中看到这项偏好,因此仍可在任务需要时选择其他槽位。 目录中发现的非法文件会被跳过并告警,不影响其他文件。通过 `--agent-file` 显式传入的文件必须合法 —— 否则 CLI 会报错并退出。 diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index f993fa7bb6..95cb267c36 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)和 `model`(`"secondary"` 表示 `[secondary_model] model` 配置的次主力模型,`"primary"` 表示主模型;resume 时无效;在 `kimi web` 或实验性 `kimi -p` 下启用次主力模型实验功能后可用,TUI 下被忽略)。显式 `model` 会覆盖所选 [Agent profile 的 `model_preference`](../customization/agents.md#agent-文件格式);两者均未设置时,已配置的次主力模型为默认值,未配置时则继承调用方模型。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_models]`](../configuration/config-files.md#subagent_models) 槽位,或表示主模型的 `"primary"`;resume 时无效;在 `kimi web` 或实验性 `kimi -p` 下启用子 Agent 模型实验功能后可用,TUI 下被忽略)。工具描述会向主 Agent 展示每个槽位的模型、说明与推荐用途,使其可根据任务自主选择。显式 `model` 会覆盖所选 [Agent profile 的 `model_preference`](../customization/agents.md#agent-文件格式);两者均未设置时,使用标记为 `default` 的槽位,若未标记则使用首个槽位。没有命名槽位时,旧版 `[secondary_model]` 会提供默认的 `"secondary"`;两个配置段均不存在时,子 Agent 继承调用方模型。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`。传入 `model`(在 `kimi web` 或实验性 `kimi -p` 下启用次主力模型实验功能后可用,TUI 下被忽略)可以让新启动的子 Agent 运行在 `[secondary_model] model` 配置的次主力模型(`"secondary"`)或主模型(`"primary"`)上。这项显式选择会覆盖所选 [Agent profile 的 `model_preference`](../customization/agents.md#agent-文件格式);两者均未设置时,已配置的次主力模型为默认值,未配置时则继承调用方模型。恢复的子 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 调用会立即失败。 +**`AgentSwarm`** 可以从共享的 `prompt_template` 和 `items` 数组启动子 Agent,也可以通过 `resume_agent_ids` 恢复已有子 Agent,或在一次调用中同时使用两者。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个新的子 Agent。传入 `subagent_type` 可以指定整个 swarm 中所有新启动的子 Agent 使用的 profile;省略时默认使用 `coder`。传入 `model`(在 `kimi web` 或实验性 `kimi -p` 下启用子 Agent 模型实验功能后可用,TUI 下被忽略)可以让新启动的子 Agent 使用一个命名 [`[subagent_models]`](../configuration/config-files.md#subagent_models) 槽位或主模型(`"primary"`)。这项显式选择会覆盖所选 [Agent profile 的 `model_preference`](../customization/agents.md#agent-文件格式);两者均未设置时,使用配置的默认槽位,若未设置默认值则使用首个槽位。没有命名槽位时,旧版 `[secondary_model]` 会提供默认的 `"secondary"`;两个配置段均不存在时,子 Agent 继承调用方模型。恢复的子 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/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml index d89be92abe..c9bf5befc1 100644 --- a/packages/agent-core-v2/docs/config-manifest.toml +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -6,9 +6,9 @@ # One [table] per registered config section, in the on-disk config.toml shape # (snake_case keys). Un-commented assignments are registered defaults; # commented "# field: type" lines describe the remaining schema fields. -# Values resolve as: default -> config.toml -> env overlay -> memory. +# Values resolve as: default -> config.toml -> env overlay -> memory -> derived overlay. -# Index (22 sections · 3 overlay(s)) +# Index (23 sections · 4 overlay(s)) # background src/agent/task/configSection.ts # cron src/app/cron/configSection.ts # defaultPermissionMode src/agent/permissionMode/configSection.ts @@ -28,12 +28,14 @@ # secondaryModel src/app/kosongConfig/configSection.ts # services src/app/auth/configSection.ts # subagent src/session/subagent/configSection.ts +# subagentModels src/session/subagent/configSection.ts # task src/agent/task/configSection.ts # thinking src/app/kosongConfig/configSection.ts # tools src/agent/toolPolicy/configSection.ts # (overlay) servicesCredentialEnvOverlay src/app/auth/configSection.ts # (overlay) kimiModelEnvOverlay src/app/kosongConfig/envOverlay.ts # (overlay) secondaryModelOverlay src/app/kosongConfig/secondaryModelOverlay.ts +# (overlay) subagentModelsOverlay src/session/subagent/configSection.ts # ########################################################################## # background @@ -352,6 +354,32 @@ merge_all_available_skills = true [subagent] timeout_ms = 7200000 +# ########################################################################## +# subagentModels (config.toml: subagent_models) +# owner: src/session/subagent/configSection.ts +# scope: core +# hooks: custom fromToml · custom toToml +# ########################################################################## + +[subagent_models] + +# one [subagent_models.""] table per entry: +# [subagent_models.""] + # max_context_size: integer + # max_input_size: integer + # max_output_size: integer + # capabilities: string[] + # display_name: string + # reasoning_key: string + # adaptive_thinking: boolean + # support_efforts: string[] + # default_effort: string + # off_effort: string + # model: string + # description: string + # recommended_for: string[] + # default: boolean + # ########################################################################## # task # owner: src/agent/task/configSection.ts diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 90050ef3ba..271367ead9 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -188,7 +188,7 @@ export interface SessionStateSnapshot { readonly tools?: readonly string[]; readonly disallowedTools?: readonly string[]; readonly subagents?: readonly string[]; - readonly modelPreference?: 'primary' | 'secondary'; + readonly modelPreference?: string; systemPrompt: (context: /* AgentProfileContext — packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts */ { readonly cwd?: string; readonly cwdListing?: string; @@ -254,7 +254,7 @@ export interface SessionStateSnapshot { readonly tools?: readonly string[]; readonly disallowedTools?: readonly string[]; readonly subagents?: readonly string[]; - readonly modelPreference?: 'primary' | 'secondary'; + readonly modelPreference?: string; systemPrompt: (context: /* AgentProfileContext — packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts */ { readonly cwd?: string; readonly cwdListing?: string; diff --git a/packages/agent-core-v2/scripts/gen-config-manifest.mts b/packages/agent-core-v2/scripts/gen-config-manifest.mts index 01662774d1..a80dd677f7 100644 --- a/packages/agent-core-v2/scripts/gen-config-manifest.mts +++ b/packages/agent-core-v2/scripts/gen-config-manifest.mts @@ -314,7 +314,7 @@ export async function buildConfigManifest(): Promise { '# One [table] per registered config section, in the on-disk config.toml shape', '# (snake_case keys). Un-commented assignments are registered defaults;', '# commented "# field: type" lines describe the remaining schema fields.', - '# Values resolve as: default -> config.toml -> env overlay -> memory.', + '# Values resolve as: default -> config.toml -> env overlay -> memory -> derived overlay.', '', `# Index (${sections.length} sections · ${overlays.length} overlay(s))`, ]; diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index 076e646eb3..e2af30d495 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -72,7 +72,12 @@ const ABORT_GRACE_MS = 2_000; const TOOL_OUTPUT_EMPTY = 'Tool output is empty.'; const TOOL_OUTPUT_NON_TEXT = 'Tool returned non-text content.'; -const validators = new WeakMap(); +interface CachedToolArgsValidator { + readonly parameters: Record; + readonly validator: ToolArgsValidator; +} + +const validators = new WeakMap(); export interface ToolExecutionTask { readonly accesses: ToolAccesses; @@ -775,16 +780,25 @@ export function parseToolCallArguments(raw: unknown): { } function validateExecutableToolArgs(tool: ExecutableTool, args: unknown): string | null { - let validator = validators.get(tool); - if (validator === undefined) { + let parameters: Record; + try { + parameters = tool.parameters; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + let cached = validators.get(tool); + if (cached === undefined || cached.parameters !== parameters) { try { - validator = compileToolArgsValidator(tool.parameters); - validators.set(tool, validator); + cached = { + parameters, + validator: compileToolArgsValidator(parameters), + }; + validators.set(tool, cached); } catch (error) { return error instanceof Error ? error.message : String(error); } } - return validateToolArgs(validator, args as JsonType); + return validateToolArgs(cached.validator, args as JsonType); } function toolCallDisplayFieldsFromExecution( diff --git a/packages/agent-core-v2/src/agent/tools/agent-swarm/agent-swarm.ts b/packages/agent-core-v2/src/agent/tools/agent-swarm/agent-swarm.ts index b0f777dd63..918879d52d 100644 --- a/packages/agent-core-v2/src/agent/tools/agent-swarm/agent-swarm.ts +++ b/packages/agent-core-v2/src/agent/tools/agent-swarm/agent-swarm.ts @@ -54,10 +54,12 @@ export const AgentSwarmToolInputSchema = z '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(['secondary', 'primary']) + .string() + .trim() + .min(1) .optional() .describe( - 'Which model to run the item-spawned subagents on: "secondary" = the configured secondary model; "primary" = the main model you are running on (for hard, quality-sensitive tasks). This explicit choice overrides the selected agent type\'s model_preference; without either, secondary is the default when configured. Only effective when a secondary model is configured; otherwise subagents inherit your model. Resumed subagents always keep their own model.', + 'Which model to run the item-spawned subagents on. Choose from the slot names listed under "Available models" in this tool description (e.g. "fast", "quality"), or "primary" for the main model. When named slots are absent, the legacy choices are "secondary" and "primary". This explicit choice overrides the selected agent type\'s model_preference. Only effective when subagent model selection is enabled; otherwise subagents inherit your model. Resumed subagents always keep their own model.', ), }) .strict(); diff --git a/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts b/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts index c69c08ef45..c36f6c3443 100644 --- a/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts @@ -22,6 +22,8 @@ * pattern used by every agent tool. Bound at Agent scope. */ +import { z } from 'zod'; + import { ToolAccesses, type ExecutableToolContext, @@ -32,7 +34,11 @@ import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution' import { toInputJsonSchema } from '#/tool/input-schema'; import { IConfigService } from '#/app/config/config'; import { IFlagService } from '#/app/flag/flag'; -import { ISessionSwarmService, type SessionSwarmTask } from '#/session/swarm/sessionSwarm'; +import { + ISessionSwarmService, + type SessionSwarmModelBinding, + type SessionSwarmTask, +} from '#/session/swarm/sessionSwarm'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { IAgentProfileService } from '#/agent/profile/profile'; import { @@ -44,6 +50,7 @@ import { IAgentSwarmService } from '#/agent/swarm/swarm'; import { buildSubagentModelDescriptions, resolveSubagentBinding, + resolveSubagentModelListForPresentation, resolveSubagentTimeoutMs, } from '#/session/subagent/configSection'; import { @@ -86,7 +93,78 @@ interface SwarmRunResult { export class AgentSwarmTool implements IAgentSwarmTool { declare readonly _serviceBrand: undefined; readonly name = 'AgentSwarm' as const; - readonly parameters: Record = toInputJsonSchema(AgentSwarmToolInputSchema); + + private parametersCache: + | { + readonly kind: 'unavailable' | 'legacy' | 'slots'; + readonly slotNames: readonly string[]; + readonly parameters: Record; + } + | undefined; + + get parameters(): Record { + const slots = resolveSubagentModelListForPresentation(this.config, this.flags); + const kind = + slots === undefined + ? 'unavailable' + : slots === null + ? 'legacy' + : 'slots'; + const cached = this.parametersCache; + if ( + cached?.kind === kind && + (slots === undefined || + slots === null || + (cached.slotNames.length === slots.length && + slots.every((slot, index) => cached.slotNames[index] === slot.name))) + ) { + return cached.parameters; + } + + let parameters: Record; + if (slots === undefined) { + parameters = toInputJsonSchema( + AgentSwarmToolInputSchema.extend({ + model: z + .string() + .trim() + .min(1) + .optional() + .describe( + 'Subagent model selection is unavailable because the configured model slots are invalid. Review the session warning and fix the configuration before spawning subagents.', + ), + }), + ); + } else if (slots !== null) { + const names = [...slots.map((s) => s.name), 'primary']; + const dynamicSchema = AgentSwarmToolInputSchema.extend({ + model: z + .enum(names as [string, ...string[]]) + .optional() + .describe( + 'Which model to run the item-spawned subagents on: "primary" (the main model) or a named slot from the configured subagent models. This explicit choice overrides the selected agent type\'s model_preference; without either, the configured default slot (or the first configured slot) is used. Resumed subagents always keep their own model.', + ), + }); + parameters = toInputJsonSchema(dynamicSchema); + } else { + const legacySchema = AgentSwarmToolInputSchema.extend({ + model: z + .enum(['secondary', 'primary'] as [string, ...string[]]) + .optional() + .describe( + 'Which model to run the item-spawned subagents on: "secondary" = the configured secondary model; "primary" = the main model you are running on (for hard, quality-sensitive tasks). This explicit choice overrides the selected agent type\'s model_preference; without either, secondary is the default when configured. Resumed subagents always keep their own model.', + ), + }); + parameters = toInputJsonSchema(legacySchema); + } + + this.parametersCache = { + kind, + slotNames: slots?.map((slot) => slot.name) ?? [], + parameters, + }; + return parameters; + } private readonly callerAgentId: string; @@ -152,7 +230,7 @@ export class AgentSwarmTool implements IAgentSwarmTool { toolCallId: string, ): Promise { const profileName = normalizeOptionalString(args.subagent_type) ?? DEFAULT_SUBAGENT_TYPE; - let binding: { model: string; thinking?: string } | undefined; + let binding: SessionSwarmModelBinding | undefined; if ((args.items?.length ?? 0) > 0) { await this.catalog.ready; const own = this.profile.data(); @@ -209,7 +287,7 @@ export class AgentSwarmTool implements IAgentSwarmTool { tasks, }); return renderSwarmResults( - results.map(({ task, ...result }) => ({ spec: task.data as AgentSwarmSpec, ...result })), + results.map(({ task, ...result }) => ({ spec: task.data, ...result })), ); } } diff --git a/packages/agent-core-v2/src/agent/tools/agent/agent.ts b/packages/agent-core-v2/src/agent/tools/agent/agent.ts index 8e480bd1ec..3818208351 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agent.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agent.ts @@ -17,6 +17,37 @@ import { type AgentTool } from '#/tool/toolContract'; export const DEFAULT_PROFILE_NAME = 'coder'; export const RESUMED_LABEL = 'subagent'; +export const SubagentToolInputObjectSchema = z.object({ + prompt: z.string().describe('Full task prompt for the subagent'), + description: z.string().describe('Short task description (3-5 words) for UI display'), + subagent_type: z + .string() + .optional() + .describe( + 'One of the available agent types (see "Available agent types" in this tool description). Defaults to "coder" when omitted.', + ), + resume: z + .string() + .optional() + .describe( + 'Optional agent ID to resume instead of creating a new instance. When set, do not also pass subagent_type — the resumed agent keeps its own type, and supplying both is rejected.', + ), + run_in_background: z + .boolean() + .optional() + .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 + .string() + .trim() + .min(1) + .optional() + .describe( + 'Which model to run the subagent on. Choose from the slot names listed under "Available models" in this tool description (e.g. "fast", "quality"), or "primary" for the main model. When named slots are absent, the legacy choices are "secondary" and "primary". This explicit choice overrides the selected agent type\'s model_preference. Only effective when subagent model selection is enabled; otherwise the subagent inherits your model. Ignored when resuming — resumed subagents keep their own model.', + ), +}); + export const SubagentToolInputSchema = z.preprocess( (input) => { if (typeof input !== 'object' || input === null || Array.isArray(input)) { @@ -35,34 +66,7 @@ export const SubagentToolInputSchema = z.preprocess( } return normalized; }, - z.object({ - prompt: z.string().describe('Full task prompt for the subagent'), - description: z.string().describe('Short task description (3-5 words) for UI display'), - subagent_type: z - .string() - .optional() - .describe( - 'One of the available agent types (see "Available agent types" in this tool description). Defaults to "coder" when omitted.', - ), - resume: z - .string() - .optional() - .describe( - 'Optional agent ID to resume instead of creating a new instance. When set, do not also pass subagent_type — the resumed agent keeps its own type, and supplying both is rejected.', - ), - run_in_background: z - .boolean() - .optional() - .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(['secondary', 'primary']) - .optional() - .describe( - 'Which model to run the subagent on: "secondary" = the configured secondary model; "primary" = the main model you are running on (for hard, quality-sensitive tasks). This explicit choice overrides the selected agent type\'s model_preference; without either, secondary is the default when configured. Only effective when a secondary model is configured; otherwise the subagent inherits your model. Ignored when resuming — resumed subagents keep their own model.', - ), - }), + SubagentToolInputObjectSchema, ); export type SubagentToolInput = z.infer; 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 6d4d62b5ea..f4188d4e0e 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -27,6 +27,8 @@ * dynamically registered tools. Bound at Agent scope. */ +import { z } from 'zod'; + import type { IAgentScopeHandle } from '#/_base/di/scope'; import { isAbortError, @@ -83,6 +85,7 @@ import { buildSubagentModelDescriptions, formatSubagentTimeoutDescription, resolveSubagentBinding, + resolveSubagentModelListForPresentation, resolveSubagentTimeoutMs, wrapSubagentModelError, } from '#/session/subagent/configSection'; @@ -94,7 +97,7 @@ import { RESUME_WITH_TYPE_UNAVAILABLE, RESUMED_LABEL, SUBAGENT_STOPPED_MESSAGE, - SubagentToolInputSchema, + SubagentToolInputObjectSchema, USER_INTERRUPTED_SUBAGENT_MESSAGE, type SubagentToolInput, } from './agent'; @@ -107,7 +110,78 @@ import AGENT_DESCRIPTION_BASE from './agent.md?raw'; export class SubagentTool implements ISubagentTool { declare readonly _serviceBrand: undefined; readonly name: string = 'Agent'; - readonly parameters: Record = toInputJsonSchema(SubagentToolInputSchema); + + private parametersCache: + | { + readonly kind: 'unavailable' | 'legacy' | 'slots'; + readonly slotNames: readonly string[]; + readonly parameters: Record; + } + | undefined; + + get parameters(): Record { + const slots = resolveSubagentModelListForPresentation(this.config, this.flags); + const kind = + slots === undefined + ? 'unavailable' + : slots === null + ? 'legacy' + : 'slots'; + const cached = this.parametersCache; + if ( + cached?.kind === kind && + (slots === undefined || + slots === null || + (cached.slotNames.length === slots.length && + slots.every((slot, index) => cached.slotNames[index] === slot.name))) + ) { + return cached.parameters; + } + + let parameters: Record; + if (slots === undefined) { + parameters = toInputJsonSchema( + SubagentToolInputObjectSchema.extend({ + model: z + .string() + .trim() + .min(1) + .optional() + .describe( + 'Subagent model selection is unavailable because the configured model slots are invalid. Review the session warning and fix the configuration before spawning subagents.', + ), + }), + ); + } else if (slots !== null) { + const names = [...slots.map((s) => s.name), 'primary']; + const dynamicSchema = SubagentToolInputObjectSchema.extend({ + model: z + .enum(names as [string, ...string[]]) + .optional() + .describe( + 'Which model to run the subagent on: "primary" (the main model) or a named slot from the configured subagent models. This explicit choice overrides the selected agent type\'s model_preference; without either, the configured default slot (or the first configured slot) is used. Ignored when resuming — resumed subagents keep their own model.', + ), + }); + parameters = toInputJsonSchema(dynamicSchema); + } else { + const legacySchema = SubagentToolInputObjectSchema.extend({ + model: z + .enum(['secondary', 'primary'] as [string, ...string[]]) + .optional() + .describe( + 'Which model to run the subagent on: "secondary" = the configured secondary model; "primary" = the main model you are running on (for hard, quality-sensitive tasks). This explicit choice overrides the selected agent type\'s model_preference; without either, secondary is the default when configured. Ignored when resuming — resumed subagents keep their own model.', + ), + }); + parameters = toInputJsonSchema(legacySchema); + } + + this.parametersCache = { + kind, + slotNames: slots?.map((slot) => slot.name) ?? [], + parameters, + }; + return parameters; + } private readonly callerAgentId: string; private readonly canRunInBackground: () => boolean; @@ -281,7 +355,7 @@ export class SubagentTool implements ISubagentTool { labels: subagentLabels(this.callerAgentId), }); } catch (error) { - throw wrapSubagentModelError(error, binding.model, own.modelAlias); + throw wrapSubagentModelError(error, binding.model, own.modelAlias, binding.source, binding.slotName); } created.accessor.get(IAgentPermissionModeService).setMode(this.permissionMode.mode); created.accessor diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/agentFile.ts b/packages/agent-core-v2/src/app/agentFileCatalog/agentFile.ts index e11b38c9b3..be6afb9b92 100644 --- a/packages/agent-core-v2/src/app/agentFileCatalog/agentFile.ts +++ b/packages/agent-core-v2/src/app/agentFileCatalog/agentFile.ts @@ -34,6 +34,11 @@ export interface ParseAgentFileOptions { const AGENT_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +// Mirrors the `[subagent_models]` slot-name grammar (configSection.ts). The +// parser is pure and cannot know the configured slots, so it validates shape +// only; whether the name resolves to a slot is checked at spawn time. +const MODEL_PREFERENCE_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/; + export function parseAgentFileText(options: ParseAgentFileOptions): AgentFileDefinition { let parsed; try { @@ -118,9 +123,11 @@ function parseModelPreference( filePath: string, ): AgentFileDefinition['modelPreference'] { if (value === undefined || value === null) return undefined; - if (value === 'primary' || value === 'secondary') return value; + if (typeof value === 'string' && MODEL_PREFERENCE_PATTERN.test(value.trim())) { + return value.trim(); + } throw new AgentFileParseError( - `Frontmatter field "model_preference" in ${filePath} must be "primary" or "secondary"`, + `Frontmatter field "model_preference" in ${filePath} must be "primary" or a [subagent_models] slot name (letters, digits, and underscores, starting with a letter)`, ); } diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts index bd88a8bd3e..866b63b0a2 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts @@ -11,8 +11,9 @@ * Every profile is self-contained: `systemPrompt(context)` returns the complete * prompt (base + role overlay are merged at definition time, not at spawn * time). Profiles stay independent of concrete model aliases, but may declare - * a symbolic primary/secondary preference used as the default when spawned as - * a subagent. The builtin {@link DEFAULT_AGENT_PROFILE_NAME} (`agent`) is the + * a model-preference slot name (from `[subagent_models]`, or `'primary'` for + * the caller's model) used as the default when spawned as a subagent. + * The builtin {@link DEFAULT_AGENT_PROFILE_NAME} (`agent`) is the * default profile used when an Agent is bound to a Model without naming a * profile. * @@ -38,7 +39,7 @@ import type { ISessionProcessRunner } from '#/session/process/processRunner'; export const DEFAULT_AGENT_PROFILE_NAME = 'agent'; -export type AgentModelPreference = 'primary' | 'secondary'; +export type AgentModelPreference = string; export interface AgentProfilePromptPrefixContext { readonly cwd: string; diff --git a/packages/agent-core-v2/src/app/config/config.ts b/packages/agent-core-v2/src/app/config/config.ts index 6c51b63a76..f53e5deb2f 100644 --- a/packages/agent-core-v2/src/app/config/config.ts +++ b/packages/agent-core-v2/src/app/config/config.ts @@ -112,6 +112,7 @@ export interface RegisterSectionOptions { } export interface ConfigEffectiveOverlay { + readonly phase?: 'environment' | 'derived'; apply( effective: Record, getEnv: (name: string) => string | undefined, diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index 43675a718a..e438b5e181 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -2,18 +2,19 @@ * `config` domain (L2) — `IConfigRegistry` and `IConfigService` implementations. * * Owns the section registry and the layered global config state: resolves a - * value by precedence across defaults, the user config file, and per-run memory - * overrides (highest, never persisted), and persists writes only for the `User` - * target — validating the merged patch and re-validating the stripped result, - * so a strip can never smuggle an unvalidated raw value (e.g. an env-masked - * invalid field) to disk. Maintains five layered views of a domain — `rawSnake` (snake_case - * write base keyed by the on-disk section key, kept for lossless round-trip), + * value by precedence across defaults, the user config file, environment + * overlays, and per-run memory overrides (highest user input, never persisted), + * then applies derived overlays to the resolved inputs. Persists writes only + * for the `User` target — validating the merged patch and re-validating the + * stripped result, so a strip can never smuggle an unvalidated raw value (e.g. + * an env-masked invalid field) to disk. Maintains five layered views of a + * domain — `rawSnake` (snake_case write base keyed by the on-disk section key, + * kept for lossless round-trip), * `raw` (camelCase, env-free), `validated` (validated `raw`, env-free — the * base every live env re-application starts from and never mutates, so a * degraded or removed env value falls back to the file instead of a stale - * overlay), `effective` - * (`validated` plus the env overlay, recomputed on load/set), and `memory` - * (per-run overrides) + * overlay), `effective` (validated + environment overlays + memory + derived + * overlays, recomputed on load/set), and `memory` (per-run input overrides) * — plus a `delivered` snapshot per domain used as the diff base for * `onDidSectionChange`. Reads config paths and the environment overlay through * `bootstrap`, persists the TOML document through the `storage` TOML @@ -263,9 +264,6 @@ export class ConfigService extends Disposable implements IConfigService { } get(domain: string): T { - if (Object.prototype.hasOwnProperty.call(this.memory, domain)) { - return this.memory[domain] as T; - } return this.freshEffective()[domain] as T; } @@ -280,13 +278,19 @@ export class ConfigService extends Disposable implements IConfigService { } getAll(): ResolvedConfig { - return { ...this.freshEffective(), ...this.memory }; + return this.freshEffective(); } private freshEffective(): ResolvedConfig { + return this.buildEffective(false); + } + + private buildEffective(reportErrors: boolean): ResolvedConfig { const effective: ResolvedConfig = { ...this.validated }; - this.applySectionEnvBindings(effective, false); - this.applyEnvOverlay(effective, false); + this.applySectionEnvBindings(effective, reportErrors); + this.applyEffectiveOverlays(effective, 'environment', reportErrors); + Object.assign(effective, this.memory); + this.applyEffectiveOverlays(effective, 'derived', reportErrors); return effective; } @@ -308,7 +312,7 @@ export class ConfigService extends Disposable implements IConfigService { } else { this.memory[domain] = validated; } - this.commit('set', [domain]); + this.refreshEffective('set', [domain]); return; } await this.enqueueStateTransition(async () => { @@ -339,7 +343,7 @@ export class ConfigService extends Disposable implements IConfigService { } else { this.memory[domain] = this.registry.validate(domain, value); } - this.commit('set', [domain]); + this.refreshEffective('set', [domain]); return; } await this.enqueueStateTransition(async () => { @@ -411,11 +415,16 @@ export class ConfigService extends Disposable implements IConfigService { source: ConfigChangeSource = 'reload', domains?: readonly string[], ): void { - const previous = this.effective; this.validated = this.buildValidated(this.raw); - const next = { ...this.validated }; - this.applySectionEnvBindings(next, true); - this.applyEnvOverlay(next); + this.refreshEffective(source, domains); + } + + private refreshEffective( + source: ConfigChangeSource, + domains?: readonly string[], + ): void { + const previous = this.effective; + const next = this.buildEffective(true); this.effective = next; // Commit candidates: the explicitly touched domains PLUS anything the @@ -435,9 +444,7 @@ export class ConfigService extends Disposable implements IConfigService { } private deliveredValue(domain: string): unknown { - return Object.prototype.hasOwnProperty.call(this.memory, domain) - ? this.memory[domain] - : this.effective[domain]; + return this.effective[domain]; } private commit(source: ConfigChangeSource, domains: readonly string[]): void { @@ -493,18 +500,23 @@ export class ConfigService extends Disposable implements IConfigService { } } - private applyEnvOverlay(effective: ResolvedConfig, reportErrors = true): void { + private applyEffectiveOverlays( + effective: ResolvedConfig, + phase: NonNullable, + reportErrors = true, + ): void { const getEnv = (name: string): string | undefined => this.bootstrap.getEnv(name); const validate = (domain: string, value: unknown): unknown => this.registry.validate(domain, value); for (const overlay of this.registry.listEffectiveOverlays()) { + if ((overlay.phase ?? 'environment') !== phase) continue; try { overlay.apply(effective, getEnv, validate); } catch (error) { if (reportErrors) { this.diagnosticsList.push({ severity: 'warning', - message: `Ignoring config environment overlay: ${describeUnknownError(error)}`, + message: `Ignoring ${phase} config overlay: ${describeUnknownError(error)}`, }); } } @@ -512,13 +524,7 @@ export class ConfigService extends Disposable implements IConfigService { } private reapplyOverlays(): void { - const before = this.effective; - this.validated = this.buildValidated(this.raw); - const next = { ...this.validated }; - this.applySectionEnvBindings(next, true); - this.applyEnvOverlay(next); - this.effective = next; - this.commit('reload', [...new Set([...Object.keys(before), ...Object.keys(next)])]); + this.rebuildEffective('reload'); } private revalidateDomain(domain: string): void { @@ -547,21 +553,7 @@ export class ConfigService extends Disposable implements IConfigService { return; } - this.applyEnvOverlay(this.effective); - if (section.env !== undefined) { - const getEnv = (name: string): string | undefined => this.bootstrap.getEnv(name); - try { - const next = applySectionEnv(this.effective[domain], section.env, getEnv); - this.effective[domain] = this.registry.validate(domain, next); - } catch (error) { - this.diagnosticsList.push({ - domain, - severity: 'warning', - message: `Ignoring env overlay for '${domain}': ${describeUnknownError(error)}`, - }); - } - } - this.commit('reload', [domain]); + this.refreshEffective('reload', [domain]); } private async persist(domain: string): Promise { diff --git a/packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts b/packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts index f58021a862..7b05ec19d4 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts @@ -46,6 +46,10 @@ import { MODELS_SECTION, PROVIDERS_SECTION, } from './configSection'; +import { + isRuntimeOnlyModelRecord, + withoutRuntimeOnlyModels, +} from './runtimeOnlyModels'; /** Persist attempts per write; see `replaceWithRetry` for why this stays small. */ const PERSIST_MAX_ATTEMPTS = 3; @@ -176,14 +180,25 @@ export class KosongConfigService extends Disposable implements IKosongConfigServ private enqueuePersistModels(): Promise { return this.enqueue(async () => { - const next = this.models.list(); - if (deepEqual(this.config.get(MODELS_SECTION) ?? {}, next)) return; + const next = withoutRuntimeOnlyModels(this.models.list()); + const current = withoutRuntimeOnlyModels( + this.config.get(MODELS_SECTION) ?? {}, + ); + if (deepEqual(current, next)) return; await this.replaceWithRetry(MODELS_SECTION, next); }); } private enqueuePersistDefaultPointer(domain: string, value: string | undefined): Promise { return this.enqueue(async () => { + if ( + domain === DEFAULT_MODEL_SECTION && + value !== undefined && + isRuntimeOnlyModelRecord(this.models.get(value)) + ) { + this.reassertEffectiveDefaultModel(value); + return; + } if (this.config.get(domain) === value) return; await this.replaceWithRetry(domain, value); // An effective overlay may pin the section (e.g. `KIMI_MODEL_NAME` @@ -203,13 +218,21 @@ export class KosongConfigService extends Disposable implements IKosongConfigServ .setDefaultProvider(effective) .catch((error) => this.logPersistFailure(error)); } else if (domain === DEFAULT_MODEL_SECTION) { - void this.models - .setDefaultModel(effective) - .catch((error) => this.logPersistFailure(error)); + this.reassertEffectiveDefaultModel(value); } }); } + private reassertEffectiveDefaultModel(current: string | undefined): void { + const effective = this.config.get(DEFAULT_MODEL_SECTION); + if (effective === current) return; + // Fire-and-forget: this is called from the persist chain, and awaiting + // the re-assert would deadlock on its own queued persistence listener. + void this.models + .setDefaultModel(effective) + .catch((error) => this.logPersistFailure(error)); + } + /** * Disk-write failures are rare and usually transient, so a failed persist is * retried with backoff before giving up to the log (via the chain's catch). diff --git a/packages/agent-core-v2/src/app/kosongConfig/runtimeOnlyModels.ts b/packages/agent-core-v2/src/app/kosongConfig/runtimeOnlyModels.ts new file mode 100644 index 0000000000..8a65c2e722 --- /dev/null +++ b/packages/agent-core-v2/src/app/kosongConfig/runtimeOnlyModels.ts @@ -0,0 +1,29 @@ +/** + * Private provenance for model records synthesized by effective overlays. + * + * Object identity is deliberate: provenance never enters the passthrough + * ModelRecord schema or TOML, and a caller that explicitly replaces a + * synthesized record creates ordinary registry state that may be persisted. + */ + +import type { ModelRecord, ModelsSection } from '#/kosong/model/model'; + +const runtimeOnlyRecords = new WeakSet(); + +export function markRuntimeOnlyModelRecord(record: ModelRecord | undefined): void { + if (record !== undefined) runtimeOnlyRecords.add(record); +} + +export function isRuntimeOnlyModelRecord(record: ModelRecord | undefined): boolean { + return record !== undefined && runtimeOnlyRecords.has(record); +} + +export function withoutRuntimeOnlyModels(models: Readonly): ModelsSection { + let result: ModelsSection | undefined; + for (const [id, record] of Object.entries(models)) { + if (!isRuntimeOnlyModelRecord(record)) continue; + result ??= { ...models }; + delete result[id]; + } + return result ?? (models as ModelsSection); +} diff --git a/packages/agent-core-v2/src/app/kosongConfig/secondaryModelOverlay.ts b/packages/agent-core-v2/src/app/kosongConfig/secondaryModelOverlay.ts index 07a7afd3bd..5d39e1f56d 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/secondaryModelOverlay.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/secondaryModelOverlay.ts @@ -32,7 +32,7 @@ import type { ConfigEffectiveOverlay } from '#/app/config/config'; import { registerConfigOverlay } from '#/app/config/configOverlayContributions'; import { isPlainObject } from '#/app/config/toml'; -import type { ModelOverride } from '#/kosong/model/model'; +import { type ModelOverride, type ModelsSection } from '#/kosong/model/model'; import { DEFAULT_MODEL_SECTION, @@ -40,6 +40,9 @@ import { SECONDARY_MODEL_SECTION, type SecondaryModelConfig, } from './configSection'; +import { + markRuntimeOnlyModelRecord, +} from './runtimeOnlyModels'; /** * The reserved registry id of the synthesized derived entry. Listing edges @@ -73,6 +76,7 @@ function withoutKey(value: unknown, key: string): unknown { } export const secondaryModelOverlay: ConfigEffectiveOverlay = { + phase: 'derived', apply(effective, _getEnv, validate) { const secondary = effective[SECONDARY_MODEL_SECTION] as SecondaryModelConfig | undefined; const patch = secondaryModelPatch(secondary); @@ -88,10 +92,12 @@ export const secondaryModelOverlay: ConfigEffectiveOverlay = { ...baseFields, overrides: { ...asRecord(baseOverrides), ...patch }, }; - effective[MODELS_SECTION] = validate(MODELS_SECTION, { + const next = validate(MODELS_SECTION, { ...models, [SECONDARY_DERIVED_MODEL_ID]: derived, - }); + }) as ModelsSection; + markRuntimeOnlyModelRecord(next[SECONDARY_DERIVED_MODEL_ID]); + effective[MODELS_SECTION] = next; return [MODELS_SECTION]; }, diff --git a/packages/agent-core-v2/src/session/subagent/configSection.ts b/packages/agent-core-v2/src/session/subagent/configSection.ts index 7a31d9cc52..c972d29de9 100644 --- a/packages/agent-core-v2/src/session/subagent/configSection.ts +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -1,59 +1,53 @@ /** - * `subagent` domain (L6) — subagent config-section schema, env binding, and - * timeout / model resolution. - * - * Owns the `[subagent]` configuration section (`timeout_ms` on disk) together - * with the `KIMI_SUBAGENT_TIMEOUT_MS` env override, mirroring v1's - * `resolveSubagentTimeoutMs` precedence (env > config.toml > 2h default). While - * the 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`. - * - * The model half of the spawn binding is the secondary model (the section - * and type in `app/kosongConfig` — `[secondary_model]` on disk): when its - * experiment is enabled and the model is 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). A recipe with patch fields binds the - * synthesized derived entry (`SECONDARY_DERIVED_MODEL_ID`); a pointer-only - * recipe binds the pointed entry directly. `default_effort` is passed as the - * explicit subagent thinking; without it the subagent resolves thinking - * naturally (global thinking config → the bound model's default effort) - * rather than inheriting the caller's level. 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. + * `subagent` domain (L6) — subagent config-section schemas, env binding, + * timeout / model resolution, and model-list descriptions. */ import { z } from 'zod'; import { Error2, ErrorCodes, isError2 } from '#/errors'; -import type { AgentModelPreference } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { + type ConfigEffectiveOverlay, + type EnvBindings, + type IConfigService, + envBindings, + stripEnvBoundFields, +} from '#/app/config/config'; +import { registerConfigOverlay } from '#/app/config/configOverlayContributions'; +import { registerConfigSection } from '#/app/config/configSectionContributions'; +import { deepEqual } from '#/app/config/configPure'; +import { + camelToSnake, + cloneRecord, + isPlainObject, + transformPlainObject, +} from '#/app/config/toml'; import type { IFlagService } from '#/app/flag/flag'; import { + DEFAULT_MODEL_SECTION, + MODELS_SECTION, + ModelOverrideSchema, SECONDARY_MODEL_ENV, SECONDARY_MODEL_SECTION, + type SecondaryModelConfig, } from '#/app/kosongConfig/configSection'; import { SECONDARY_DERIVED_MODEL_ID, secondaryModelPatch, } from '#/app/kosongConfig/secondaryModelOverlay'; -import { type SecondaryModelConfig } from '#/app/kosongConfig/configSection'; import { - type EnvBindings, - envBindings, - stripEnvBoundFields, - type IConfigService, -} from '#/app/config/config'; -import { registerConfigSection } from '#/app/config/configSectionContributions'; + markRuntimeOnlyModelRecord, + withoutRuntimeOnlyModels, +} from '#/app/kosongConfig/runtimeOnlyModels'; +import { + type ModelOverride, + type ModelsSection, +} from '#/kosong/model/model'; import { SECONDARY_MODEL_FLAG_ID } from './flag'; +export const DERIVED_MODEL_PREFIX = '__sm__'; + export const SUBAGENT_SECTION = 'subagent'; export const SubagentConfigSchema = z.object({ @@ -62,12 +56,10 @@ export const SubagentConfigSchema = z.object({ export type SubagentConfig = z.infer; -/** Default per-run subagent timeout: 2 hours, same as v1. */ export const DEFAULT_SUBAGENT_TIMEOUT_MS = 2 * 60 * 60 * 1000; export const SUBAGENT_TIMEOUT_ENV = 'KIMI_SUBAGENT_TIMEOUT_MS'; -/** Parse the env override; anything but a positive integer is ignored (v1 semantics). */ function parseTimeoutMsEnv(raw: string): number | undefined { const parsed = Number(raw); return Number.isInteger(parsed) && parsed >= 1 ? parsed : undefined; @@ -88,11 +80,6 @@ registerConfigSection(SUBAGENT_SECTION, SubagentConfigSchema, { stripEnv: stripSubagentEnv, }); -/** - * Resolve the effective per-run subagent timeout. Governs foreground and - * background subagents (and AgentSwarm) through the task manager's per-task - * timeout. - */ export function resolveSubagentTimeoutMs(config: IConfigService): number { return ( config.get(SUBAGENT_SECTION)?.timeoutMs ?? @@ -100,7 +87,138 @@ export function resolveSubagentTimeoutMs(config: IConfigService): number { ); } -export type SubagentModelChoice = AgentModelPreference; +export const SUBAGENT_MODELS_SECTION = 'subagentModels'; + +export const SUBAGENT_MODELS_TOML_KEY = 'subagent_models'; + +const VALID_SLOT_NAME = /^[a-zA-Z][a-zA-Z0-9_]*$/; +const RESERVED_SLOT_NAMES = new Set(['primary']); +const DERIVED_SLOT_PREFIX = '__'; + +export const SubagentModelEntrySchema = ModelOverrideSchema.extend({ + model: z.string().trim().min(1), + description: z.string().trim().min(1), + recommendedFor: z.array(z.string().trim().min(1)).optional(), + default: z.boolean().optional(), +}); + +export const SubagentModelsConfigSchema = z + .record(z.string(), SubagentModelEntrySchema) + .refine( + (map) => { + let defaults = 0; + for (const key of Object.keys(map)) { + if (!VALID_SLOT_NAME.test(key)) return false; + if (RESERVED_SLOT_NAMES.has(key)) return false; + if (key.startsWith(DERIVED_SLOT_PREFIX)) return false; + if (map[key]?.default === true) defaults++; + } + return defaults <= 1; + }, + { + message: + 'Slot names must match /^[a-zA-Z][a-zA-Z0-9_]*$/, must not be "primary" or start with "__", and at most one slot may have default=true', + }, + ); + +export type SubagentModelEntry = z.infer; +export type SubagentModelsConfig = z.infer; + +function subagentModelsFromToml(value: unknown): unknown { + if (!isPlainObject(value)) return value; + const out: Record = {}; + for (const [slotKey, entry] of Object.entries(value)) { + out[slotKey] = isPlainObject(entry) ? transformPlainObject(entry) : entry; + } + return out; +} + +function subagentModelsToToml( + value: unknown, + raw: unknown, +): unknown { + if (!isPlainObject(value)) return value; + const rawSnake = isPlainObject(raw) ? raw : {}; + const out: Record = {}; + for (const [slotKey, entry] of Object.entries(value)) { + if (!isPlainObject(entry)) { + out[slotKey] = entry; + continue; + } + const converted = cloneRecord(rawSnake[slotKey]); + for (const key of Object.keys(SubagentModelEntrySchema.shape)) { + const tomlKey = camelToSnake(key); + const field = entry[key]; + if (field === undefined) { + delete converted[tomlKey]; + } else { + converted[tomlKey] = field; + } + } + out[slotKey] = converted; + } + return out; +} + +registerConfigSection(SUBAGENT_MODELS_SECTION, SubagentModelsConfigSchema, { + fromToml: subagentModelsFromToml, + toToml: subagentModelsToToml, +}); + +export interface SubagentModelSlot { + name: string; + model: string; + baseModel: string; + source: 'legacy-secondary' | 'named-slot'; + thinking?: string; + description: string; + recommendedFor: readonly string[]; + isDefault: boolean; + patchedSupportEfforts?: readonly string[]; +} + +export type ResolvedSubagentModelList = readonly SubagentModelSlot[] | null; + +export type SubagentModelChoice = string; + +export interface SubagentBinding { + model: string; + thinking?: string; + source: 'primary' | 'legacy-secondary' | 'named-slot'; + slotName?: string; +} + +function subagentDerivedId(slotName: string): string { + return `${DERIVED_MODEL_PREFIX}${slotName}`; +} + +function subagentModelCollisionError(slotName: string, derivedId: string): Error2 { + return new Error2( + ErrorCodes.CONFIG_INVALID, + `[subagent_models.${slotName}] would overwrite user-defined model "${derivedId}" — rename or remove the [models.${derivedId}] entry`, + { details: { derivedId, slotName } }, + ); +} + +function findSubagentModelCollision( + config: IConfigService, + entries: SubagentModelsConfig, +): { derivedId: string; slotName: string } | undefined { + const inspection = config.inspect(MODELS_SECTION); + const userModels = asRecord(inspection.userValue); + const memoryModels = asRecord(inspection.memoryValue); + for (const [slotName, entry] of Object.entries(entries)) { + if (namedSubagentModelPatch(entry) === undefined) continue; + const derivedId = subagentDerivedId(slotName); + if ( + Object.hasOwn(userModels, derivedId) || + Object.hasOwn(memoryModels, derivedId) + ) { + return { derivedId, slotName }; + } + } + return undefined; +} export function resolveSecondaryModel( config: IConfigService, @@ -110,23 +228,140 @@ export function resolveSecondaryModel( return config.get(SECONDARY_MODEL_SECTION); } -export function resolveSubagentBinding( +export function resolveSubagentModelList( config: IConfigService, flags: IFlagService, - own: { modelAlias: string; thinkingLevel: string }, - requested?: SubagentModelChoice, -): { model: string; thinking?: string } { +): ResolvedSubagentModelList { + if (!flags.enabled(SECONDARY_MODEL_FLAG_ID)) return null; + + const inspection = config.inspect( + SUBAGENT_MODELS_SECTION, + ); + const map = inspection.value; + if (map === undefined && inspection.userValue !== undefined) { + const diagnostic = config + .diagnostics() + .find((entry) => entry.domain === SUBAGENT_MODELS_SECTION); + throw new Error2( + ErrorCodes.CONFIG_INVALID, + diagnostic?.message ?? + 'Invalid [subagent_models] configuration — review the config diagnostics', + { details: { section: SUBAGENT_MODELS_TOML_KEY } }, + ); + } + if (map !== undefined && Object.keys(map).length > 0) { + const collision = findSubagentModelCollision(config, map); + if (collision !== undefined) { + throw subagentModelCollisionError( + collision.slotName, + collision.derivedId, + ); + } + const slots: SubagentModelSlot[] = []; + for (const [name, entry] of Object.entries(map)) { + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) continue; + const hasPatch = namedSubagentModelPatch(entry) !== undefined; + slots.push({ + name, + model: hasPatch ? subagentDerivedId(name) : entry.model, + baseModel: entry.model, + source: 'named-slot', + thinking: entry.defaultEffort, + description: entry.description, + recommendedFor: entry.recommendedFor ?? [], + isDefault: false, + patchedSupportEfforts: entry.supportEfforts, + }); + } + const explicitDefault = slots.findIndex((s) => map[s.name]?.default === true); + if (explicitDefault >= 0 && explicitDefault < slots.length) { + slots[explicitDefault]!.isDefault = true; + } else if (slots.length > 0) { + slots[0]!.isDefault = true; + } + return slots; + } + const secondary = resolveSecondaryModel(config, flags); - if (requested !== 'primary' && secondary?.model !== undefined) { - return { - model: - secondaryModelPatch(secondary) === undefined + if (secondary?.model !== undefined) { + return [ + { + name: 'secondary', + model: secondaryModelPatch(secondary) === undefined ? secondary.model : SECONDARY_DERIVED_MODEL_ID, - thinking: secondary.defaultEffort, - }; + baseModel: secondary.model, + source: 'legacy-secondary', + thinking: secondary.defaultEffort, + description: 'The configured secondary model; prefer it for routine subagent tasks.', + recommendedFor: [], + isDefault: true, + patchedSupportEfforts: secondary.supportEfforts, + }, + ]; + } + + return null; +} + +export function resolveSubagentModelListForPresentation( + config: IConfigService, + flags: IFlagService, +): ResolvedSubagentModelList | undefined { + try { + return resolveSubagentModelList(config, flags); + } catch (error) { + if (isError2(error) && error.code === ErrorCodes.CONFIG_INVALID) { + return undefined; + } + throw error; + } +} + +export function resolveSubagentBinding( + config: IConfigService, + flags: IFlagService, + own: { modelAlias: string; thinkingLevel: string }, + requestedModelName?: SubagentModelChoice, +): SubagentBinding { + const slots = resolveSubagentModelList(config, flags); + if (slots !== null) { + if (requestedModelName === undefined) { + const def = slots.find((s) => s.isDefault); + if (def !== undefined) { + return { + model: def.model, + thinking: def.thinking, + source: def.source, + slotName: def.name, + }; + } + return { model: own.modelAlias, thinking: own.thinkingLevel, source: 'primary' }; + } + if (requestedModelName === 'primary') { + return { model: own.modelAlias, thinking: own.thinkingLevel, source: 'primary' }; + } + const slot = slots.find((s) => s.name === requestedModelName); + if (slot !== undefined) { + return { + model: slot.model, + thinking: slot.thinking, + source: slot.source, + slotName: slot.name, + }; + } + throw new Error2( + ErrorCodes.CONFIG_INVALID, + `Unknown subagent model slot "${requestedModelName}" — available: ${slots.map((s) => s.name).join(', ')}, primary`, + { + details: { + requestedSlot: requestedModelName, + availableSlots: slots.map((s) => s.name), + }, + }, + ); } - return { model: own.modelAlias, thinking: own.thinkingLevel }; + return { model: own.modelAlias, thinking: own.thinkingLevel, source: 'primary' }; } export function buildSubagentModelDescriptions( @@ -134,46 +369,222 @@ export function buildSubagentModelDescriptions( flags: IFlagService, callerModelAlias: string | undefined, ): string | undefined { - const secondaryModel = resolveSecondaryModel(config, flags)?.model; - if (secondaryModel === undefined || callerModelAlias === undefined) return undefined; - return [ - 'Available models (pass via model):', - `- secondary: ${secondaryModel} (default) — the configured secondary model; prefer it for routine subagent tasks`, + const slots = resolveSubagentModelListForPresentation(config, flags); + if ( + slots === null || + slots === undefined || + callerModelAlias === undefined + ) { + return undefined; + } + + const lines: string[] = ['Available models (pass via model):']; + for (const slot of slots) { + const tags = + slot.recommendedFor.length > 0 + ? ` | Recommended for: ${slot.recommendedFor.join(', ')}` + : ''; + const defaultMark = slot.isDefault ? ' (default)' : ''; + lines.push( + `- ${slot.name}${defaultMark}: ${slot.baseModel}${tags} | ${slot.description}`, + ); + } + lines.push( `- primary: ${callerModelAlias} — the main model you are running on; use it for hard, quality-sensitive subagent tasks`, - ].join('\n'); + ); + return lines.join('\n'); } export function wrapSubagentModelError( error: unknown, boundModel: string, callerModelAlias: string, + source?: SubagentBinding['source'], + slotName?: string, ): unknown { if (boundModel === callerModelAlias) return error; if (!isError2(error) || error.code !== ErrorCodes.CONFIG_INVALID) return error; if (error.details?.['model'] !== boundModel) return error; + + let subagentModelConfig: { section: string; environment?: string }; + let secondaryModelConfig: { section: string; environment?: string } | undefined; + + if (source === 'legacy-secondary') { + secondaryModelConfig = { + section: 'secondaryModel.model', + environment: SECONDARY_MODEL_ENV, + }; + subagentModelConfig = secondaryModelConfig; + } else if (source === 'named-slot' && slotName !== undefined) { + subagentModelConfig = { + section: `[${SUBAGENT_MODELS_TOML_KEY}.${slotName}]`, + }; + } else { + subagentModelConfig = { + section: `[${SUBAGENT_MODELS_TOML_KEY}]`, + }; + } + const displayModel = boundModel === SECONDARY_DERIVED_MODEL_ID ? `the derived entry "${SECONDARY_DERIVED_MODEL_ID}"` - : `"${boundModel}"`; + : boundModel.startsWith(DERIVED_MODEL_PREFIX) + ? `the derived entry for slot "${boundModel.slice(DERIVED_MODEL_PREFIX.length)}"` + : `"${boundModel}"`; + + const details: Record = { + ...error.details, + subagentModel: boundModel, + subagentModelConfig, + ...(secondaryModelConfig !== undefined + ? { secondaryModel: boundModel, secondaryModelConfig } + : {}), + }; + return new Error2( error.code, - `${error.message} (secondary model ${displayModel} comes from [secondary_model].model / ${SECONDARY_MODEL_ENV} — check that it names a valid [models] entry)`, + `${error.message} (subagent model ${displayModel} — check that it names a valid [models] entry)`, { cause: error, name: error.name, - details: { - ...error.details, - secondaryModel: boundModel, - secondaryModelConfig: { - section: 'secondaryModel.model', - environment: SECONDARY_MODEL_ENV, - }, - }, + details, }, ); } -/** Human-readable duration for the subagent timeout message. */ +function asRecord(value: unknown): Record { + return isPlainObject(value) ? value : {}; +} + +function namedSubagentModelPatch( + entry: SubagentModelEntry, +): ModelOverride | undefined { + const { + model: _model, + description: _description, + recommendedFor: _recommendedFor, + default: _default, + ...rawPatch + } = entry; + const patch = Object.fromEntries( + Object.entries(rawPatch).filter(([, value]) => value !== undefined), + ) as ModelOverride; + return Object.keys(patch).length > 0 ? patch : undefined; +} + +function persistedSubagentDerivedRecipes( + rawSnake: Record, +): ReadonlyMap { + const result = new Map(); + const parsed = SubagentModelsConfigSchema.safeParse( + subagentModelsFromToml(rawSnake[SUBAGENT_MODELS_TOML_KEY]), + ); + if (!parsed.success) return result; + for (const [name, entry] of Object.entries(parsed.data)) { + const patch = namedSubagentModelPatch(entry); + if (patch === undefined) continue; + result.set(subagentDerivedId(name), { baseId: entry.model, patch }); + } + return result; +} + +function persistedSubagentDerivedIds( + rawSnake: Record, +): ReadonlySet { + return new Set(persistedSubagentDerivedRecipes(rawSnake).keys()); +} + +function synthesizeDerivedRecord( + base: Record, + patch: ModelOverride, +): Record { + const { overrides: baseOverrides, aliases: _aliases, ...baseFields } = base; + return { ...baseFields, overrides: { ...asRecord(baseOverrides), ...patch } }; +} + +export const subagentModelsOverlay: ConfigEffectiveOverlay = { + phase: 'derived', + apply(effective, _getEnv, validate) { + const entries = effective[SUBAGENT_MODELS_SECTION] as + | SubagentModelsConfig + | undefined; + if (!entries) return []; + + let models = asRecord(effective[MODELS_SECTION]); + let synthesized: Record | undefined; + + for (const [name, entry] of Object.entries(entries)) { + const patch = namedSubagentModelPatch(entry); + if (patch === undefined) continue; + const baseId = entry.model; + if (!baseId) continue; + const base = models[baseId]; + if (!isPlainObject(base)) continue; + const derivedId = subagentDerivedId(name); + if (Object.hasOwn(models, derivedId)) { + throw subagentModelCollisionError(name, derivedId); + } + synthesized ??= { ...models }; + models = synthesized; + models[derivedId] = synthesizeDerivedRecord(base, patch); + } + + if (synthesized === undefined) return []; + const next = validate(MODELS_SECTION, models) as ModelsSection; + for (const [name, entry] of Object.entries(entries)) { + if (namedSubagentModelPatch(entry) === undefined) continue; + markRuntimeOnlyModelRecord(next[subagentDerivedId(name)]); + } + effective[MODELS_SECTION] = next; + return [MODELS_SECTION]; + }, + + strip(domain, value, rawSnake) { + switch (domain) { + case MODELS_SECTION: { + if (!isPlainObject(value)) return value; + const identityStripped = withoutRuntimeOnlyModels(value as ModelsSection); + const recipes = persistedSubagentDerivedRecipes(rawSnake); + if (recipes.size === 0) return identityStripped; + const rawModels = asRecord(rawSnake['models']); + let result: ModelsSection | undefined; + for (const [derivedId, recipe] of recipes) { + if ( + !Object.hasOwn(identityStripped, derivedId) || + Object.hasOwn(rawModels, derivedId) + ) { + continue; + } + const base = identityStripped[recipe.baseId]; + if (!isPlainObject(base)) continue; + const echoed = deepEqual( + identityStripped[derivedId], + synthesizeDerivedRecord(base, recipe.patch), + ); + if (!echoed) continue; + result ??= { ...identityStripped }; + delete result[derivedId]; + } + return result ?? identityStripped; + } + case DEFAULT_MODEL_SECTION: + if ( + typeof value === 'string' && + persistedSubagentDerivedIds(rawSnake).has(value) + ) { + return typeof rawSnake['default_model'] === 'string' + ? rawSnake['default_model'] + : undefined; + } + return value; + default: + return value; + } + }, +}; + +registerConfigOverlay(subagentModelsOverlay); + export function formatSubagentTimeoutDescription(ms: number): string { if (ms % (60 * 60 * 1000) === 0) { const h = ms / (60 * 60 * 1000); diff --git a/packages/agent-core-v2/src/session/subagent/flag.ts b/packages/agent-core-v2/src/session/subagent/flag.ts index 72ba140500..c2964c605a 100644 --- a/packages/agent-core-v2/src/session/subagent/flag.ts +++ b/packages/agent-core-v2/src/session/subagent/flag.ts @@ -2,8 +2,12 @@ * `subagent` domain (L6) — registers the `secondary-model` experimental flag * into `flag`. * - * Gates secondary-model selection for newly spawned subagents, including the - * agent-facing model choices and startup validation warning. Off by default; + * Gates model selection for newly spawned subagents. When enabled and + * `[subagent_models]` is populated, the `Agent`/`AgentSwarm` tools expose a + * choice list (slot names + descriptions) so the parent model can assign + * subagents to named model slots. Falls back to the legacy + * `[secondary_model]` section when `[subagent_models]` is empty. + * Startup validation warnings cover both sections. Off by default; * enable via `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL`, the master * `KIMI_CODE_EXPERIMENTAL_FLAG`, or the `[experimental]` config section. * Imported for its side effect from the package barrel. @@ -16,9 +20,9 @@ export const SECONDARY_MODEL_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL' export const secondaryModelFlag: FlagDefinitionInput = { id: SECONDARY_MODEL_FLAG_ID, - title: 'Secondary model for subagents', + title: 'Subagent model selection', description: - 'Let newly spawned subagents use a separately configured secondary model by default, with an explicit primary-model override for quality-sensitive tasks.', + 'Let newly spawned subagents use a configured model slot from [subagent_models] (or [secondary_model]) instead of always inheriting the caller model, with an explicit primary-model override for quality-sensitive tasks.', env: SECONDARY_MODEL_FLAG_ENV, default: false, surface: 'core', diff --git a/packages/agent-core-v2/src/session/subagent/secondaryModelWarningService.ts b/packages/agent-core-v2/src/session/subagent/secondaryModelWarningService.ts index e2144779e0..1a9d31a6c0 100644 --- a/packages/agent-core-v2/src/session/subagent/secondaryModelWarningService.ts +++ b/packages/agent-core-v2/src/session/subagent/secondaryModelWarningService.ts @@ -1,7 +1,7 @@ /** * `subagent` domain (L6) — `ISessionSecondaryModelWarningService` implementation. * - * When enabled through `flag`, runs the secondary-model check once per session + * When enabled through `flag`, runs the subagent-model check once per session * when the main agent appears (`agentLifecycle` onDidCreate, or an * already-present main at construction): * resolves the pointed entry through the kosong `modelCatalog` and, when the @@ -9,7 +9,7 @@ * `supportEfforts` (what the derived entry will carry) — on failure, caches a * warning and publishes it as a `warning` event on the main agent's * `eventBus`, and stays cached for the edge to pull - * (`GET /sessions/{id}/warnings`). Never throws: a broken secondary model + * (`GET /sessions/{id}/warnings`). Never throws: a broken subagent model * demotes to a notice here, with spawn-time resolution * (`resolveSubagentBinding` + `wrapSubagentModelError`) staying as the * backstop. Bound at Session scope. @@ -30,14 +30,16 @@ import { SECONDARY_MODEL_ENV, } from '#/app/kosongConfig/configSection'; import { IModelCatalog, type Model } from '#/kosong/model/catalog'; -import { secondaryModelPatch } from '#/app/kosongConfig/secondaryModelOverlay'; import { normalizeRequestedThinkingEffort } from '#/kosong/model/thinking'; import { IAgentLifecycleService, MAIN_AGENT_ID, } from '#/session/agentLifecycle/agentLifecycle'; -import { resolveSecondaryModel } from './configSection'; +import { + resolveSubagentModelList, + type ResolvedSubagentModelList, +} from './configSection'; import { ISessionSecondaryModelWarningService, SECONDARY_MODEL_EFFORT_WARNING_CODE, @@ -88,36 +90,66 @@ export class SessionSecondaryModelWarningService } private computeWarning(): SecondaryModelWarning | undefined { - const secondary = resolveSecondaryModel(this.config, this.flags); - if (secondary?.model === undefined) return undefined; - let model: Model; + let slots: ResolvedSubagentModelList; try { - model = this.modelCatalog.get(secondary.model); + slots = resolveSubagentModelList(this.config, this.flags); } catch (error) { return { code: SECONDARY_MODEL_INVALID_WARNING_CODE, message: - `Secondary model "${secondary.model}" (from [secondary_model].model / ${SECONDARY_MODEL_ENV}) ` + - `could not be resolved: ${error instanceof Error ? error.message : String(error)}. ` + - 'Subagent spawning will fail until this is fixed.', + `Subagent model configuration could not be resolved: ` + + formatWarningReason(error), }; } - // The effort check targets what subagents actually bind: with patch - // fields the derived entry carries the patched `supportEfforts`, without - // them the pointed entry's own list applies. - const patch = secondaryModelPatch(secondary); - return effortWarning( - secondary.model, - secondary.defaultEffort, - patch?.supportEfforts ?? model.supportEfforts, - ); + if (slots === null) return undefined; + + for (const slot of slots) { + let model: Model; + try { + model = this.modelCatalog.get(slot.baseModel); + } catch (error) { + const source = + slot.source === 'legacy-secondary' + ? `[secondary_model].model / ${SECONDARY_MODEL_ENV}` + : `[subagent_models.${slot.name}].model`; + return { + code: SECONDARY_MODEL_INVALID_WARNING_CODE, + message: + `Subagent model slot "${slot.name}" points at "${slot.baseModel}"` + + ` (from ${source}) which could not be resolved:` + + ` ${formatWarningReason(error)} ` + + 'Subagent spawning will fail until this is fixed.', + }; + } + const eff = effortWarning( + slot.baseModel, + slot.thinking, + slot.patchedSupportEfforts ?? model.supportEfforts, + slot.source === 'legacy-secondary' + ? { section: '[secondary_model].default_effort', env: SECONDARY_MODEL_EFFORT_ENV } + : { section: `[subagent_models.${slot.name}].default_effort` }, + ); + if (eff !== undefined) return eff; + } + return undefined; } } +function formatWarningReason(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return /[.!?]$/.test(message) ? message : `${message}.`; +} + +interface EffortWarningSource { + section: string; + env?: string; +} + function effortWarning( alias: string, effort: string | undefined, supportEfforts: readonly string[] | undefined, + source: EffortWarningSource, ): SecondaryModelWarning | undefined { const requested = normalizeRequestedThinkingEffort(effort); if (requested === undefined || requested === 'off' || requested === 'on') return undefined; @@ -125,10 +157,14 @@ function effortWarning( .map((entry) => entry.trim()) .filter((entry) => entry.length > 0); if (known.length === 0 || known.includes(requested)) return undefined; + const origin = + source.env !== undefined + ? `${source.section} / ${source.env}` + : source.section; return { code: SECONDARY_MODEL_EFFORT_WARNING_CODE, message: - `Secondary model default effort "${requested}" (from [secondary_model].default_effort / ${SECONDARY_MODEL_EFFORT_ENV}) ` + + `Subagent model effort "${requested}" (from ${origin}) ` + `is not listed for model "${alias}" (known: ${known.join(', ')}). ` + 'Subagents may clamp or reject it.', }; diff --git a/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts b/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts index 1a9e6b3fa1..1bbe439969 100644 --- a/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts +++ b/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts @@ -13,7 +13,11 @@ import { type TokenUsage } from '#/kosong/contract/usage'; import * as retry from 'retry'; import { isUserCancellation } from '#/_base/utils/abort'; -import type { SessionSwarmRunResult, SessionSwarmTask } from './sessionSwarm'; +import type { + SessionSwarmModelBinding, + SessionSwarmRunResult, + SessionSwarmTask, +} from './sessionSwarm'; export interface AgentRunAttemptOptions { @@ -31,7 +35,7 @@ export interface AgentRunAttemptOptions { export interface AgentSpawnAttemptOptions extends AgentRunAttemptOptions { readonly profileName: string; readonly swarmItem?: string; - readonly binding?: { readonly model: string; readonly thinking?: string }; + readonly binding?: SessionSwarmModelBinding; } export type AgentRunAttemptHandle = { @@ -650,4 +654,3 @@ export function resolveSwarmMaxConcurrency( } return value; } - diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts index 4f2fdf0a8a..270908395f 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts @@ -25,10 +25,17 @@ type SessionSwarmTaskBase = { readonly signal?: AbortSignal; }; +export interface SessionSwarmModelBinding { + readonly model: string; + readonly thinking?: string; + readonly source: 'primary' | 'legacy-secondary' | 'named-slot'; + readonly slotName?: string; +} + export type SessionSwarmSpawnTask = SessionSwarmTaskBase & { readonly kind: 'spawn'; readonly resumeAgentId?: undefined; - readonly binding?: { readonly model: string; readonly thinking?: string }; + readonly binding?: SessionSwarmModelBinding; }; 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 00fa9fc11b..111923e8dc 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts @@ -155,6 +155,7 @@ export class SessionSwarmService implements ISessionSwarmService { const binding = options.binding ?? { model: callerData.modelAlias, thinking: callerData.thinkingLevel, + source: 'primary' as const, }; let child: IAgentScopeHandle; try { @@ -169,7 +170,13 @@ export class SessionSwarmService implements ISessionSwarmService { labels: subagentLabels(callerAgentId, { swarmItem: options.swarmItem }), }); } catch (error) { - throw wrapSubagentModelError(error, binding.model, callerData.modelAlias); + throw wrapSubagentModelError( + error, + binding.model, + callerData.modelAlias, + binding.source, + binding.slotName, + ); } child.accessor .get(IAgentPermissionModeService) 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 02a7a24767..4dac1d8696 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": "