diff --git a/.agents/skills/agent-core-dev/config.md b/.agents/skills/agent-core-dev/config.md index ac00d13135..3c4d2402e8 100644 --- a/.agents/skills/agent-core-dev/config.md +++ b/.agents/skills/agent-core-dev/config.md @@ -92,7 +92,7 @@ pass `ConfigTarget.Memory` for a per-run override that is never written to disk. - `src/profile/thinking.ts` (owner domain, not `config`) — the `resolveThinkingEffort` helper; uses the authoritative `ThinkingConfig` from `configSection.ts`. - `src/config/configPure.ts` — `isPlainObject`, `deepMerge`, `omitUndefined`, `describeUnknownError`. -A domain that owns a section keeps the schema in its own `configSection.ts` (e.g. `src/flag/flag.ts` for `experimental`, `src/loop/configSection.ts` for `loopControl`). Exception: kosong-owned sections (`providers`, `models`, `thinking`) — kosong is a pure, persistence-free abstraction layer that defines only the types (`src/kosong/{provider,model}`); the section constants, the zod schemas (re-derived from those types and compile-time pinned via `AssertExact, Type>>`, see `_base/utils/typeEquality.ts`), the registrations, env bindings, and TOML transforms all live in the persistence wrapper `src/app/kosongConfig/configSection.ts`. (`modelCatalog` has no kosong-side type at all — its section is fully self-contained in `app/kosongConfig`.) A cross-section env overlay (e.g. the `KIMI_MODEL_*` synthesis) lives in the wrapper too (`src/app/kosongConfig/envOverlay.ts`) and is registered via `IConfigRegistry.registerEffectiveOverlay`. The two-way sync between config sections and kosong's in-memory registries is owned by `IKosongConfigService` (`src/app/kosongConfig/kosongConfigService.ts`). +A domain that owns a section keeps the schema in its own `configSection.ts` (e.g. `src/flag/flag.ts` for `experimental`, `src/loop/configSection.ts` for `loopControl`). Exception: kosong-owned sections (`providers`, `models`, `thinking`) — kosong is a pure, persistence-free abstraction layer that defines only the types (`src/kosong/{provider,model}`); the section constants, the zod schemas (re-derived from those types and compile-time pinned via `AssertExact, Type>>`, see `_base/utils/typeEquality.ts`), the registrations, env bindings, and TOML transforms all live in the persistence wrapper `src/app/kosongConfig/configSection.ts`. (`modelCatalog` and `secondaryModel` have no kosong-side type at all — their sections are fully self-contained in `app/kosongConfig`, types derived from the schemas.) A cross-section env overlay (e.g. the `KIMI_MODEL_*` synthesis) lives in the wrapper too (`src/app/kosongConfig/envOverlay.ts`; the `[secondary_model]` derived-entry synthesis in `secondaryModelOverlay.ts`) and is registered via `IConfigRegistry.registerEffectiveOverlay`. The two-way sync between config sections and kosong's in-memory registries is owned by `IKosongConfigService` (`src/app/kosongConfig/kosongConfigService.ts`). ## Scope diff --git a/.changeset/subagent-secondary-model.md b/.changeset/subagent-secondary-model.md new file mode 100644 index 0000000000..c413ac098e --- /dev/null +++ b/.changeset/subagent-secondary-model.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add experimental secondary-model bindings for newly spawned subagents, including per-agent model preferences and subagent-only model overrides. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index f5f65fc190..e8df6e99cf 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -188,6 +188,31 @@ 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` + +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. + +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` | — | A model id from your configured `[models]` (any provider, not limited to Kimi models) | +| `default_effort` | `string` | — | Thinking effort applied when subagents bind to the secondary model. Unset, the effort resolves naturally (global `[thinking]` config → the bound model's default effort) instead of inheriting the main agent's effort. Follows the main model's thinking-effort semantics: models with strict effort validation (e.g. Kimi models) fall back to their default effort for unsupported values; other providers receive the value as-is | +| Other fields | — | — | Accepts every field of [`[models."".overrides]`](#models) (`max_context_size`, `max_output_size`, `support_efforts`, …) as a model patch applied only to subagents | + +Every field besides `model` forms a patch: when at least one patch field is set, the runtime synthesizes a derived model entry in memory (a copy of the pointed entry with the patch merged into its overrides, patch winning conflicts) and subagents bind that derived entry; with no patch fields, subagents bind the pointed entry directly. The derived entry lives only in memory (never written back to `config.toml`) and is hidden from model-selection lists. + +```toml +[secondary_model] +model = "kimi-code/kimi-k2.5" +default_effort = "low" +max_output_size = 8192 +``` + +`model` / `default_effort` can be overridden by the `KIMI_SECONDARY_MODEL` / `KIMI_SECONDARY_EFFORT` environment variables, which take higher priority than `config.toml`. + +When the experiment is enabled, the configuration is validated as the session starts: an unresolvable `model`, or a `default_effort` not listed by the (patched) model, produces a startup warning (also returned by the session-warnings API). The check is advisory — a broken secondary model still fails at spawn time, with the same source hint attached to the spawn error. + ## `thinking` `thinking` sets the global default behavior for Thinking mode. @@ -241,7 +266,6 @@ In print mode (`kimi -p ""`), Kimi Code stays alive after the main agent | Field | Type | Default | Description | | --- | --- | --- | --- | | `timeout_ms` | `integer` | `7200000` (2 hours) | Maximum wall-clock time (milliseconds) a single subagent (`Agent` / `AgentSwarm`) is allowed to run before it is settled as `timed_out`. `0` means no timeout — the subagent runs until it finishes or the model stops it. This is the background-task manager's per-task timeout for each subagent task, so it applies to both foreground and background subagents. In print mode (`kimi -p`) the default is `0` unless explicitly set. Note: any value above `2147483647` (about 24.8 days) is clamped to roughly 24.8 days by the runtime | - `timeout_ms` can be overridden by the `KIMI_SUBAGENT_TIMEOUT_MS` environment variable, which takes higher priority than `config.toml`. ## `mcp` diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 1ae9413913..1457746245 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -128,6 +128,9 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | Override the plugin marketplace JSON loaded by `/plugins`; useful for dev loopback servers, staging CDN files, or alternate marketplace directories | `https://code.kimi.com/kimi-code/plugins/marketplace.json`; also accepts `http://`, `file://` URLs, and local paths | | `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | Cap how many AgentSwarm subagents run concurrently during the initial ramp; leave unset for no cap | Positive integer; invalid values fail fast | | `KIMI_SUBAGENT_TIMEOUT_MS` | Maximum wall-clock time (ms) a single subagent (`Agent` / `AgentSwarm`) may run; takes higher priority than `[subagent] timeout_ms` in `config.toml` (default `7200000`, i.e. 2 hours) | Positive integer; invalid values fall back to the config or default | +| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | Enable experimental secondary-model behavior under `kimi web`; `kimi -p` still requires `KIMI_CODE_EXPERIMENTAL_FLAG=1` to select the v2 engine, which also enables this feature | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | +| `KIMI_SECONDARY_MODEL` | Secondary model; takes higher priority than `[secondary_model] model` in `config.toml`. When the secondary-model experiment is enabled, newly spawned subagents (`Agent` / `AgentSwarm`) bind to it by default instead of inheriting the main agent's model (not supported in the TUI) | A model id from your configured `[models]`, e.g. `kimi-code/kimi-k2.5`; blank values are ignored | +| `KIMI_SECONDARY_EFFORT` | Thinking effort for the secondary model; takes higher priority than `[secondary_model] default_effort` in `config.toml` and applies only when both the model and its experiment are enabled (not supported in the TUI) | An effort value, e.g. `low`; blank values are ignored | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | Global default connection timeout (ms) for all MCP servers; takes higher priority than `[mcp] startup_timeout_ms` in `config.toml`, but a per-server `startupTimeoutMs` in `mcp.json` still wins (default `30000`) | Integer from `1` to `2147483647`; invalid values are ignored | | `KIMI_MCP_TOOL_TIMEOUT_MS` | Global default single tool-call timeout (ms) for all MCP servers; takes higher priority than `[mcp] tool_timeout_ms` in `config.toml`, but a per-server `toolTimeoutMs` in `mcp.json` still wins (default `60000`) | Integer from `1` to `2147483647`; invalid values are ignored | | `KIMI_LOOP_MAX_STEPS_PER_TURN` | Maximum Agent steps per turn; takes higher priority than `[loop_control] max_steps_per_turn` in `config.toml` (unset or `0` means unlimited) | Non-negative integer; invalid values are ignored | diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md index 93d5e1d1d4..209711bdca 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -79,6 +79,7 @@ name: reviewer description: Strict code reviewer that reports severity-ranked findings whenToUse: Code reviews and PR checks override: false +model_preference: primary tools: - Read - Grep @@ -97,6 +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 | | `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 | @@ -107,6 +109,8 @@ 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. + 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. ::: warning Note @@ -117,7 +121,7 @@ Custom agents delegated as sub-agents run without the built-in sub-agent framing ### Selecting the Main Agent -Two CLI flags select which agent drives the session. **Both currently require the v2 engine** — `kimi -p` with `KIMI_CODE_EXPERIMENTAL_FLAG=1`; the interactive TUI (v1) rejects them with a clear error for now: +Two CLI flags select which agent drives the session. **Both are currently available only under `kimi -p` with `KIMI_CODE_EXPERIMENTAL_FLAG=1`**; the interactive TUI rejects them with a clear error for now: - **`--agent `**: Start the session with the named agent as the main Agent. The name can refer to a built-in agent or to any discovered file; an unknown name fails with an error listing the available agents. - **`--agent-file `**: Load one agent file at the highest priority for this launch and start with it. The flag accepts exactly one file: it cannot be repeated, and it cannot be combined with `--agent`. @@ -134,7 +138,7 @@ For main-agent customization, reference `${base_prompt}` in the body so the envi ### Overriding the main agent's system prompt with SYSTEM.md -To override the main agent's system prompt permanently — without passing `--agent` or `--agent-file` on every launch — write a `$KIMI_CODE_HOME/SYSTEM.md` file (default: `~/.kimi-code/SYSTEM.md`; it moves with `KIMI_CODE_HOME`). While the file exists and is non-empty, it replaces the built-in default main agent's system prompt in full — and only the prompt: the description and tool set are inherited from the built-in defaults. Like `--agent` / `--agent-file`, SYSTEM.md currently takes effect only under the v2 engine (`KIMI_CODE_EXPERIMENTAL_FLAG=1`); the v1 engine ignores the file. +To override the main agent's system prompt permanently — without passing `--agent` or `--agent-file` on every launch — write a `$KIMI_CODE_HOME/SYSTEM.md` file (default: `~/.kimi-code/SYSTEM.md`; it moves with `KIMI_CODE_HOME`). While the file exists and is non-empty, it replaces the built-in default main agent's system prompt in full — and only the prompt: the description and tool set are inherited from the built-in defaults. SYSTEM.md currently takes effect only under `kimi web` and under `kimi -p` with `KIMI_CODE_EXPERIMENTAL_FLAG=1`; the interactive TUI ignores the file. SYSTEM.md is a plain Markdown body — no frontmatter is required or read. A missing or empty file has no effect, and a read failure falls back to the built-in prompt with a warning. Explicit intent still outranks it: a project-scoped same-name agent file declaring `override: true` and any file passed via `--agent-file` take precedence, and selecting another agent with `--agent` bypasses it entirely. Within the user scope itself, SYSTEM.md wins over a same-name file discovered in the `agents/` directories. diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index 70953a3fb7..0c29c35ba0 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -24,8 +24,8 @@ All flags are optional — run `kimi` directly to enter an interactive session: | `--auto` | | Start with auto permission mode; tool approvals are handled automatically and the Agent will not ask the user questions | | `--plan` | | Start a new session in Plan mode — the AI will prioritize read-only tools for exploration and planning | | `--skills-dir ` | | Load Skills from the specified directory, replacing the automatically discovered user and project directories. Can be repeated | -| `--agent ` | | Start the session with the specified agent as the main Agent (v2 engine only) | -| `--agent-file ` | | Load a custom agent from a Markdown file for this launch and select it (v2 engine only). Cannot be repeated or combined with `--agent` | +| `--agent ` | | Start the session with the specified agent as the main Agent (experimental `kimi -p` only) | +| `--agent-file ` | | Load a custom agent from a Markdown file for this launch and select it (experimental `kimi -p` only). Cannot be repeated or combined with `--agent` | | `--add-dir ` | | Add an extra workspace directory for this session. Relative paths resolve against the current working directory. Can be repeated | `-r` / `--resume` is a hidden alias for `--session`; `--yes` and `--auto-approve` are hidden aliases for `--yolo` and are not shown in help output. @@ -98,7 +98,7 @@ There are two ways to specify Skills directories, with different semantics: ### Custom Agents -`--agent` and `--agent-file` select which agent drives the session. Both currently require the v2 engine — `kimi -p` with `KIMI_CODE_EXPERIMENTAL_FLAG=1`: +`--agent` and `--agent-file` select which agent drives the session. Both are currently available only under `kimi -p` with `KIMI_CODE_EXPERIMENTAL_FLAG=1`; any other launch rejects them with a clear error: ```sh KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p --agent reviewer "Review the changes on this branch" diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index 0a5c3ecf50..37972f5116 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -89,9 +89,9 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill | `AskUserQuestion` | Auto-allow | Ask the user a question to gather structured input | | `Skill` | Auto-allow | Invoke a registered inline Skill | -**`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), and `run_in_background` (defaults to false). Agent tasks time out after 2 hours by default; the limit is configurable via `[subagent] timeout_ms` in `config.toml` (`0` = no timeout, or the `KIMI_SUBAGENT_TIMEOUT_MS` env var), and defaults to no timeout in print mode (`kimi -p`). In foreground mode the parent Agent waits for the sub-Agent to complete before continuing; in background mode a task ID is returned immediately and the result is automatically delivered back to the main Agent via a synthetic User message when done. When several foreground `Agent` calls run in the same step, the TUI groups them and shows each subagent's running, waiting, completed, or failed status with elapsed time. See [Agent & Sub-Agents](../customization/agents.md) for details. +**`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), `run_in_background` (defaults to false), and `model` (`"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. -**`AgentSwarm`** launches subagents from a shared `prompt_template` and an `items` array, resumes existing subagents through `resume_agent_ids`, or combines both in one call. The template must contain the `{{item}}` placeholder; each item replaces that placeholder and launches one new subagent. Pass `subagent_type` to choose the profile used by every spawned subagent in the swarm, or omit it to use `coder`. Without `resume_agent_ids`, the tool requires at least 2 items; with `resume_agent_ids`, it can resume one or more existing subagents. The tool supports up to 128 total subagents, waits for all subagents to finish, and returns an aggregated report. In the TUI, foreground swarms show a live `Agent swarm` progress panel above the input box. If a model response calls `AgentSwarm`, that call must be the only tool call in the response; to run multiple swarms, call one `AgentSwarm`, wait for its result, then call the next, or combine the work into one swarm when a single template can cover it. In `manual` permission mode, `AgentSwarm` calls outside active swarm mode request approval unless a permission rule allows them; while swarm mode is active, `AgentSwarm` itself is auto-approved. Permission rules match `AgentSwarm` by tool name only — argument patterns such as `AgentSwarm(swarm)` are not supported. By default the tool ramps up concurrency without an upper limit (5 subagents start immediately, then 1 more every 700 ms); set `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` to a positive integer to cap how many subagents run at the same time during that ramp, or leave it unset for no cap. If it is set to a value that is not a positive integer, the AgentSwarm call fails fast. +**`AgentSwarm`** launches subagents from a shared `prompt_template` and an `items` array, resumes existing subagents through `resume_agent_ids`, or combines both in one call. The template must contain the `{{item}}` placeholder; each item replaces that placeholder and launches one new subagent. Pass `subagent_type` to choose the profile used by every spawned subagent in the swarm, or omit it to use `coder`. Pass `model` (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. **`AskUserQuestion`** asks the user a structured multiple-choice question — useful for disambiguation or option selection. The `questions` parameter accepts 1–4 questions; each question requires `question` (ending with `?`), `options` (2–4 choices, each with a `label` and `description`), and optional `header` (max 12 characters) and `multi_select` (defaults to false). An "Other" option is appended automatically. Setting `background` to true starts a background question task and returns a task ID immediately. When the host does not support interactive questioning, a failure message is returned and the Agent should ask the user directly in a text reply instead. diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index b135aa289b..be8757e99b 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -188,6 +188,31 @@ display_name = "Kimi for Coding (custom)" 无需修改配置文件也可以临时切换模型——通过 `KIMI_MODEL_*` 环境变量在内存里合成一个临时供应商,详见[用环境变量定义模型](./env-vars.md#用环境变量定义模型-kimi-model)。 +## `secondary_model` + +次主力模型是主模型 `default_model` 之外的第二个模型指针——通常是一个更便宜的模型,供不需要主模型的功能绑定使用。目前的消费者是子 Agent 派生:设置后,新派生的子 Agent(`Agent` / `AgentSwarm`)默认绑定该模型,而不再继承主 Agent 的模型;主 Agent 会被告知每次派生可在 `"secondary"`(该模型)与 `"primary"`(主模型)之间选择。未设置时,子 Agent 继承主 Agent 的模型。 + +该功能目前是实验功能,默认关闭。在 `kimi web` 下,通过 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1` 启用;在 `kimi -p` 下,选择 v2 引擎本就需要 `KIMI_CODE_EXPERIMENTAL_FLAG=1`,该 master flag 也会启用本功能。交互式 TUI 会忽略该配置。 + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `model` | `string` | — | 已配置 `[models]` 中的模型 id(不限 kimi 模型,可用任意供应商) | +| `default_effort` | `string` | — | 子代理绑定次主力模型时使用的 thinking effort。未设置时按"全局 `[thinking]` 配置 → 模型默认 effort"的链路解析,不再继承主 Agent 的 effort。与主模型的 thinking effort 语义一致:严格校验 effort 的模型(如 kimi 模型)在不支持该取值时回退到模型默认 effort,其他供应商的模型按原样发送给后端 | +| 其他字段 | — | — | 接受 [`[models."".overrides]`](#models) 的全部字段(`max_context_size`、`max_output_size`、`support_efforts` 等),作为仅对子代理生效的模型补丁 | + +`model` 之外的字段构成补丁:存在补丁字段时,运行时会在内存中合成一个派生模型条目(被指向条目的拷贝,补丁并入其 overrides 且补丁优先),子代理实际绑定该派生条目;没有补丁字段时,子代理直接绑定 `model` 指向的条目。派生条目只存在于内存中(不写回 `config.toml`),也不会出现在模型选择列表里。 + +```toml +[secondary_model] +model = "kimi-code/kimi-k2.5" +default_effort = "low" +max_output_size = 8192 +``` + +`model` / `default_effort` 可被环境变量 `KIMI_SECONDARY_MODEL` / `KIMI_SECONDARY_EFFORT` 覆盖,优先级均高于配置文件。 + +实验功能启用后,会话启动时会校验该配置:`model` 无法解析,或 `default_effort` 不在(应用补丁后的)模型 effort 列表中时,会在启动时显示警告(并通过会话警告 API 返回)。该检查仅为提示——配置有误的次主力模型仍会在派生子 Agent 时失败,派生错误中同样附带配置来源提示。 + ## `thinking` `thinking` 设置 Thinking 模式的全局默认行为。 @@ -241,7 +266,6 @@ display_name = "Kimi for Coding (custom)" | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `timeout_ms` | `integer` | `7200000`(2 小时) | 单个子代理(`Agent` / `AgentSwarm`)允许运行的最长时间(毫秒)。超时后子代理以 `timed_out` 收尾。`0` 表示无超时——子代理一直运行到自行结束或被模型手动停止。该值是后台任务管理器对每个子代理任务的 per-task timeout,因此对前台与后台子代理同时生效。在 print 模式(`kimi -p`)下未显式设置时默认为 `0`。注意:超过 `2147483647`(约 24.8 天)的值会被运行时钳到约 24.8 天 | - `timeout_ms` 可被环境变量 `KIMI_SUBAGENT_TIMEOUT_MS` 覆盖,优先级高于配置文件。 ## `mcp` diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 1e4bba38cc..e5a654ebe0 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -128,6 +128,9 @@ kimi | `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | 覆盖 `/plugins` 加载的 plugin marketplace JSON,适合 dev loopback server、测试 CDN 文件或替换 marketplace 目录 | `https://code.kimi.com/kimi-code/plugins/marketplace.json`;也接受 `http://`、`file://` URL 和本地路径 | | `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | 限制 AgentSwarm 初始提升并发阶段可同时运行的子 Agent 数量;不设置表示不限制 | 正整数;非法值会立即失败 | | `KIMI_SUBAGENT_TIMEOUT_MS` | 单个子 Agent(`Agent` / `AgentSwarm`)可运行的最长时间(毫秒);优先级高于 `config.toml` 的 `[subagent] timeout_ms`(默认 `7200000`,即 2 小时) | 正整数;非法值回退到配置或默认值 | +| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | 在 `kimi web` 下启用实验性的次主力模型功能;`kimi -p` 仍需通过 `KIMI_CODE_EXPERIMENTAL_FLAG=1` 选择 v2 引擎,该 master flag 也会启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | +| `KIMI_SECONDARY_MODEL` | 次主力模型;优先级高于 `config.toml` 的 `[secondary_model] model`。次主力模型实验功能启用后,新派生的子 Agent 默认绑定该模型,而不再继承主 Agent 的模型(TUI 不支持) | 已配置 `[models]` 中的模型 id,如 `kimi-code/kimi-k2.5`;空白值被忽略 | +| `KIMI_SECONDARY_EFFORT` | 次主力模型的 thinking effort;优先级高于 `config.toml` 的 `[secondary_model] default_effort`,仅在次主力模型及其实验功能均启用时生效(TUI 不支持) | effort 取值,如 `low`;空白值被忽略 | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | 所有 MCP server 的全局默认连接超时(毫秒);优先级高于 `config.toml` 的 `[mcp] startup_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `startupTimeoutMs`(默认 `30000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | | `KIMI_MCP_TOOL_TIMEOUT_MS` | 所有 MCP server 的全局默认单次工具调用超时(毫秒);优先级高于 `config.toml` 的 `[mcp] tool_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `toolTimeoutMs`(默认 `60000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | | `KIMI_LOOP_MAX_STEPS_PER_TURN` | Agent 单轮最大步数;优先级高于 `config.toml` 的 `[loop_control] max_steps_per_turn`(不设或 `0` 表示无上限) | 非负整数;非法值被忽略 | diff --git a/docs/zh/customization/agents.md b/docs/zh/customization/agents.md index c095ba1eed..0dd0a9c8a7 100644 --- a/docs/zh/customization/agents.md +++ b/docs/zh/customization/agents.md @@ -79,6 +79,7 @@ name: reviewer description: 严格的代码审查 Agent,按严重度分级报告问题 whenToUse: 代码评审与 PR 检查 override: false +model_preference: primary tools: - Read - Grep @@ -97,6 +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 继承调用方模型 | | `tools` | 否 | 工具名允许列表,如 `Read`、`Bash`;MCP 工具用 glob 匹配,如 `mcp__github__*`。支持 YAML 列表或逗号分隔字符串(`tools: Read, Grep`)两种写法。缺省表示允许全部工具;单独的 `*` 同样表示允许全部工具;空列表(`tools: []`)表示禁用全部工具 | | `disallowedTools` | 否 | 禁止列表,写法与匹配规则相同,在 `tools` 之后应用 | | `subagents` | 否 | 允许委派的子 Agent 名称列表,写法与 `tools` 相同(YAML 列表或逗号分隔字符串)。缺省表示可委派所有类型;单独的 `*` 同样表示全部 | @@ -107,6 +109,8 @@ 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`。 + 目录中发现的非法文件会被跳过并告警,不影响其他文件。通过 `--agent-file` 显式传入的文件必须合法 —— 否则 CLI 会报错并退出。 ::: warning 注意 @@ -117,7 +121,7 @@ disallowedTools: ### 选择主 Agent -两个 CLI flag 用于选择驱动会话的 Agent。**目前二者都要求 v2 引擎** —— 即 `KIMI_CODE_EXPERIMENTAL_FLAG=1` 下的 `kimi -p`;交互式 TUI(v1)暂时会以明确错误拒绝它们: +两个 CLI flag 用于选择驱动会话的 Agent。**目前二者仅在 `KIMI_CODE_EXPERIMENTAL_FLAG=1` 时的 `kimi -p` 下可用**;交互式 TUI 会以明确错误拒绝它们: - **`--agent `**:以指定 Agent 作为主 Agent 启动会话。名称可以指向内置 Agent 或任何已发现的文件;名称不存在时会报错,并列出可用的 Agent。 - **`--agent-file `**:以最高优先级加载一个 Agent 文件(仅本次启动)并以其启动。该 flag 只接受一个文件:不可重复传入,也不能与 `--agent` 同时使用。 @@ -134,7 +138,7 @@ KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p --agent reviewer "审查这个分支上的 ### 用 SYSTEM.md 覆盖主 Agent 的系统提示词 -希望永久覆盖主 Agent 的系统提示词、而不必每次启动都传入 `--agent` 或 `--agent-file` 时,可以写一份 `$KIMI_CODE_HOME/SYSTEM.md`(默认:`~/.kimi-code/SYSTEM.md`,随 `KIMI_CODE_HOME` 移动)。文件存在且非空期间,它整体替换内置默认主 Agent 的系统提示词——但只替换提示词,描述与工具集仍沿用内置默认值。与 `--agent` / `--agent-file` 一样,SYSTEM.md 目前仅在 v2 引擎下生效(`KIMI_CODE_EXPERIMENTAL_FLAG=1`);v1 引擎会忽略该文件。 +希望永久覆盖主 Agent 的系统提示词、而不必每次启动都传入 `--agent` 或 `--agent-file` 时,可以写一份 `$KIMI_CODE_HOME/SYSTEM.md`(默认:`~/.kimi-code/SYSTEM.md`,随 `KIMI_CODE_HOME` 移动)。文件存在且非空期间,它整体替换内置默认主 Agent 的系统提示词——但只替换提示词,描述与工具集仍沿用内置默认值。SYSTEM.md 目前仅在 `kimi web`,以及 `KIMI_CODE_EXPERIMENTAL_FLAG=1` 时的 `kimi -p` 下生效;交互式 TUI 会忽略该文件。 SYSTEM.md 是纯 Markdown 正文,不需要也不读取 Frontmatter。文件缺失或为空时不生效;读取失败时会告警并回退到内置提示词。优先级上,显式意图仍然胜出:项目作用域中声明了 `override: true` 的同名 Agent 文件、通过 `--agent-file` 传入的文件都排在 SYSTEM.md 之前,用 `--agent` 选择其他 Agent 时 SYSTEM.md 也不会生效;而在用户作用域内部,SYSTEM.md 优先于 `agents/` 目录中扫描到的同名文件。 diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 6da18d7272..f68b451239 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -24,8 +24,8 @@ kimi [options] | `--auto` | | 以 auto 权限模式启动;工具审批自动处理,Agent 不会向用户提问 | | `--plan` | | 以 Plan 模式启动新会话,AI 会优先使用只读工具进行探索和规划 | | `--skills-dir ` | | 从指定目录加载 Skills,替换自动发现的用户和项目目录。可重复传入 | -| `--agent ` | | 以指定 Agent 作为主 Agent 启动会话(仅 v2 引擎) | -| `--agent-file ` | | 从 Markdown 文件加载自定义 Agent(仅本次启动、仅 v2 引擎)并选中它。不可重复传入,也不能与 `--agent` 同时使用 | +| `--agent ` | | 以指定 Agent 作为主 Agent 启动会话(仅实验性 `kimi -p`) | +| `--agent-file ` | | 从 Markdown 文件加载自定义 Agent(仅本次启动、仅实验性 `kimi -p`)并选中它。不可重复传入,也不能与 `--agent` 同时使用 | | `--add-dir ` | | 为本次会话添加额外的工作目录。相对路径按当前工作目录解析。可重复传入 | `-r` / `--resume` 是 `--session` 的隐藏别名;`--yes` 和 `--auto-approve` 是 `--yolo` 的隐藏别名,在帮助信息中不显示。 @@ -98,7 +98,7 @@ kimi --plan ### 自定义 Agent -`--agent` 和 `--agent-file` 用于选择驱动会话的 Agent。目前二者都要求 v2 引擎 —— 即 `KIMI_CODE_EXPERIMENTAL_FLAG=1` 下的 `kimi -p`: +`--agent` 和 `--agent-file` 用于选择驱动会话的 Agent。目前二者仅在 `KIMI_CODE_EXPERIMENTAL_FLAG=1` 时的 `kimi -p` 下可用,其他启动方式会以明确错误拒绝: ```sh KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p --agent reviewer "审查这个分支上的改动" diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 3514fda042..f993fa7bb6 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -89,9 +89,9 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只 | `AskUserQuestion` | 自动放行 | 向用户提问以获取结构化输入 | | `Skill` | 自动放行 | 调用已注册的 inline Skill | -**`Agent`** 将子任务委托给子 Agent 执行。必填参数:`prompt`(完整任务描述)和 `description`(3–5 个词的简短说明)。可选参数:`subagent_type`(默认 `coder`)、`resume`(恢复已有 Agent 的 ID,与 `subagent_type` 互斥)和 `run_in_background`(默认 false)。Agent 任务默认 2 小时超时,可通过 `config.toml` 的 `[subagent] timeout_ms`(`0` = 无超时,或 `KIMI_SUBAGENT_TIMEOUT_MS` 环境变量)配置,且在 print 模式(`kimi -p`)下默认无超时。前台模式下父 Agent 等待子 Agent 完成再继续;后台模式立即返回任务 ID,完成时通过合成 User 消息自动回到主 Agent。多个前台 `Agent` 调用在同一步运行时,TUI 会合并展示,并为每个子 Agent 显示运行、等待、完成或失败状态以及已耗时长。子 Agent 体系细节见 [Agent 与子 Agent](../customization/agents.md)。 +**`Agent`** 将子任务委托给子 Agent 执行。必填参数:`prompt`(完整任务描述)和 `description`(3–5 个词的简短说明)。可选参数:`subagent_type`(默认 `coder`)、`resume`(恢复已有 Agent 的 ID,与 `subagent_type` 互斥)、`run_in_background`(默认 false)和 `model`(`"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)。 -**`AgentSwarm`** 可以从共享的 `prompt_template` 和 `items` 数组启动子 Agent,也可以通过 `resume_agent_ids` 恢复已有子 Agent,或在一次调用中同时使用两者。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个新的子 Agent。传入 `subagent_type` 可以指定整个 swarm 中所有新启动的子 Agent 使用的 profile;省略时默认使用 `coder`。不传 `resume_agent_ids` 时,本工具要求至少 2 个 item;传入 `resume_agent_ids` 时,可以恢复 1 个或多个已有子 Agent。本工具最多支持 128 个子 Agent,会等待全部子 Agent 完成,并返回聚合报告。在 TUI 中,前台 swarm 会在输入框上方显示实时 `Agent swarm` 进度面板。若一次模型响应调用 `AgentSwarm`,该调用必须是该响应中的唯一工具调用;如需运行多个 swarm,应先调用一个 `AgentSwarm` 并等待结果,再调用下一个,若单个模板可以覆盖这些工作,也可以合并为一个 swarm。在 `manual` 权限模式下,未处于 swarm mode 时调用 `AgentSwarm` 会触发审批,除非已有权限规则允许;swarm mode 已开启时,`AgentSwarm` 本身会自动放行。权限规则只能按工具名 `AgentSwarm` 匹配,不支持 `AgentSwarm(swarm)` 这类参数模式。默认情况下,本工具会逐步提升并发且不设上限(立即启动 5 个子 Agent,之后每 700 毫秒再启动 1 个);将 `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` 设为正整数可限制该阶段同时运行的子 Agent 数量,不设置则表示不限制。若设置为非正整数的值,本次 AgentSwarm 调用会立即失败。 +**`AgentSwarm`** 可以从共享的 `prompt_template` 和 `items` 数组启动子 Agent,也可以通过 `resume_agent_ids` 恢复已有子 Agent,或在一次调用中同时使用两者。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个新的子 Agent。传入 `subagent_type` 可以指定整个 swarm 中所有新启动的子 Agent 使用的 profile;省略时默认使用 `coder`。传入 `model`(在 `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 调用会立即失败。 **`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 e95ea6da00..d89be92abe 100644 --- a/packages/agent-core-v2/docs/config-manifest.toml +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -8,7 +8,7 @@ # commented "# field: type" lines describe the remaining schema fields. # Values resolve as: default -> config.toml -> env overlay -> memory. -# Index (21 sections · 2 overlay(s)) +# Index (22 sections · 3 overlay(s)) # background src/agent/task/configSection.ts # cron src/app/cron/configSection.ts # defaultPermissionMode src/agent/permissionMode/configSection.ts @@ -25,6 +25,7 @@ # models src/app/kosongConfig/configSection.ts # permission src/agent/permissionRules/configSection.ts # providers src/app/kosongConfig/configSection.ts +# secondaryModel src/app/kosongConfig/configSection.ts # services src/app/auth/configSection.ts # subagent src/session/subagent/configSection.ts # task src/agent/task/configSection.ts @@ -32,6 +33,7 @@ # 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 # ########################################################################## # background @@ -283,6 +285,29 @@ merge_all_available_skills = true # env: record # source: record +# ########################################################################## +# secondaryModel (config.toml: secondary_model) +# owner: src/app/kosongConfig/configSection.ts +# scope: core +# hooks: stripEnv +# env: +# model <- KIMI_SECONDARY_MODEL (custom parse) +# default_effort <- KIMI_SECONDARY_EFFORT (custom parse) +# ########################################################################## + +[secondary_model] +# 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 + # ########################################################################## # services # owner: src/app/auth/configSection.ts diff --git a/packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts b/packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts index c358dcdc13..3d11c001d4 100644 --- a/packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts +++ b/packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts @@ -4,8 +4,12 @@ * Launches a batch of child agents (an ordinary Agent scope each) through the * session swarm coordinator and renders the per-subagent XML result. Reads * persisted swarm item labels through the Session-scoped coordinator so later - * `resume_agent_ids` calls relabel resumed subagents like v1. Pure tool — - * owns no scoped state. + * `resume_agent_ids` calls relabel resumed subagents like v1. When the caller + * has a model bound, the tool resolves the explicit or target-profile model + * preference up front via `resolveSubagentBinding` and threads it through the + * swarm tasks; otherwise binding is left to the service, which keeps its own + * "no model bound" check and inherit-caller fallback. Pure tool — owns no + * scoped state. */ import { z } from 'zod'; @@ -20,6 +24,7 @@ import { import { registerTool } 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 { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { IAgentProfileService } from '#/agent/profile/profile'; @@ -29,7 +34,11 @@ import { } from '#/app/agentProfileCatalog/profile-shared'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentSwarmService } from '#/agent/swarm/swarm'; -import { resolveSubagentTimeoutMs } from '#/session/subagent/configSection'; +import { + buildSubagentModelDescriptions, + resolveSubagentBinding, + resolveSubagentTimeoutMs, +} from '#/session/subagent/configSection'; import AGENT_SWARM_DESCRIPTION from './agent-swarm.md?raw'; const DEFAULT_SUBAGENT_TYPE = 'coder'; @@ -72,6 +81,12 @@ export const AgentSwarmToolInputSchema = z .describe( 'Map of existing subagent agent_id to the prompt used to resume that subagent. These resumed subagents are launched before new item-based subagents.', ), + model: z + .enum(['secondary', 'primary']) + .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.', + ), }) .strict(); @@ -105,7 +120,6 @@ interface SwarmRunResult { export class AgentSwarmTool implements BuiltinTool { readonly name = 'AgentSwarm' as const; - readonly description = AGENT_SWARM_DESCRIPTION; readonly parameters: Record = toInputJsonSchema(AgentSwarmToolInputSchema); private readonly callerAgentId: string; @@ -115,12 +129,24 @@ export class AgentSwarmTool implements BuiltinTool { @IAgentScopeContext scopeContext: IAgentScopeContext, @IAgentSwarmService private readonly swarmMode: IAgentSwarmService, @IConfigService private readonly config: IConfigService, + @IFlagService private readonly flags: IFlagService, @ISessionAgentProfileCatalog private readonly catalog: ISessionAgentProfileCatalog, @IAgentProfileService private readonly profile: IAgentProfileService, ) { this.callerAgentId = scopeContext.agentId; } + get description(): string { + const modelLines = buildSubagentModelDescriptions( + this.config, + this.flags, + this.profile.data().modelAlias, + ); + return modelLines === undefined + ? AGENT_SWARM_DESCRIPTION + : `${AGENT_SWARM_DESCRIPTION}\n\n${modelLines}`; + } + resolveExecution(args: AgentSwarmToolInput): ToolExecution { const agentCount = (args.items?.length ?? 0) + Object.keys(args.resume_agent_ids ?? {}).length; return { @@ -160,12 +186,26 @@ export class AgentSwarmTool implements BuiltinTool { toolCallId: string, ): Promise { const profileName = normalizeOptionalString(args.subagent_type) ?? DEFAULT_SUBAGENT_TYPE; + let binding: { model: string; thinking?: string } | undefined; if ((args.items?.length ?? 0) > 0) { await this.catalog.ready; - const allowlist = subagentAllowlistFor(this.catalog, this.profile.data()); + const own = this.profile.data(); + const allowlist = subagentAllowlistFor(this.catalog, own); if (allowlist !== undefined && !allowlist.includes(profileName)) { throw new Error(subagentTypeNotAllowedMessage(profileName, allowlist)); } + const targetProfile = this.catalog.get(profileName); + if (targetProfile === undefined) { + throw new Error(`Unknown agent type: "${profileName}"`); + } + if (own.modelAlias !== undefined) { + binding = resolveSubagentBinding( + this.config, + this.flags, + { modelAlias: own.modelAlias, thinkingLevel: own.thinkingLevel }, + args.model ?? targetProfile.modelPreference, + ); + } } const timeoutMs = resolveSubagentTimeoutMs(this.config); const specs = await createAgentSwarmSpecs(args, (agentId) => @@ -195,6 +235,7 @@ export class AgentSwarmTool implements BuiltinTool { return { ...common, kind: 'spawn' as const, + binding, }; }); const results = await this.swarmService.run({ diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/agentFile.ts b/packages/agent-core-v2/src/app/agentFileCatalog/agentFile.ts index b174d9ca72..e11b38c9b3 100644 --- a/packages/agent-core-v2/src/app/agentFileCatalog/agentFile.ts +++ b/packages/agent-core-v2/src/app/agentFileCatalog/agentFile.ts @@ -91,6 +91,7 @@ export function parseAgentFileText(options: ParseAgentFileOptions): AgentFileDef const rawSubagents = parseStringList(frontmatter['subagents'], 'subagents', options.path); const subagents = rawSubagents?.length === 1 && rawSubagents[0] === '*' ? undefined : rawSubagents; + const modelPreference = parseModelPreference(frontmatter['model_preference'], options.path); const prompt = parsed.body.trim(); if (prompt.length === 0) { @@ -105,12 +106,24 @@ export function parseAgentFileText(options: ParseAgentFileOptions): AgentFileDef tools, disallowedTools, subagents, + modelPreference, prompt, path: options.path, source: options.source, }; } +function parseModelPreference( + value: unknown, + filePath: string, +): AgentFileDefinition['modelPreference'] { + if (value === undefined || value === null) return undefined; + if (value === 'primary' || value === 'secondary') return value; + throw new AgentFileParseError( + `Frontmatter field "model_preference" in ${filePath} must be "primary" or "secondary"`, + ); +} + function parseBoolean(value: unknown, field: string, filePath: string): boolean { if (value === undefined || value === null) return false; if (typeof value === 'boolean') return value; diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/agentProfileFromFile.ts b/packages/agent-core-v2/src/app/agentFileCatalog/agentProfileFromFile.ts index 4ec048b803..7f8d0affe2 100644 --- a/packages/agent-core-v2/src/app/agentFileCatalog/agentProfileFromFile.ts +++ b/packages/agent-core-v2/src/app/agentFileCatalog/agentProfileFromFile.ts @@ -9,7 +9,8 @@ * `tools` passes through as the allowlist (`undefined` = every tool active); * `disallowedTools` passes through as the denylist evaluated by * `IAgentToolPolicyService`; `subagents` passes through as the delegation - * allowlist enforced by the `Agent` / `AgentSwarm` tools. + * allowlist enforced by the `Agent` / `AgentSwarm` tools; `model_preference` + * becomes the symbolic default those tools use when spawning the profile. */ import type { @@ -35,6 +36,7 @@ export function agentProfileFromFile( tools: definition.tools, disallowedTools: definition.disallowedTools, subagents: definition.subagents, + modelPreference: definition.modelPreference, systemPrompt: (context) => renderPromptTemplate(definition.prompt, context, { skillActive }, basePrompt), }; diff --git a/packages/agent-core-v2/src/app/agentFileCatalog/types.ts b/packages/agent-core-v2/src/app/agentFileCatalog/types.ts index c52694e7e9..7203f9b951 100644 --- a/packages/agent-core-v2/src/app/agentFileCatalog/types.ts +++ b/packages/agent-core-v2/src/app/agentFileCatalog/types.ts @@ -7,6 +7,8 @@ * Pure data; no scoped state. */ +import type { AgentModelPreference } from '#/app/agentProfileCatalog/agentProfileCatalog'; + export type AgentFileSource = 'project' | 'user' | 'extra' | 'explicit'; export interface AgentFileRoot { @@ -22,6 +24,7 @@ export interface AgentFileDefinition { readonly tools?: readonly string[]; readonly disallowedTools?: readonly string[]; readonly subagents?: readonly string[]; + readonly modelPreference?: AgentModelPreference; readonly prompt: string; readonly path: string; readonly source: AgentFileSource; diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts index 62e899495a..66aac1cde2 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts @@ -10,8 +10,11 @@ * * Every profile is self-contained: `systemPrompt(context)` returns the complete * prompt (base + role overlay are merged at definition time, not at spawn - * time). 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. + * 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 + * default profile used when an Agent is bound to a Model without naming a + * profile. * * `tools` is an allowlist of exact builtin names plus `mcp__` globs * (`undefined` = every tool active); `disallowedTools` denies with the same @@ -35,6 +38,8 @@ import type { ISessionProcessRunner } from '#/session/process/processRunner'; export const DEFAULT_AGENT_PROFILE_NAME = 'agent'; +export type AgentModelPreference = 'primary' | 'secondary'; + export interface AgentProfilePromptPrefixContext { readonly cwd: string; readonly runner: ISessionProcessRunner; @@ -69,6 +74,7 @@ export interface AgentProfile { readonly tools?: readonly string[]; readonly disallowedTools?: readonly string[]; readonly subagents?: readonly string[]; + readonly modelPreference?: AgentModelPreference; systemPrompt(context: AgentProfileContext): string; readonly promptPrefix?: (ctx: AgentProfilePromptPrefixContext) => Promise; readonly summaryPolicy?: AgentProfileSummaryPolicy; diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index b54bfd99cd..4c815ff303 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -419,10 +419,20 @@ export class ConfigService extends Disposable implements IConfigService { this.applyEnvOverlay(next); this.effective = next; - const changedDomains = domains ?? [ - ...new Set([...Object.keys(previous), ...Object.keys(next)]), - ]; - this.commit(source, changedDomains); + // Commit candidates: the explicitly touched domains PLUS anything the + // recompute actually changed. Section env bindings and effective overlays + // rewrite sibling domains the caller's list never names (e.g. setting + // `[secondary_model]` synthesizes a derived entry into `models`; removing + // the recipe retracts it). `commit` re-checks every candidate with + // deepEqual before firing, so widening the set is free — missing a real + // change is what costs (a stale registry downstream). + const candidates = new Set( + domains ?? [...Object.keys(previous), ...Object.keys(next)], + ); + for (const domain of new Set([...Object.keys(previous), ...Object.keys(next)])) { + if (!deepEqual(previous[domain], next[domain])) candidates.add(domain); + } + this.commit(source, [...candidates]); } private deliveredValue(domain: string): unknown { diff --git a/packages/agent-core-v2/src/app/kosongConfig/configSection.ts b/packages/agent-core-v2/src/app/kosongConfig/configSection.ts index bf4a4329fd..9465ba9c00 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/configSection.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/configSection.ts @@ -2,14 +2,15 @@ * `kosongConfig` domain (L3) — config-section declarations for kosong. * * The persistence wrapper for kosong's provider/model registries and the - * thinking / model-catalog preferences: declares every kosong-owned section - * constant and its zod schema, plus the env bindings / write-path strips - * and the snake_case ↔ camelCase TOML transforms. Where kosong owns a pure - * type (`providers` / `models` / `thinking`), the schema is re-derived from - * it and pinned by an `AssertExact` assertion (schema ≡ type at compile - * time); `modelCatalog` has no kosong-side type and keeps its own local - * schema. Self-registered at module load via `registerConfigSection`, so - * the `config` domain never imports kosong types. + * thinking / model-catalog / secondary-model preferences: declares every + * kosong-owned section constant and its zod schema, plus the env bindings / + * write-path strips and the snake_case ↔ camelCase TOML transforms. Where + * kosong owns a pure type (`providers` / `models` / `thinking`), the schema + * is re-derived from it and pinned by an `AssertExact` assertion (schema ≡ + * type at compile time); `modelCatalog` and `secondaryModel` have no + * kosong-side type — theirs derive from the local schemas. Self-registered + * at module load via `registerConfigSection`, so the `config` domain never + * imports kosong types. * * `ProviderTypeSchema` is deliberately free-form text: vendor identity is * NOT enumerated at parse time. Validation happens at resolve time against @@ -22,7 +23,11 @@ import { z } from 'zod'; -import { type ConfigStripEnv, envBindings } from '#/app/config/config'; +import { + type ConfigStripEnv, + envBindings, + stripEnvBoundFields, +} from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; import { camelToSnake, @@ -337,6 +342,37 @@ registerConfigSection(THINKING_SECTION, ThinkingConfigSchema, { stripEnv: stripThinkingEnv, }); +// `secondaryModel` — the secondary-model recipe: `model` points at a +// `[models]` entry and every `ModelOverride` field is a subagent-only patch +// (materialized as a synthesized derived entry by `secondaryModelOverlay`). +// On disk `[secondary_model]`; `default_effort` doubles as the subagent +// thinking effort. No kosong-side type — derived from the schema. +export const SECONDARY_MODEL_SECTION = 'secondaryModel'; + +export const SECONDARY_MODEL_ENV = 'KIMI_SECONDARY_MODEL'; +export const SECONDARY_MODEL_EFFORT_ENV = 'KIMI_SECONDARY_EFFORT'; + +export const SecondaryModelConfigSchema = ModelOverrideSchema.extend({ + model: z.string().min(1).optional(), +}); + +export type SecondaryModelConfig = z.infer; + +function parseNonEmptyEnv(raw: string): string | undefined { + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export const secondaryModelEnvBindings = envBindings(SecondaryModelConfigSchema, { + model: { env: SECONDARY_MODEL_ENV, parse: parseNonEmptyEnv }, + defaultEffort: { env: SECONDARY_MODEL_EFFORT_ENV, parse: parseNonEmptyEnv }, +}); + +registerConfigSection(SECONDARY_MODEL_SECTION, SecondaryModelConfigSchema, { + env: secondaryModelEnvBindings, + stripEnv: stripEnvBoundFields(secondaryModelEnvBindings), +}); + // --------------------------------------------------------------------------- // `modelCatalog` — provider-model catalog auto-refresh cadence (no kosong type) // --------------------------------------------------------------------------- diff --git a/packages/agent-core-v2/src/app/kosongConfig/secondaryModelOverlay.ts b/packages/agent-core-v2/src/app/kosongConfig/secondaryModelOverlay.ts new file mode 100644 index 0000000000..07a7afd3bd --- /dev/null +++ b/packages/agent-core-v2/src/app/kosongConfig/secondaryModelOverlay.ts @@ -0,0 +1,113 @@ +/** + * `kosongConfig` domain (L3) — `[secondary_model]` derived-entry overlay. + * + * When the secondary-model recipe carries patch fields (see + * `secondaryModelPatch`), synthesizes the derived registry entry + * (`SECONDARY_DERIVED_MODEL_ID`) into the effective `models` view: a copy of + * the pointed entry with the patch merged into its `overrides` block (patch + * wins conflicts) and `aliases` dropped, so the derived entry never competes + * in name/alias routing. Subagent binding then resolves it by name through + * the standard catalog path, and the patch rides the same + * `effectiveModelConfig` merge as any `models.*.overrides` (including its + * supportEfforts/defaultEffort pruning and input clamping). + * + * Like the env overlay, the synthesized entry lives ONLY in the in-memory + * effective view: `strip` removes it from `models` writes so it never + * reaches `config.toml`, and the persistence bridge's deep-equal guards keep + * the two-way sync silent. `strip` also rolls back a `defaultModel` pointer + * set to the derived id (restoring the raw value, mirroring the env + * overlay's pinned-pointer handling) — the pointer can never dangle on disk + * after the recipe is removed. Nothing is synthesized when the recipe has no + * patch fields (subagents bind the pointed entry directly), when + * `secondary.model` is unset, or when the pointed entry does not exist (the + * warning service reports the dangling pointer; spawn fails with the wrapped + * error). The id is reserved: a user-configured entry under it is stripped + * on write all the same. + * + * Self-registered at module load via `registerConfigOverlay`; `src/index.ts` + * imports it for side effects AFTER `envOverlay`, so a `secondary.model` + * pointing at the env-synthesized entry sees the already-applied env view. + */ + +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 { + DEFAULT_MODEL_SECTION, + MODELS_SECTION, + SECONDARY_MODEL_SECTION, + type SecondaryModelConfig, +} from './configSection'; + +/** + * The reserved registry id of the synthesized derived entry. Listing edges + * (e.g. the kap-server `GET /models` route) hide it from pickers; the + * catalog resolves it by id like any other entry. + */ +export const SECONDARY_DERIVED_MODEL_ID = '__secondary__'; + +/** + * The patch half of the recipe: every field except `model`. Returns + * `undefined` when no patch field is set — the signal that subagents bind + * the pointed entry directly and no derived entry is synthesized. + */ +export function secondaryModelPatch( + secondary: SecondaryModelConfig | undefined, +): ModelOverride | undefined { + if (secondary === undefined) return undefined; + const { model: _model, ...patch } = secondary; + return Object.keys(patch).length > 0 ? patch : undefined; +} + +function asRecord(value: unknown): Record { + return isPlainObject(value) ? value : {}; +} + +function withoutKey(value: unknown, key: string): unknown { + if (!isPlainObject(value) || !(key in value)) return value; + const out: Record = { ...value }; + delete out[key]; + return out; +} + +export const secondaryModelOverlay: ConfigEffectiveOverlay = { + apply(effective, _getEnv, validate) { + const secondary = effective[SECONDARY_MODEL_SECTION] as SecondaryModelConfig | undefined; + const patch = secondaryModelPatch(secondary); + const baseId = secondary?.model; + if (patch === undefined || baseId === undefined || baseId === SECONDARY_DERIVED_MODEL_ID) { + return []; + } + const models = asRecord(effective[MODELS_SECTION]); + const base = models[baseId]; + if (!isPlainObject(base)) return []; + const { overrides: baseOverrides, aliases: _aliases, ...baseFields } = base; + const derived: Record = { + ...baseFields, + overrides: { ...asRecord(baseOverrides), ...patch }, + }; + effective[MODELS_SECTION] = validate(MODELS_SECTION, { + ...models, + [SECONDARY_DERIVED_MODEL_ID]: derived, + }); + return [MODELS_SECTION]; + }, + + strip(domain, value, rawSnake) { + switch (domain) { + case MODELS_SECTION: + return withoutKey(value, SECONDARY_DERIVED_MODEL_ID); + case DEFAULT_MODEL_SECTION: + if (value !== SECONDARY_DERIVED_MODEL_ID) return value; + return typeof rawSnake['default_model'] === 'string' + ? rawSnake['default_model'] + : undefined; + default: + return value; + } + }, +}; + +registerConfigOverlay(secondaryModelOverlay); diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index eb2f592848..a84c196701 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -31,7 +31,8 @@ * instead of poisoning the session cache (the skill catalog, by contrast, is * kicked fire-and-forget). The session-level eager services whose * subscriptions must exist before the first agent / turn (external hooks, - * cron) are force-instantiated at the same point. + * cron, the secondary-model startup warning) are force-instantiated at the + * same point. */ import { randomUUID } from 'node:crypto'; @@ -79,6 +80,7 @@ import { labelsFromAgentMeta } from '#/session/agentLifecycle/subagentMetadata'; import { ISessionExternalHooksService } from '#/session/externalHooks/externalHooks'; import { ISessionContext, sessionContextSeed } from '#/session/sessionContext/sessionContext'; import { ISessionCronService } from '#/session/cron/sessionCronService'; +import { ISessionSecondaryModelWarningService } from '#/session/subagent/secondaryModelWarning'; import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; @@ -222,6 +224,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec await handle.accessor.get(ISessionMcpService).ensureMcpReady(opts.mcpServers); handle.accessor.get(ISessionExternalHooksService); handle.accessor.get(ISessionCronService); + handle.accessor.get(ISessionSecondaryModelWarningService); } catch (error) { handle.dispose(); throw error; diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 11c230b283..456f6d5eda 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -97,6 +97,7 @@ export * from '#/kosong/protocol/protocol'; export * from '#/kosong/protocol/protocolBase'; export * from '#/kosong/protocol/protocolTrait'; import '#/app/kosongConfig/envOverlay'; +import '#/app/kosongConfig/secondaryModelOverlay'; export * from '#/kosong/model/completionBudget'; export * from '#/kosong/model/hostRequestHeaders'; export * from '#/kosong/model/model'; @@ -115,6 +116,15 @@ export { ModelCatalogConfigSchema, type ModelCatalogConfig, } from '#/app/kosongConfig/configSection'; +export type { SecondaryModelConfig } from '#/app/kosongConfig/configSection'; +// The secondary-model derived-entry overlay: the edge (kap-server's +// `GET /models` route) hides the reserved id from pickers, and tests drive +// the overlay directly — re-export from the package root. +export { + SECONDARY_DERIVED_MODEL_ID, + secondaryModelOverlay, + secondaryModelPatch, +} from '#/app/kosongConfig/secondaryModelOverlay'; export * from '#/app/kosongConfig/kosongConfig'; export * from '#/app/kosongConfig/kosongConfigService'; export * from '#/kosong/model/modelOAuth'; @@ -273,6 +283,9 @@ export * from '#/session/mcp/sessionMcp'; export * from '#/session/mcp/sessionMcpService'; export * from '#/session/subagent/subagent'; export * from '#/session/subagent/subagentService'; +import '#/session/subagent/flag'; +export * from '#/session/subagent/secondaryModelWarning'; +export * from '#/session/subagent/secondaryModelWarningService'; export * from '#/session/subagent/tools/subagent-task'; export { AGENT_RUN_PROMPT_ORIGIN } from '#/session/subagent/runAgentTurn'; export * from '#/session/subagent/mirrorAgentRun'; diff --git a/packages/agent-core-v2/src/kosong/model/catalogService.ts b/packages/agent-core-v2/src/kosong/model/catalogService.ts index 1018249fbd..5251c4ffeb 100644 --- a/packages/agent-core-v2/src/kosong/model/catalogService.ts +++ b/packages/agent-core-v2/src/kosong/model/catalogService.ts @@ -320,6 +320,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog { throw new Error2( CONFIG_INVALID_ERROR_CODE, `Model "${id}" is not configured in config.toml.`, + { details: { model: id } }, ); } trace.capture(TRACE.configuredModel, configuredModel); diff --git a/packages/agent-core-v2/src/session/subagent/configSection.ts b/packages/agent-core-v2/src/session/subagent/configSection.ts index 2178197edf..7a31d9cc52 100644 --- a/packages/agent-core-v2/src/session/subagent/configSection.ts +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -1,6 +1,6 @@ /** * `subagent` domain (L6) — subagent config-section schema, env binding, and - * timeout resolution. + * timeout / model resolution. * * Owns the `[subagent]` configuration section (`timeout_ms` on disk) together * with the `KIMI_SUBAGENT_TIMEOUT_MS` env override, mirroring v1's @@ -10,12 +10,40 @@ * collaboration tools — `Agent` in this domain and `AgentSwarm` in the `swarm` * domain — resolve their per-run timeout through `resolveSubagentTimeoutMs`, * and render the timeout message with `formatSubagentTimeoutDescription`. - * Self-registered at module load via `registerConfigSection`, so the `config` - * domain never imports this domain's types. + * + * The 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. */ import { z } from 'zod'; +import { Error2, ErrorCodes, isError2 } from '#/errors'; +import type { AgentModelPreference } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import type { IFlagService } from '#/app/flag/flag'; +import { + SECONDARY_MODEL_ENV, + SECONDARY_MODEL_SECTION, +} 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, @@ -24,6 +52,8 @@ import { } from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; +import { SECONDARY_MODEL_FLAG_ID } from './flag'; + export const SUBAGENT_SECTION = 'subagent'; export const SubagentConfigSchema = z.object({ @@ -70,6 +100,79 @@ export function resolveSubagentTimeoutMs(config: IConfigService): number { ); } +export type SubagentModelChoice = AgentModelPreference; + +export function resolveSecondaryModel( + config: IConfigService, + flags: IFlagService, +): SecondaryModelConfig | undefined { + if (!flags.enabled(SECONDARY_MODEL_FLAG_ID)) return undefined; + return config.get(SECONDARY_MODEL_SECTION); +} + +export function resolveSubagentBinding( + config: IConfigService, + flags: IFlagService, + own: { modelAlias: string; thinkingLevel: string }, + requested?: SubagentModelChoice, +): { model: string; thinking?: string } { + const secondary = resolveSecondaryModel(config, flags); + if (requested !== 'primary' && secondary?.model !== undefined) { + return { + model: + secondaryModelPatch(secondary) === undefined + ? secondary.model + : SECONDARY_DERIVED_MODEL_ID, + thinking: secondary.defaultEffort, + }; + } + return { model: own.modelAlias, thinking: own.thinkingLevel }; +} + +export function buildSubagentModelDescriptions( + config: IConfigService, + 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`, + `- primary: ${callerModelAlias} — the main model you are running on; use it for hard, quality-sensitive subagent tasks`, + ].join('\n'); +} + +export function wrapSubagentModelError( + error: unknown, + boundModel: string, + callerModelAlias: string, +): unknown { + if (boundModel === callerModelAlias) return error; + if (!isError2(error) || error.code !== ErrorCodes.CONFIG_INVALID) return error; + if (error.details?.['model'] !== boundModel) return error; + const displayModel = + boundModel === SECONDARY_DERIVED_MODEL_ID + ? `the derived entry "${SECONDARY_DERIVED_MODEL_ID}"` + : `"${boundModel}"`; + 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)`, + { + cause: error, + name: error.name, + details: { + ...error.details, + secondaryModel: boundModel, + secondaryModelConfig: { + section: 'secondaryModel.model', + environment: SECONDARY_MODEL_ENV, + }, + }, + }, + ); +} + /** Human-readable duration for the subagent timeout message. */ export function formatSubagentTimeoutDescription(ms: number): string { if (ms % (60 * 60 * 1000) === 0) { diff --git a/packages/agent-core-v2/src/session/subagent/flag.ts b/packages/agent-core-v2/src/session/subagent/flag.ts new file mode 100644 index 0000000000..72ba140500 --- /dev/null +++ b/packages/agent-core-v2/src/session/subagent/flag.ts @@ -0,0 +1,27 @@ +/** + * `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; + * 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. + */ + +import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; + +export const SECONDARY_MODEL_FLAG_ID = 'secondary-model'; +export const SECONDARY_MODEL_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL'; + +export const secondaryModelFlag: FlagDefinitionInput = { + id: SECONDARY_MODEL_FLAG_ID, + title: 'Secondary model for subagents', + description: + 'Let newly spawned subagents use a separately configured secondary model by default, with an explicit primary-model override for quality-sensitive tasks.', + env: SECONDARY_MODEL_FLAG_ENV, + default: false, + surface: 'core', +}; + +registerFlagDefinition(secondaryModelFlag); diff --git a/packages/agent-core-v2/src/session/subagent/secondaryModelWarning.ts b/packages/agent-core-v2/src/session/subagent/secondaryModelWarning.ts new file mode 100644 index 0000000000..979734139e --- /dev/null +++ b/packages/agent-core-v2/src/session/subagent/secondaryModelWarning.ts @@ -0,0 +1,30 @@ +/** + * `subagent` domain (L6) — `ISessionSecondaryModelWarningService` contract: + * early validation of the configured secondary model. + * + * The secondary-model pointer (`[secondary_model]` / `KIMI_SECONDARY_MODEL`) + * is otherwise validated lazily at spawn time, so a typo surfaces as a + * mid-conversation tool failure handed back to the parent model. This service + * front-loads the same resolution to session start (main-agent creation): an + * unresolvable model or an effort the model does not list becomes a `warning` + * event on the main agent's event bus, and stays cached for the edge to pull + * (`GET /sessions/{id}/warnings`). Session-scoped — one instance per session. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export const SECONDARY_MODEL_INVALID_WARNING_CODE = 'secondary-model-invalid'; +export const SECONDARY_MODEL_EFFORT_WARNING_CODE = 'secondary-model-effort-not-listed'; + +export interface SecondaryModelWarning { + readonly code: string; + readonly message: string; +} + +export interface ISessionSecondaryModelWarningService { + readonly _serviceBrand: undefined; + getSecondaryModelWarning(): SecondaryModelWarning | undefined; +} + +export const ISessionSecondaryModelWarningService: ServiceIdentifier = + createDecorator('sessionSecondaryModelWarningService'); diff --git a/packages/agent-core-v2/src/session/subagent/secondaryModelWarningService.ts b/packages/agent-core-v2/src/session/subagent/secondaryModelWarningService.ts new file mode 100644 index 0000000000..7fa7c4d5a4 --- /dev/null +++ b/packages/agent-core-v2/src/session/subagent/secondaryModelWarningService.ts @@ -0,0 +1,143 @@ +/** + * `subagent` domain (L6) — `ISessionSecondaryModelWarningService` implementation. + * + * When enabled through `flag`, runs the secondary-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 + * recipe carries patch fields, checks `default_effort` against the patched + * `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 + * demotes to a notice here, with spawn-time resolution + * (`resolveSubagentBinding` + `wrapSubagentModelError`) staying as the + * backstop. Bound at Session scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { Disposable } from '#/_base/di/lifecycle'; +import { + type IAgentScopeHandle, + LifecycleScope, + registerScopedService, +} from '#/_base/di/scope'; +import { IConfigService } from '#/app/config/config'; +import { IEventBus } from '#/app/event/eventBus'; +import { IFlagService } from '#/app/flag/flag'; +import { + SECONDARY_MODEL_EFFORT_ENV, + 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 { + ISessionSecondaryModelWarningService, + SECONDARY_MODEL_EFFORT_WARNING_CODE, + SECONDARY_MODEL_INVALID_WARNING_CODE, + type SecondaryModelWarning, +} from './secondaryModelWarning'; + +export class SessionSecondaryModelWarningService + extends Disposable + implements ISessionSecondaryModelWarningService +{ + declare readonly _serviceBrand: undefined; + + private warning: SecondaryModelWarning | undefined; + private checked = false; + + constructor( + @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, + @IConfigService private readonly config: IConfigService, + @IFlagService private readonly flags: IFlagService, + @IModelCatalog private readonly modelCatalog: IModelCatalog, + ) { + super(); + this._register( + this.agentLifecycle.onDidCreate((handle) => { + if (handle.id === MAIN_AGENT_ID) this.check(handle); + }), + ); + const main = this.agentLifecycle.get(MAIN_AGENT_ID); + if (main !== undefined) this.check(main); + } + + getSecondaryModelWarning(): SecondaryModelWarning | undefined { + return this.warning; + } + + private check(main: IAgentScopeHandle): void { + if (this.checked) return; + this.checked = true; + this.warning = this.computeWarning(); + if (this.warning !== undefined) { + main.accessor.get(IEventBus).publish({ + type: 'warning', + code: this.warning.code, + message: this.warning.message, + }); + } + } + + private computeWarning(): SecondaryModelWarning | undefined { + const secondary = resolveSecondaryModel(this.config, this.flags); + if (secondary?.model === undefined) return undefined; + let model: Model; + try { + model = this.modelCatalog.get(secondary.model); + } 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.', + }; + } + // 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, + ); + } +} + +function effortWarning( + alias: string, + effort: string | undefined, + supportEfforts: readonly string[] | undefined, +): SecondaryModelWarning | undefined { + const requested = normalizeRequestedThinkingEffort(effort); + if (requested === undefined || requested === 'off' || requested === 'on') return undefined; + const known = (supportEfforts ?? []) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + if (known.length === 0 || known.includes(requested)) return undefined; + return { + code: SECONDARY_MODEL_EFFORT_WARNING_CODE, + message: + `Secondary model default effort "${requested}" (from [secondary_model].default_effort / ${SECONDARY_MODEL_EFFORT_ENV}) ` + + `is not listed for model "${alias}" (known: ${known.join(', ')}). ` + + 'Subagents may clamp or reject it.', + }; +} + +registerScopedService( + LifecycleScope.Session, + ISessionSecondaryModelWarningService, + SessionSecondaryModelWarningService, + InstantiationType.Eager, + 'subagent', +); diff --git a/packages/agent-core-v2/src/session/subagent/tools/agent.ts b/packages/agent-core-v2/src/session/subagent/tools/agent.ts index e98c270ad5..275d76a29e 100644 --- a/packages/agent-core-v2/src/session/subagent/tools/agent.ts +++ b/packages/agent-core-v2/src/session/subagent/tools/agent.ts @@ -10,6 +10,14 @@ * under TaskList/TaskOutput/TaskStop when `run_in_background=true` or after * detach), and terminal text formatting. * + * Spawn bindings use an explicit tool choice first, then the target profile's + * symbolic model preference, before `resolveSubagentBinding` falls back to the + * configured secondary model or the caller's model. The selected alias is + * resolved through the model catalog before lifecycle allocation. A resumed + * agent keeps the model recorded in its own wire journal — with per-subagent + * models there is no "child follows the parent's current model" invariant to + * enforce. + * * Registered via the module-level `registerTool(AgentTool)` at the bottom of * this file — the same "import = register" pattern used by every builtin tool. */ @@ -56,6 +64,8 @@ import { } from '#/app/agentProfileCatalog/profile-shared'; import { ILogService } from '#/_base/log/log'; import { IConfigService } from '#/app/config/config'; +import { IFlagService } from '#/app/flag/flag'; +import { IModelCatalog } from '#/kosong/model/catalog'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { isSubagentMeta, subagentLabels, subagentParentAgentId } from '#/session/agentLifecycle/subagentMetadata'; import { ISessionProcessRunner } from '#/session/process/processRunner'; @@ -65,9 +75,13 @@ import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceCo import { emitAgentRunSpawned, mirrorAgentRun } from '../mirrorAgentRun'; import { ISessionSubagentService } from '../subagent'; import { + buildSubagentModelDescriptions, formatSubagentTimeoutDescription, + resolveSubagentBinding, resolveSubagentTimeoutMs, + wrapSubagentModelError, } from '../configSection'; +import { SECONDARY_MODEL_FLAG_ID } from '../flag'; import { SubagentTask, type SubagentHandle } from './subagent-task'; import AGENT_BACKGROUND_DISABLED_DESCRIPTION from './agent-background-disabled.md?raw'; @@ -116,6 +130,12 @@ export const AgentToolInputSchema = z.preprocess( .describe( 'If true, return immediately without waiting for completion. Prefer false unless the task can run independently and there is a clear benefit to not waiting.', ), + model: z + .enum(['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.', + ), }), ); @@ -167,6 +187,8 @@ export class AgentTool implements BuiltinTool { @ILogService private readonly log: ILogService, @IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService, @IConfigService private readonly config: IConfigService, + @IFlagService private readonly flags: IFlagService, + @IModelCatalog private readonly modelCatalog: IModelCatalog, ) { this.callerAgentId = scopeContext.agentId; this.canRunInBackground = () => @@ -179,7 +201,7 @@ export class AgentTool implements BuiltinTool { const backgroundDescription = this.canRunInBackground() ? AGENT_BACKGROUND_DESCRIPTION : AGENT_BACKGROUND_DISABLED_DESCRIPTION; - const baseDescription = `${AGENT_DESCRIPTION_BASE}\n\n${backgroundDescription}`; + let description = `${AGENT_DESCRIPTION_BASE}\n\n${backgroundDescription}`; const allowlist = subagentAllowlistFor(this.catalog, this.profile.data()); const profiles = allowlist === undefined @@ -190,10 +212,20 @@ export class AgentTool implements BuiltinTool { this.toolRegistry.listReferences(), (profile, name, source) => this.toolPolicy.isToolActiveForProfile(profile, name, source), + this.flags.enabled(SECONDARY_MODEL_FLAG_ID), ); - return typeLines - ? `${baseDescription}\n\nAvailable agent types (pass via subagent_type):\n${typeLines}` - : baseDescription; + if (typeLines) { + description += `\n\nAvailable agent types (pass via subagent_type):\n${typeLines}`; + } + const modelLines = buildSubagentModelDescriptions( + this.config, + this.flags, + this.profile.data().modelAlias, + ); + if (modelLines !== undefined) { + description += `\n\n${modelLines}`; + } + return description; } async resolveExecution(args: AgentToolInput): Promise { @@ -256,7 +288,6 @@ export class AgentTool implements BuiltinTool { throw new Error(`Agent instance "${resumeAgentId}" does not exist`); } await this.ensureOwnedIdleSubagent(resumeAgentId, target); - this.realignChildModel(target); agentId = target.id; profileName = target.accessor.get(IAgentProfileService).data().profileName ?? RESUMED_LABEL; @@ -277,15 +308,27 @@ export class AgentTool implements BuiltinTool { if (own.modelAlias === undefined) { throw new Error('Caller agent has no model bound'); } - const created = await this.lifecycle.create({ - binding: { - profile: profile.name, - model: own.modelAlias, - thinking: own.thinkingLevel, - cwd: own.cwd, - }, - labels: subagentLabels(this.callerAgentId), - }); + const binding = resolveSubagentBinding( + this.config, + this.flags, + { modelAlias: own.modelAlias, thinkingLevel: own.thinkingLevel }, + args.model ?? profile.modelPreference, + ); + let created: IAgentScopeHandle; + try { + this.modelCatalog.get(binding.model); + created = await this.lifecycle.create({ + binding: { + profile: profile.name, + model: binding.model, + thinking: binding.thinking, + cwd: own.cwd, + }, + labels: subagentLabels(this.callerAgentId), + }); + } catch (error) { + throw wrapSubagentModelError(error, binding.model, own.modelAlias); + } created.accessor.get(IAgentPermissionModeService).setMode(this.permissionMode.mode); created.accessor .get(IAgentUserToolService) @@ -343,14 +386,6 @@ export class AgentTool implements BuiltinTool { } } - private realignChildModel(target: IAgentScopeHandle): void { - const modelAlias = this.profile.data().modelAlias; - if (modelAlias === undefined) { - throw new Error('Caller agent has no model bound'); - } - target.accessor.get(IAgentProfileService).update({ modelAlias }); - } - private async execution( args: AgentToolInput, { toolCallId, signal }: ExecutableToolContext, @@ -479,6 +514,7 @@ function buildProfileDescriptions( name: string, source: ToolReference['source'], ) => boolean, + showModelPreferences: boolean, ): string { return profiles .map((profile) => { @@ -486,6 +522,10 @@ function buildProfileDescriptions( (part): part is string => part !== undefined && part.length > 0, ); const header = details.length === 0 ? `- ${profile.name}` : `- ${profile.name}: ${details.join(' ')}`; + const headerLines = + !showModelPreferences || profile.modelPreference === undefined + ? header + : `${header}\n Model preference: ${profile.modelPreference}`; const activeTools = resolveActiveToolNames(profile); const externallyRestricted = tools.some( (tool) => @@ -497,20 +537,20 @@ function buildProfileDescriptions( .filter((tool) => isToolActive(profile, tool.name, tool.source)) .map((tool) => tool.name); if (effectiveTools.length === 0) { - return `${header}\n Tools: none`; + return `${headerLines}\n Tools: none`; } - return `${header}\n Tools: ${effectiveTools.join(', ')}`; + return `${headerLines}\n Tools: ${effectiveTools.join(', ')}`; } if (activeTools === undefined) { if ((profile.disallowedTools?.length ?? 0) > 0) { - return `${header}\n Tools: all except ${profile.disallowedTools!.join(', ')}`; + return `${headerLines}\n Tools: all except ${profile.disallowedTools!.join(', ')}`; } - return `${header}\n Tools: all`; + return `${headerLines}\n Tools: all`; } if (activeTools.length === 0) { - return `${header}\n Tools: none`; + return `${headerLines}\n Tools: none`; } - return `${header}\n Tools: ${activeTools.join(', ')}`; + return `${headerLines}\n Tools: ${activeTools.join(', ')}`; }) .join('\n'); } diff --git a/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts b/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts index 2488c37356..1a9e6b3fa1 100644 --- a/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts +++ b/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts @@ -31,6 +31,7 @@ export interface AgentRunAttemptOptions { export interface AgentSpawnAttemptOptions extends AgentRunAttemptOptions { readonly profileName: string; readonly swarmItem?: string; + readonly binding?: { readonly model: string; readonly thinking?: string }; } export type AgentRunAttemptHandle = { @@ -301,6 +302,7 @@ export class AgentRunBatch { const spawnOptions: AgentSpawnAttemptOptions = { profileName: task.profileName, swarmItem: task.swarmItem, + binding: task.binding, ...runOptions, }; handle = await this.launcher.spawn(spawnOptions); @@ -649,4 +651,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 7915237aac..4f2fdf0a8a 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts @@ -28,6 +28,7 @@ type SessionSwarmTaskBase = { export type SessionSwarmSpawnTask = SessionSwarmTaskBase & { readonly kind: 'spawn'; readonly resumeAgentId?: undefined; + readonly binding?: { readonly model: string; readonly thinking?: string }; }; export type SessionSwarmResumeTask = SessionSwarmTaskBase & { diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts index 889c3fe17f..3e6c02b192 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts @@ -10,10 +10,17 @@ * carrying the swarm's tool-call context, `subagent.suspended` when a task is * requeued after a provider rate limit) are emitted here / via the * `agentLifecycle` wrapper helper `mirrorAgentRun`; the lifecycle registry - * itself stays flat. Bound at Session scope. + * itself stays flat. Spawn tasks may carry a concrete `binding` resolved by + * the caller (the `AgentSwarm` tool via `resolveSubagentBinding`); without + * one, spawns inherit the caller agent's model and thinking level. Spawn + * bindings are resolved through the model catalog before lifecycle allocation. + * Resumed agents keep the model recorded in their own wire journal — with + * per-subagent models there is no "child follows the parent's current model" + * invariant to enforce. Bound at Session scope. */ import type { TokenUsage } from '#/kosong/contract/usage'; +import { IModelCatalog } from '#/kosong/model/catalog'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; @@ -35,6 +42,7 @@ import { } from '#/session/agentLifecycle/subagentMetadata'; import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/subagent/mirrorAgentRun'; import { ISessionSubagentService } from '#/session/subagent/subagent'; +import { wrapSubagentModelError } from '#/session/subagent/configSection'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata, type AgentMeta } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionProcessRunner } from '#/session/process/processRunner'; @@ -82,6 +90,7 @@ export class SessionSwarmService implements ISessionSwarmService { @ISessionMetadata private readonly metadata: ISessionMetadata, @ISessionProcessRunner private readonly processRunner: ISessionProcessRunner, @ILogService private readonly log: ILogService, + @IModelCatalog private readonly modelCatalog: IModelCatalog, ) {} async getSwarmItem(args: { @@ -144,15 +153,25 @@ export class SessionSwarmService implements ISessionSwarmService { if (callerData.modelAlias === undefined) { throw new Error('Caller agent has no model bound'); } - const child = await this.lifecycle.create({ - binding: { - profile: profile.name, - model: callerData.modelAlias, - thinking: callerData.thinkingLevel, - cwd: callerData.cwd, - }, - labels: subagentLabels(callerAgentId, { swarmItem: options.swarmItem }), - }); + const binding = options.binding ?? { + model: callerData.modelAlias, + thinking: callerData.thinkingLevel, + }; + let child: IAgentScopeHandle; + try { + this.modelCatalog.get(binding.model); + child = await this.lifecycle.create({ + binding: { + profile: profile.name, + model: binding.model, + thinking: binding.thinking, + cwd: callerData.cwd, + }, + labels: subagentLabels(callerAgentId, { swarmItem: options.swarmItem }), + }); + } catch (error) { + throw wrapSubagentModelError(error, binding.model, callerData.modelAlias); + } child.accessor .get(IAgentPermissionModeService) .setMode(caller.accessor.get(IAgentPermissionModeService).mode); @@ -189,7 +208,6 @@ export class SessionSwarmService implements ISessionSwarmService { const caller = this.requireHandle(callerAgentId, 'Caller agent'); const child = this.requireHandle(agentId, 'Agent instance'); this.requireIdleSubagent(agentId, child); - this.realignChildModel(caller, child); const profileName = child.accessor.get(IAgentProfileService).data().profileName ?? RESUMED_PROFILE_FALLBACK; if (!retryTurn) { @@ -238,14 +256,6 @@ export class SessionSwarmService implements ISessionSwarmService { return handle; } - private realignChildModel(caller: IAgentScopeHandle, child: IAgentScopeHandle): void { - const modelAlias = caller.accessor.get(IAgentProfileService).data().modelAlias; - if (modelAlias === undefined) { - throw new Error('Caller agent has no model bound'); - } - child.accessor.get(IAgentProfileService).update({ modelAlias }); - } - private requireIdleSubagent(agentId: string, child: IAgentScopeHandle): void { if (child.accessor.get(IAgentLoopService).status().state === 'running') { throw new Error(`Agent instance "${agentId}" is already running and cannot run concurrently`); diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 3fe4f6b895..67d0e2393d 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": "