From 16c026a7383d763dba11626f297e7ac3d8677c37 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 27 Jul 2026 13:09:17 +0800 Subject: [PATCH 01/30] feat(agent-core): custom agent files and secondary model on the v1 engine Migrate the custom agentfile and secondary-model capabilities from agent-core-v2 to the v1 engine so they work in the TUI and plain kimi -p sessions: - discover Markdown agent files from user/project/extra/explicit directories with the v2 precedence rules, a merged session profile catalog replacing the hardcoded builtin profile lookups, SYSTEM.md main prompt override, and ${base_prompt} backed by the effective default - --agent/--agent-file now work in print mode on the default engine; CreateSessionOptions gains agentProfile/agentFiles - [secondary_model] config + KIMI_SECONDARY_MODEL/EFFORT bind newly spawned subagents to a cheaper model behind the secondary-model experiment flag, with primary/secondary model params on Agent and AgentSwarm and upfront session warnings - full disallowedTools deny semantics (exact names + mcp__ globs) evaluated by the tool manager and persisted in the agent wire --- .changeset/sdk-agent-profile-options.md | 5 + .changeset/v1-custom-agent-files.md | 5 + .changeset/v1-secondary-model.md | 5 + apps/kimi-code/src/cli/options.ts | 6 +- apps/kimi-code/src/cli/run-prompt.ts | 2 + apps/kimi-code/test/cli/options.test.ts | 9 +- docs/en/configuration/config-files.md | 2 +- docs/en/customization/agents.md | 8 +- docs/zh/configuration/config-files.md | 2 +- docs/zh/customization/agents.md | 8 +- packages/agent-core/src/agent/index.ts | 2 +- .../agent-core/src/agent/records/index.ts | 2 +- .../agent-core/src/agent/records/types.ts | 6 + packages/agent-core/src/agent/tool/index.ts | 52 +- packages/agent-core/src/config/index.ts | 1 + packages/agent-core/src/config/schema.ts | 18 + .../agent-core/src/config/secondary-model.ts | 146 +++++ packages/agent-core/src/config/toml.ts | 34 +- packages/agent-core/src/flags/registry.ts | 9 + .../src/profile/agentfile/catalog.ts | 297 +++++++++ .../src/profile/agentfile/discovery.ts | 125 ++++ .../src/profile/agentfile/from-file.ts | 134 ++++ .../agent-core/src/profile/agentfile/index.ts | 9 + .../src/profile/agentfile/parser.ts | 189 ++++++ .../agent-core/src/profile/agentfile/paths.ts | 56 ++ .../agent-core/src/profile/agentfile/roots.ts | 108 +++ .../src/profile/agentfile/system-file.ts | 82 +++ .../agent-core/src/profile/agentfile/types.ts | 40 ++ .../src/profile/agentfile/validate.ts | 59 ++ packages/agent-core/src/profile/index.ts | 1 + packages/agent-core/src/profile/resolve.ts | 4 + packages/agent-core/src/profile/types.ts | 17 + packages/agent-core/src/rpc/core-api.ts | 4 + packages/agent-core/src/rpc/core-impl.ts | 10 + packages/agent-core/src/session/index.ts | 132 +++- .../agent-core/src/session/subagent-batch.ts | 3 + .../src/session/subagent-binding.ts | 115 ++++ .../agent-core/src/session/subagent-host.ts | 84 ++- .../builtin/collaboration/agent-swarm.ts | 17 +- .../src/tools/builtin/collaboration/agent.ts | 31 +- .../test/agent/records/index.test.ts | 20 + packages/agent-core/test/agent/tool.test.ts | 34 +- packages/agent-core/test/agent/turn.test.ts | 9 +- .../test/config/secondary-model.test.ts | 212 ++++++ .../test/mcp/tool-manager-mcp.test.ts | 50 ++ .../agent-core/test/profile/agentfile.test.ts | 620 ++++++++++++++++++ .../test/session/subagent-host.test.ts | 228 ++++++- packages/agent-core/test/tools/agent.test.ts | 68 +- .../test/tools/builtin-current.test.ts | 1 + packages/node-sdk/src/types.ts | 10 + 50 files changed, 3017 insertions(+), 74 deletions(-) create mode 100644 .changeset/sdk-agent-profile-options.md create mode 100644 .changeset/v1-custom-agent-files.md create mode 100644 .changeset/v1-secondary-model.md create mode 100644 packages/agent-core/src/config/secondary-model.ts create mode 100644 packages/agent-core/src/profile/agentfile/catalog.ts create mode 100644 packages/agent-core/src/profile/agentfile/discovery.ts create mode 100644 packages/agent-core/src/profile/agentfile/from-file.ts create mode 100644 packages/agent-core/src/profile/agentfile/index.ts create mode 100644 packages/agent-core/src/profile/agentfile/parser.ts create mode 100644 packages/agent-core/src/profile/agentfile/paths.ts create mode 100644 packages/agent-core/src/profile/agentfile/roots.ts create mode 100644 packages/agent-core/src/profile/agentfile/system-file.ts create mode 100644 packages/agent-core/src/profile/agentfile/types.ts create mode 100644 packages/agent-core/src/profile/agentfile/validate.ts create mode 100644 packages/agent-core/src/session/subagent-binding.ts create mode 100644 packages/agent-core/test/config/secondary-model.test.ts create mode 100644 packages/agent-core/test/profile/agentfile.test.ts diff --git a/.changeset/sdk-agent-profile-options.md b/.changeset/sdk-agent-profile-options.md new file mode 100644 index 0000000000..175fa7d270 --- /dev/null +++ b/.changeset/sdk-agent-profile-options.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": patch +--- + +Add optional `agentProfile` and `agentFiles` fields to session creation options for selecting a custom main-agent profile. diff --git a/.changeset/v1-custom-agent-files.md b/.changeset/v1-custom-agent-files.md new file mode 100644 index 0000000000..680950583a --- /dev/null +++ b/.changeset/v1-custom-agent-files.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Support Markdown-defined custom agents on the default engine: agents discovered from user (`~/.kimi-code/agents/`) and project (`.kimi-code/agents/`) directories can be delegated to as sub-agents, `$KIMI_CODE_HOME/SYSTEM.md` overrides the default main agent's system prompt, and `kimi -p --agent/--agent-file` select the main agent without the experimental flag. Add a file like `~/.kimi-code/agents/reviewer.md` to define your own agent. diff --git a/.changeset/v1-secondary-model.md b/.changeset/v1-secondary-model.md new file mode 100644 index 0000000000..f4b11d3303 --- /dev/null +++ b/.changeset/v1-secondary-model.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Support a secondary model for subagents on the default engine (experimental): with `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1` and a `[secondary_model]` section in `config.toml`, newly spawned subagents bind the configured secondary model by default, and the `Agent`/`AgentSwarm` tools can pick `primary` or `secondary` per spawn. diff --git a/apps/kimi-code/src/cli/options.ts b/apps/kimi-code/src/cli/options.ts index 6e422c3e27..71afb4518f 100644 --- a/apps/kimi-code/src/cli/options.ts +++ b/apps/kimi-code/src/cli/options.ts @@ -1,5 +1,3 @@ -import { isKimiV2Enabled } from './experimental-v2'; - export type UIMode = 'shell' | 'print'; export type PromptOutputFormat = 'text' | 'stream-json'; @@ -101,10 +99,10 @@ export function validateOptions( } if ( (opts.agent !== undefined || opts.agentFiles.length > 0) && - (!promptMode || !isKimiV2Enabled(env)) + !promptMode ) { throw new OptionConflictError( - '--agent/--agent-file are only available with the v2 engine (kimi -p with KIMI_CODE_EXPERIMENTAL_FLAG=1).', + '--agent/--agent-file are only available in print mode (kimi -p).', ); } if (promptMode && opts.session === '') { diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index abee299622..db8db55c26 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -371,6 +371,8 @@ async function resolvePromptSession( model, permission: 'auto', additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, + agentProfile: opts.agent, + agentFiles: opts.agentFiles.length > 0 ? opts.agentFiles : undefined, drainAgentTasksOnStop: true, }); installHeadlessHandlers(session); diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 8bfe8561ef..8af79a9659 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -453,16 +453,13 @@ describe('CLI options parsing', () => { const opts = parse(['--agent', 'reviewer']); expect(() => validateOptions(opts)).toThrow(OptionConflictError); expect(() => validateOptions(opts)).toThrow( - '--agent/--agent-file are only available with the v2 engine', + '--agent/--agent-file are only available in print mode (kimi -p).', ); }); - it('rejects the flags in prompt mode without the v2 engine flag', () => { + it('accepts the flags in prompt mode without the v2 engine flag', () => { const opts = parse(['-p', 'hi', '--agent-file', 'a.md']); - expect(() => validateOptions(opts, {})).toThrow(OptionConflictError); - expect(() => validateOptions(opts, {})).toThrow( - '--agent/--agent-file are only available with the v2 engine', - ); + expect(validateOptions(opts, {}).uiMode).toBe('print'); }); it('accepts the flags in prompt mode with the v2 engine flag', () => { diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index e8df6e99cf..6e08c3080b 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -192,7 +192,7 @@ You can also switch models temporarily without touching the config file — by s 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. +This feature is experimental and disabled by default. Enable it with `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`, or the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` (which `kimi -p` already requires to select the v2 engine). It takes effect on every engine, including the interactive TUI. | Field | Type | Default | Description | | --- | --- | --- | --- | diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md index 209711bdca..2ab6e3a297 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -109,7 +109,7 @@ The body is the agent's system prompt, and it is rendered as a template each tim Unknown fields are ignored, so newer files stay readable by older versions. Fields from other agent tools (such as Claude Code's `model` or OpenCode's `mode`) are ignored the same way, the comma-separated `tools` form keeps Claude Code-style agent files loadable, and a missing `name` falls back to the file name so OpenCode-style files load too — a minimal file with `description` and a body works across tools. -`model_preference` applies only to newly spawned subagents when the secondary-model experiment is enabled. Under `kimi web`, set `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`; under experimental `kimi -p`, the required `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it. The TUI currently ignores this field. It never names a concrete model alias, and resumed subagents keep their existing model. The selected preference is shown to the main agent alongside the profile description so it can still pass an explicit `model` when a task needs a different choice. +`model_preference` applies only to newly spawned subagents when the secondary-model experiment is enabled — set `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`, or the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` (which `kimi -p` already requires to select the v2 engine). It takes effect on every engine, including the interactive TUI. The field 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. @@ -121,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 are currently available only under `kimi -p` with `KIMI_CODE_EXPERIMENTAL_FLAG=1`**; the interactive TUI rejects them with a clear error for now: +Two CLI flags select which agent drives the session. **Both are available in print mode (`kimi -p`), on either engine**; 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`. @@ -129,7 +129,7 @@ Two CLI flags select which agent drives the session. **Both are currently availa For example, in print mode: ```sh -KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p --agent reviewer "Review the changes on this branch" +kimi -p --agent reviewer "Review the changes on this branch" ``` The bound agent is the session's identity: it is fixed at the session's first bind and cannot be switched later. Re-selecting the already-bound agent (for example resuming with the same `--agent`) is a no-op; selecting a different one fails with an "already bound" error. @@ -138,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. 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. +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 takes effect on every engine, including interactive TUI sessions. 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/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index be8757e99b..045904130f 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -192,7 +192,7 @@ display_name = "Kimi for Coding (custom)" 次主力模型是主模型 `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 会忽略该配置。 +该功能目前是实验功能,默认关闭。通过 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1` 启用,或使用 master `KIMI_CODE_EXPERIMENTAL_FLAG=1`(`kimi -p` 选择 v2 引擎本就需要该 flag)。它在包括交互式 TUI 在内的所有引擎上生效。 | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | diff --git a/docs/zh/customization/agents.md b/docs/zh/customization/agents.md index 0dd0a9c8a7..f314f069c2 100644 --- a/docs/zh/customization/agents.md +++ b/docs/zh/customization/agents.md @@ -109,7 +109,7 @@ disallowedTools: 未知字段会被忽略,新版本写的文件在旧版本上仍可读取。其他 Agent 工具的字段(如 Claude Code 的 `model`、OpenCode 的 `mode`)同样会被忽略;加上 `tools` 的逗号分隔写法和 `name` 缺省回退到文件名,Claude Code 与 OpenCode 风格的 Agent 文件一般可直接加载 —— 只含 `description` 和正文的最小文件可跨工具通用。 -`model_preference` 仅在次主力模型实验功能启用时对新启动的子 Agent 生效。在 `kimi web` 下,设置 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`;在实验性 `kimi -p` 下,必需的 `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用该功能。TUI 目前会忽略此字段。该字段不用于填写具体模型 alias,已恢复的子 Agent 也会保持原模型。主 Agent 会在 profile 描述中看到这项偏好,因此仍可在某项任务需要不同选择时显式传入 `model`。 +`model_preference` 仅在次主力模型实验功能启用时对新启动的子 Agent 生效——设置 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`,或 master `KIMI_CODE_EXPERIMENTAL_FLAG=1`(`kimi -p` 选择 v2 引擎本就需要该 flag)。它在包括交互式 TUI 在内的所有引擎上生效。该字段不用于填写具体模型 alias,已恢复的子 Agent 也会保持原模型。主 Agent 会在 profile 描述中看到这项偏好,因此仍可在某项任务需要不同选择时显式传入 `model`。 目录中发现的非法文件会被跳过并告警,不影响其他文件。通过 `--agent-file` 显式传入的文件必须合法 —— 否则 CLI 会报错并退出。 @@ -121,7 +121,7 @@ disallowedTools: ### 选择主 Agent -两个 CLI flag 用于选择驱动会话的 Agent。**目前二者仅在 `KIMI_CODE_EXPERIMENTAL_FLAG=1` 时的 `kimi -p` 下可用**;交互式 TUI 会以明确错误拒绝它们: +两个 CLI flag 用于选择驱动会话的 Agent。**二者在 print 模式(`kimi -p`)下可用,两个引擎均支持**;交互式 TUI 会以明确错误拒绝它们: - **`--agent `**:以指定 Agent 作为主 Agent 启动会话。名称可以指向内置 Agent 或任何已发现的文件;名称不存在时会报错,并列出可用的 Agent。 - **`--agent-file `**:以最高优先级加载一个 Agent 文件(仅本次启动)并以其启动。该 flag 只接受一个文件:不可重复传入,也不能与 `--agent` 同时使用。 @@ -129,7 +129,7 @@ disallowedTools: 例如在 print 模式下: ```sh -KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p --agent reviewer "审查这个分支上的改动" +kimi -p --agent reviewer "审查这个分支上的改动" ``` 绑定的 Agent 即会话的身份:在会话首次绑定后即固定,之后不可切换。重复选择已绑定的 Agent(例如以相同的 `--agent` 恢复会话)是 no-op;选择不同的 Agent 会报 "already bound" 错误。 @@ -138,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 的系统提示词——但只替换提示词,描述与工具集仍沿用内置默认值。SYSTEM.md 目前仅在 `kimi web`,以及 `KIMI_CODE_EXPERIMENTAL_FLAG=1` 时的 `kimi -p` 下生效;交互式 TUI 会忽略该文件。 +希望永久覆盖主 Agent 的系统提示词、而不必每次启动都传入 `--agent` 或 `--agent-file` 时,可以写一份 `$KIMI_CODE_HOME/SYSTEM.md`(默认:`~/.kimi-code/SYSTEM.md`,随 `KIMI_CODE_HOME` 移动)。文件存在且非空期间,它整体替换内置默认主 Agent 的系统提示词——但只替换提示词,描述与工具集仍沿用内置默认值。SYSTEM.md 在包括交互式 TUI 会话在内的所有引擎上生效。 SYSTEM.md 是纯 Markdown 正文,不需要也不读取 Frontmatter。文件缺失或为空时不生效;读取失败时会告警并回退到内置提示词。优先级上,显式意图仍然胜出:项目作用域中声明了 `override: true` 的同名 Agent 文件、通过 `--agent-file` 传入的文件都排在 SYSTEM.md 之前,用 `--agent` 选择其他 Agent 时 SYSTEM.md 也不会生效;而在用户作用域内部,SYSTEM.md 优先于 `agents/` 目录中扫描到的同名文件。 diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 7786c5eb04..28841d73f8 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -438,7 +438,7 @@ export class Agent { ): void { this.setActiveProfile(profile, brandHome); this.updateSystemPromptFromProfile(profile, context); - this.tools.setActiveTools(profile.tools); + this.tools.setActiveTools(profile.tools, profile.disallowedTools); } setActiveProfile(profile: ResolvedAgentProfile, brandHome?: string): void { diff --git a/packages/agent-core/src/agent/records/index.ts b/packages/agent-core/src/agent/records/index.ts index 8a1df676a3..29511a738a 100644 --- a/packages/agent-core/src/agent/records/index.ts +++ b/packages/agent-core/src/agent/records/index.ts @@ -121,7 +121,7 @@ function restoreAgentRecord(agent: Agent, input: AgentRecord): void { // v2-engine wires may omit `names` (= every tool active); there is no // state to restore in that case, and calling setActiveTools(undefined) // would throw and wedge the whole resume. - if (Array.isArray(input.names)) agent.tools.setActiveTools(input.names); + if (Array.isArray(input.names)) agent.tools.setActiveTools(input.names, input.disallowedNames); return; case 'tools.update_store': agent.tools.updateStore(input.key, input.value); diff --git a/packages/agent-core/src/agent/records/types.ts b/packages/agent-core/src/agent/records/types.ts index 77310021f4..e9c1e1b240 100644 --- a/packages/agent-core/src/agent/records/types.ts +++ b/packages/agent-core/src/agent/records/types.ts @@ -81,6 +81,12 @@ export interface AgentRecordEvents { }; 'tools.set_active_tools': { names: readonly string[]; + /** + * Profile denylist applied on top of `names` (agentfile + * `disallowedTools`). Optional for backwards compatibility: wires written + * before deny support (and v2-engine wires) carry no deny state. + */ + disallowedNames?: readonly string[]; }; 'usage.record': { diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index 818571a7a1..a3752a0106 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -14,8 +14,8 @@ import type { McpConnectionManager, McpServerEntry } from '../../mcp'; import { mcpResultToExecutableOutput } from '../../mcp/output'; import { isMcpToolName, qualifyMcpToolName } from '../../mcp/tool-naming'; import type { MCPClient, MCPToolDefinition } from '../../mcp/types'; -import { DEFAULT_AGENT_PROFILES } from '../../profile'; import { resolveSubagentTimeoutMs } from '../../session/subagent-host'; +import { buildSubagentModelDescriptions } from '../../session/subagent-binding'; import { extendWorkspaceWithSkillRoots } from '../../skill'; import { fingerprint } from '../llm-request-logger'; import * as b from '../../tools/builtin'; @@ -56,6 +56,13 @@ export class ToolManager { protected enabledTools: Set = new Set(); /** Glob patterns (e.g. `mcp__*`, `mcp__github__*`) gating which MCP tools the profile exposes. */ private mcpAccessPatterns: string[] = []; + /** + * Exact builtin/user tool names the profile denies, evaluated on top of the + * allowlist result (`enabledTools`). + */ + private disabledTools: Set = new Set(); + /** Glob patterns (`mcp__…`) the profile denies, evaluated on top of `mcpAccessPatterns`. */ + private mcpDenyPatterns: string[] = []; /** * Defer-window lead for the loaded-tools ledger: names marked loaded whose * schema message may still sit in the context's deferred queue (an open tool @@ -522,15 +529,18 @@ export class ToolManager { }); } - setActiveTools(names: readonly string[]): void { + setActiveTools(names: readonly string[], disallowedNames?: readonly string[]): void { this.agent.records.logRecord({ type: 'tools.set_active_tools', names, + disallowedNames, }); // MCP entries are glob patterns gated separately; the rest are exact // builtin/user tool names. The split keeps every caller on one string[]. this.enabledTools = new Set(names.filter((name) => !isMcpToolName(name))); this.mcpAccessPatterns = names.filter((name) => isMcpToolName(name)); + this.disabledTools = new Set((disallowedNames ?? []).filter((name) => !isMcpToolName(name))); + this.mcpDenyPatterns = (disallowedNames ?? []).filter((name) => isMcpToolName(name)); // Builtin construction reads the enabled set (Bash/Agent bake // `allowBackground` from the Task* trio), and the constructor may already // have built the map while the enabled set was still empty. The lazy @@ -547,7 +557,15 @@ export class ToolManager { } private isMcpToolEnabled(name: string): boolean { - return this.mcpAccessPatterns.some((pattern) => picomatch.isMatch(name, pattern)); + return ( + this.mcpAccessPatterns.some((pattern) => picomatch.isMatch(name, pattern)) && + !this.mcpDenyPatterns.some((pattern) => picomatch.isMatch(name, pattern)) + ); + } + + /** An exact builtin/user tool name survives when allowed and not denied. */ + private isExactToolEnabled(name: string): boolean { + return this.enabledTools.has(name) && !this.disabledTools.has(name); } /** @@ -571,7 +589,7 @@ export class ToolManager { [...this.mcpTools.keys()].filter((name) => this.isMcpToolEnabled(name)), ); for (const name of this.deferredUserTools) { - if (this.userTools.has(name) && this.enabledTools.has(name)) names.add(name); + if (this.userTools.has(name) && this.isExactToolEnabled(name)) names.add(name); } return [...names].toSorted((a, b) => a.localeCompare(b)); } @@ -632,7 +650,7 @@ export class ToolManager { return ( this.deferredUserTools.has(name) && this.userTools.has(name) && - this.enabledTools.has(name) + this.isExactToolEnabled(name) ); } @@ -668,7 +686,7 @@ export class ToolManager { */ getDynamicToolSchema(name: string): Tool | undefined { const userTool = - this.deferredUserTools.has(name) && this.enabledTools.has(name) + this.deferredUserTools.has(name) && this.isExactToolEnabled(name) ? this.userTools.get(name) : undefined; const mcpTool = this.isMcpToolEnabled(name) ? this.mcpTools.get(name)?.tool : undefined; @@ -720,7 +738,7 @@ export class ToolManager { // select_tools is always registered but only offered while the // disclosure gate is open (see loopTools); report that live state. active: - this.enabledTools.has(tool.name) || + this.isExactToolEnabled(tool.name) || (tool.name === b.SELECT_TOOLS_TOOL_NAME && this.agent.toolSelectEnabled), source: 'builtin', }; @@ -729,7 +747,7 @@ export class ToolManager { yield { name: tool.name, description: tool.description, - active: this.enabledTools.has(tool.name), + active: this.isExactToolEnabled(tool.name), source: 'user', }; } @@ -821,11 +839,16 @@ export class ToolManager { new b.AgentTool( this.agent.subagentHost, background, - DEFAULT_AGENT_PROFILES['agent']?.subagents, + this.agent.subagentHost.delegatableSubagents(this.agent.config.profileName), { allowBackground, log: this.agent.log, subagentTimeoutMs: resolveSubagentTimeoutMs(this.agent.kimiConfig?.subagent?.timeoutMs), + subagentModelDescription: buildSubagentModelDescriptions( + this.agent.kimiConfig, + this.agent.experimentalFlags, + this.agent.config.modelAlias, + ), }, ), this.agent.subagentHost && @@ -833,6 +856,11 @@ export class ToolManager { this.agent.subagentHost, this.agent.swarmMode, resolveSubagentTimeoutMs(this.agent.kimiConfig?.subagent?.timeoutMs), + buildSubagentModelDescriptions( + this.agent.kimiConfig, + this.agent.experimentalFlags, + this.agent.config.modelAlias, + ), ), toolServices?.webSearcher && new b.WebSearchTool(toolServices.webSearcher), toolServices?.urlFetcher && new b.FetchURLTool(toolServices.urlFetcher), @@ -951,9 +979,11 @@ export class ToolManager { const loadedSet = disclosure ? this.loadedDynamicToolNames() : undefined; const enabledNames = loadedSet === undefined - ? [...this.enabledTools] + ? [...this.enabledTools].filter((name) => !this.disabledTools.has(name)) : [...this.enabledTools].filter( - (name) => !this.deferredUserTools.has(name) || loadedSet.has(name), + (name) => + !this.disabledTools.has(name) && + (!this.deferredUserTools.has(name) || loadedSet.has(name)), ); const mcpNames = loadedSet === undefined diff --git a/packages/agent-core/src/config/index.ts b/packages/agent-core/src/config/index.ts index 7b05879bb6..5542accbcb 100644 --- a/packages/agent-core/src/config/index.ts +++ b/packages/agent-core/src/config/index.ts @@ -7,4 +7,5 @@ export * from './resolve'; export * from './schema'; export * from './toml'; export * from './env-model'; +export * from './secondary-model'; export * from './workspace-local'; diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 8b7baea6ca..0e4b7bcaa7 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -93,6 +93,19 @@ export const ModelAliasSchema = ModelAliasBaseSchema.extend({ export type ModelAlias = z.infer; +/** + * The secondary-model recipe (`[secondary_model]` on disk): `model` points at + * a `[models]` entry and every remaining field is a subagent-only patch, + * materialized into a synthesized derived model entry at runtime (see + * `config/secondary-model.ts`). `default_effort` doubles as the subagent + * thinking effort. + */ +export const SecondaryModelConfigSchema = ModelAliasOverrideSchema.extend({ + model: z.string().min(1).optional(), +}); + +export type SecondaryModelConfig = z.infer; + export const ThinkingConfigSchema = z.object({ enabled: z.boolean().optional(), effort: z.string().optional(), @@ -338,9 +351,11 @@ export const KimiConfigSchema = z.object({ services: ServicesConfigSchema.optional(), mergeAllAvailableSkills: z.boolean().optional(), extraSkillDirs: z.array(z.string()).optional(), + extraAgentDirs: z.array(z.string()).optional(), loopControl: LoopControlSchema.optional(), background: BackgroundConfigSchema.optional(), subagent: SubagentConfigSchema.optional(), + secondaryModel: SecondaryModelConfigSchema.optional(), mcp: McpConfigSchema.optional(), image: ImageConfigSchema.optional(), modelCatalog: ModelCatalogConfigSchema.optional(), @@ -358,6 +373,7 @@ const PermissionConfigPatchSchema = PermissionConfigSchema.partial(); const LoopControlPatchSchema = LoopControlSchema.partial(); const BackgroundConfigPatchSchema = BackgroundConfigSchema.partial(); const SubagentConfigPatchSchema = SubagentConfigSchema.partial(); +const SecondaryModelConfigPatchSchema = SecondaryModelConfigSchema.partial(); const McpConfigPatchSchema = McpConfigSchema.partial(); const ImageConfigPatchSchema = ImageConfigSchema.partial(); const ModelCatalogConfigPatchSchema = ModelCatalogConfigSchema.partial(); @@ -384,9 +400,11 @@ export const KimiConfigPatchSchema = z services: ServicesConfigPatchSchema.optional(), mergeAllAvailableSkills: z.boolean().optional(), extraSkillDirs: z.array(z.string()).optional(), + extraAgentDirs: z.array(z.string()).optional(), loopControl: LoopControlPatchSchema.optional(), background: BackgroundConfigPatchSchema.optional(), subagent: SubagentConfigPatchSchema.optional(), + secondaryModel: SecondaryModelConfigPatchSchema.optional(), mcp: McpConfigPatchSchema.optional(), image: ImageConfigPatchSchema.optional(), modelCatalog: ModelCatalogConfigPatchSchema.optional(), diff --git a/packages/agent-core/src/config/secondary-model.ts b/packages/agent-core/src/config/secondary-model.ts new file mode 100644 index 0000000000..e80f4de15c --- /dev/null +++ b/packages/agent-core/src/config/secondary-model.ts @@ -0,0 +1,146 @@ +import type { + KimiConfig, + ModelAlias, + ModelAliasOverrides, + SecondaryModelConfig, +} from './schema'; + +/** + * Secondary-model runtime overlay. + * + * `[secondary_model]` is a recipe: `model` points at a `[models]` entry and + * every remaining field is a subagent-only patch. When patch fields exist, + * {@link applySecondaryModelConfig} synthesizes the derived model entry + * ({@link SECONDARY_DERIVED_MODEL_ALIAS}) into the in-memory `models` view — a + * copy of the pointed entry with the patch merged into its `overrides` block + * (patch wins conflicts) — so subagent binding resolves it through the + * standard alias path, riding the same `effectiveModelAlias` merge as any + * `models.*.overrides`. With no patch fields, subagents bind the pointed + * entry directly and nothing is synthesized. + * + * The `KIMI_SECONDARY_MODEL` / `KIMI_SECONDARY_EFFORT` env vars override + * `model` / `default_effort` in memory only: like the `KIMI_MODEL_*` + * synthesized entries, the derived entry and the env-injected fields must + * never reach `config.toml`. {@link stripSecondaryModelConfig} is the write + * path mirror, wired next to `stripEnvModelConfig`. + */ + +export const SECONDARY_DERIVED_MODEL_ALIAS = '__secondary__'; +export const SECONDARY_MODEL_ENV = 'KIMI_SECONDARY_MODEL'; +export const SECONDARY_MODEL_EFFORT_ENV = 'KIMI_SECONDARY_EFFORT'; + +type Env = Readonly>; + +function trimmed(value: string | undefined): string | undefined { + const t = value?.trim(); + return t === undefined || t.length === 0 ? undefined : t; +} + +/** + * 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, +): ModelAliasOverrides | undefined { + if (secondary === undefined) return undefined; + const { model: _model, ...patch } = secondary; + return Object.keys(patch).length > 0 ? patch : undefined; +} + +/** + * Apply the secondary-model runtime view: env overrides for the recipe, then + * the derived-entry synthesis. Returns the config unchanged when neither + * applies. Nothing is synthesized when `secondary.model` is unset or the + * pointed entry does not exist (the session warning reports the dangling + * pointer; spawn fails with the wrapped error). + */ +export function applySecondaryModelConfig(config: KimiConfig, env: Env = process.env): KimiConfig { + let secondary = config.secondaryModel; + const envModel = trimmed(env[SECONDARY_MODEL_ENV]); + const envEffort = trimmed(env[SECONDARY_MODEL_EFFORT_ENV]); + if (envModel !== undefined || envEffort !== undefined) { + secondary = { + ...secondary, + ...(envModel !== undefined ? { model: envModel } : {}), + ...(envEffort !== undefined ? { defaultEffort: envEffort } : {}), + }; + } + + let next = secondary === config.secondaryModel ? config : { ...config, secondaryModel: secondary }; + + const patch = secondaryModelPatch(secondary); + const baseId = secondary?.model; + if (patch === undefined || baseId === undefined || baseId === SECONDARY_DERIVED_MODEL_ALIAS) { + return next; + } + const base = next.models?.[baseId]; + if (base === undefined) return next; + + const { overrides: baseOverrides, ...baseFields } = base; + const derived: ModelAlias = { + ...baseFields, + overrides: { ...baseOverrides, ...patch }, + }; + return { + ...next, + models: { ...next.models, [SECONDARY_DERIVED_MODEL_ALIAS]: derived }, + }; +} + +/** + * Remove the runtime-only secondary-model state before a config is persisted + * to disk: the synthesized derived entry (never a legitimate on-disk entry, + * mirroring the v2 overlay which strips a user-configured entry under the + * reserved id all the same), a `default_model` pointer at the derived entry + * (restored from raw so it cannot dangle after the recipe is removed), and + * the env-injected recipe fields (restored from raw while the env vars are + * set, so a `getConfig` -> `setConfig` round-trip cannot persist shell + * overrides). + */ +export function stripSecondaryModelConfig( + config: KimiConfig, + env: Env = process.env, +): KimiConfig { + let next = config; + + if (next.models !== undefined && SECONDARY_DERIVED_MODEL_ALIAS in next.models) { + const models = { ...next.models }; + delete models[SECONDARY_DERIVED_MODEL_ALIAS]; + next = { ...next, models }; + } + + if (next.defaultModel === SECONDARY_DERIVED_MODEL_ALIAS) { + const rawDefault = config.raw?.['default_model']; + next = { + ...next, + defaultModel: typeof rawDefault === 'string' ? rawDefault : undefined, + }; + } + + const envModelSet = trimmed(env[SECONDARY_MODEL_ENV]) !== undefined; + const envEffortSet = trimmed(env[SECONDARY_MODEL_EFFORT_ENV]) !== undefined; + if ((envModelSet || envEffortSet) && next.secondaryModel !== undefined) { + const raw = isPlainObject(config.raw?.['secondary_model']) ? config.raw['secondary_model'] : {}; + const restored = { ...next.secondaryModel }; + if (envModelSet) { + if (typeof raw['model'] === 'string') restored.model = raw['model']; + else delete restored.model; + } + if (envEffortSet) { + if (typeof raw['default_effort'] === 'string') restored.defaultEffort = raw['default_effort']; + else delete restored.defaultEffort; + } + next = { + ...next, + secondaryModel: Object.keys(restored).length > 0 ? restored : undefined, + }; + } + + return next; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index ff1285a6df..2aa143b09e 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -4,6 +4,7 @@ import { dirname } from 'pathe'; import { ErrorCodes, KimiError } from '#/errors'; import { applyEnvModelConfig, stripEnvModelConfig } from './env-model'; +import { applySecondaryModelConfig, stripSecondaryModelConfig } from './secondary-model'; import { KimiConfigSchema, formatConfigValidationError, @@ -20,6 +21,7 @@ import { type OAuthRef, type PermissionConfig, type ProviderConfig, + type SecondaryModelConfig, type ServicesConfig, type SubagentConfig, type ThinkingConfig, @@ -104,7 +106,7 @@ export function loadRuntimeConfig( filePath: string, env: Readonly> = process.env, ): KimiConfig { - return applyEnvModelConfig(readConfigFile(filePath), env); + return applySecondaryModelConfig(applyEnvModelConfig(readConfigFile(filePath), env), env); } export interface RuntimeConfigLoadResult { @@ -199,6 +201,8 @@ export function loadRuntimeConfigSafe( `Ignoring KIMI_MODEL_* environment overrides: ${describeUnknownError(error)}`, ); } + // Never throws: the secondary overlay only copies already-validated entries. + config = applySecondaryModelConfig(config, env); return { config, fileWarnings, envWarnings, fileError }; } @@ -321,6 +325,8 @@ export function transformTomlData(data: Record): Record): Record { - // Final guard: never persist the env-synthesized model/provider to disk, - // even if a caller passes back the runtime config as a patch (see - // stripEnvModelConfig / the getConfig -> setConfig round-trip). - const validated = validateConfig(stripEnvModelConfig(config)); + // Final guard: never persist the env-synthesized model/provider or the + // secondary-model runtime view to disk, even if a caller passes back the + // runtime config as a patch (see stripEnvModelConfig / + // stripSecondaryModelConfig / the getConfig -> setConfig round-trip). + const validated = validateConfig(stripSecondaryModelConfig(stripEnvModelConfig(config))); await mkdir(dirname(filePath), { recursive: true, mode: 0o700 }); await atomicWrite(filePath, `${stringifyToml(configToTomlData(validated))}\n`); } @@ -486,6 +493,7 @@ export function configToTomlData(config: KimiConfig): Record { 'defaultPlanMode', 'mergeAllAvailableSkills', 'extraSkillDirs', + 'extraAgentDirs', 'telemetry', ]; for (const key of scalarFields) { @@ -499,6 +507,7 @@ export function configToTomlData(config: KimiConfig): Record { setSection(out, 'loop_control', config.loopControl, loopControlToToml); setSection(out, 'background', config.background, backgroundToToml); setSection(out, 'subagent', config.subagent, subagentToToml); + setSection(out, 'secondary_model', config.secondaryModel, secondaryModelToToml); setSection(out, 'mcp', config.mcp, mcpToToml); setSection(out, 'image', config.image, imageToToml); setSection(out, 'experimental', config.experimental, experimentalToToml); @@ -690,6 +699,21 @@ function subagentToToml(subagent: SubagentConfig, rawSubagent: unknown): Record< return out; } +function secondaryModelToToml( + secondaryModel: SecondaryModelConfig, + rawSecondaryModel: unknown, +): Record { + const out = cloneRecord(rawSecondaryModel); + for (const [key, value] of Object.entries(secondaryModel)) { + if (key === 'capabilities' && Array.isArray(value)) { + out[camelToSnake(key)] = [...value]; + } else { + setDefined(out, camelToSnake(key), value); + } + } + return out; +} + function mcpToToml(mcp: McpConfig, rawMcp: unknown): Record { const out = cloneRecord(rawMcp); for (const [key, value] of Object.entries(mcp)) { diff --git a/packages/agent-core/src/flags/registry.ts b/packages/agent-core/src/flags/registry.ts index dbec75b80b..55903c6d00 100644 --- a/packages/agent-core/src/flags/registry.ts +++ b/packages/agent-core/src/flags/registry.ts @@ -32,6 +32,15 @@ export const FLAG_DEFINITIONS = [ default: false, surface: 'core', }, + { + id: 'secondary-model', + title: 'Secondary model for subagents', + description: + 'Let newly spawned subagents use a separately configured secondary model by default, with an explicit primary-model override for quality-sensitive tasks.', + env: 'KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', + default: false, + surface: 'core', + }, ] as const satisfies readonly FlagDefinitionInput[]; /** Literal union of registered flag ids. */ diff --git a/packages/agent-core/src/profile/agentfile/catalog.ts b/packages/agent-core/src/profile/agentfile/catalog.ts new file mode 100644 index 0000000000..282f78dac6 --- /dev/null +++ b/packages/agent-core/src/profile/agentfile/catalog.ts @@ -0,0 +1,297 @@ +/** + * Session-level agent profile catalog. + * + * Merges the builtin (code-embedded) profiles with the file-backed sources + * (user / extra / project / explicit) by priority, requiring an explicit + * opt-in (`override: true`) before a file replaces a same-name builtin. The + * merged view always contains the builtin profiles (seeded at construction); + * file profiles appear once `ready` resolves. A failing `explicit` source (an + * invalid `--agent-file`) rejects `ready` so session creation surfaces the + * error; a failing directory source degrades to warnings, so directory + * problems never poison the session. + * + * After merging, the catalog links the delegation graph: a file profile's + * `subagents` allowlist resolves against the merged set (an omitted allowlist + * means "any type"), and the builtin default profile's subagent set extends + * with every file-defined profile so the main agent can delegate to custom + * agents. + */ + +import { DEFAULT_AGENT_PROFILES } from '../default'; +import type { ResolvedAgentProfile } from '../types'; + +import { discoverAgentFiles } from './discovery'; +import { agentProfileFromFile } from './from-file'; +import { resolveAgentPath } from './paths'; +import { configuredAgentRoots, projectAgentRoots, userAgentRoots } from './roots'; +import { loadSystemMdDefinition, systemMdProfile } from './system-file'; +import { describeInactiveToolPattern, findInactiveToolPatterns } from './validate'; +import type { AgentFileDefinition, AgentFileSource } from './types'; +import { promises as fs } from 'node:fs'; +import { parseAgentFileText } from './parser'; + +export interface SessionAgentCatalogOptions { + readonly workDir: string; + /** Brand data dir (`KIMI_CODE_HOME`, default `~/.kimi-code`). */ + readonly brandHomeDir: string; + /** OS home dir, for `~/.agents/agents` and `~` expansion. */ + readonly osHomeDir: string; + readonly extraDirs?: readonly string[]; + readonly explicitFiles?: readonly string[]; + readonly warn?: ((message: string, error?: unknown) => void) | undefined; +} + +const SOURCE_PRIORITY: Readonly> = { + user: 10, + extra: 20, + project: 30, + explicit: 40, +}; + +export const DEFAULT_AGENT_PROFILE_NAME = 'agent'; + +/** Exact tool names known to the builtin profiles (MCP glob entries excluded). */ +const KNOWN_BUILTIN_TOOL_NAMES: ReadonlySet = new Set( + Object.values(DEFAULT_AGENT_PROFILES).flatMap((profile) => + profile.tools.filter((tool) => !tool.startsWith('mcp__')), + ), +); + +function isKnownBuiltinToolName(name: string): boolean { + return KNOWN_BUILTIN_TOOL_NAMES.has(name); +} + +interface FileProfileEntry { + readonly definition: AgentFileDefinition; + readonly profile: ResolvedAgentProfile; + readonly priority: number; + readonly override: boolean; +} + +export class SessionAgentProfileCatalog { + private merged: Map; + private readonly readyPromise: Promise; + + constructor(private readonly options: SessionAgentCatalogOptions) { + this.merged = new Map(Object.entries(DEFAULT_AGENT_PROFILES)); + this.readyPromise = this.load(); + // Keep an un-awaited rejection from crashing the process; createMain / + // spawn awaiters see the error through `ready`. + void this.readyPromise.catch(() => undefined); + } + + get ready(): Promise { + return this.readyPromise; + } + + get(name: string): ResolvedAgentProfile | undefined { + return this.merged.get(name); + } + + getDefault(): ResolvedAgentProfile { + const profile = this.get(DEFAULT_AGENT_PROFILE_NAME); + if (profile === undefined) { + throw new Error( + `Default agent profile "${DEFAULT_AGENT_PROFILE_NAME}" is not registered`, + ); + } + return profile; + } + + list(): readonly ResolvedAgentProfile[] { + return [...this.merged.values()]; + } + + /** + * The subagent types `callerProfileName` may delegate to: the caller's own + * linked set, falling back to the default profile's set when the caller + * declares none (mirroring the historical lookup against the builtin + * `agent` profile). + */ + delegatableSubagents(callerProfileName?: string): Record { + const caller = callerProfileName === undefined ? undefined : this.merged.get(callerProfileName); + const record = caller?.subagents ?? this.getDefault().subagents; + return record ?? {}; + } + + private async load(): Promise { + const warn = this.warn; + const entries: FileProfileEntry[] = []; + + // ── Directory sources (non-fatal) ──────────────────────────────── + // Each source scans its own roots: a same-named file in a higher- + // priority source must shadow, not swallow, the lower-priority one. + const [userRoots, projectRoots, extraRoots] = await Promise.all([ + userAgentRoots(this.options.brandHomeDir, this.options.osHomeDir, warn), + projectAgentRoots(this.options.workDir, warn), + configuredAgentRoots( + this.options.extraDirs ?? [], + this.options.workDir, + this.options.osHomeDir, + 'extra', + warn, + ), + ]); + + // SYSTEM.md is pushed first: within the user source it wins the `agent` + // name over directory files (first candidate per priority wins). + const systemMd = await loadSystemMdDefinition(this.options.brandHomeDir, (message) => + warn?.(message), + ); + + // The base every file profile's `${base_prompt}` renders against — the + // "effective default": the SYSTEM.md override when present, else the + // builtin default. This slot is structurally disjoint from the merge + // (only ever SYSTEM.md or builtin), so a file profile that overrides the + // default can never recurse into itself through `${base_prompt}`. The + // chain is at most: agent file → SYSTEM.md → builtin default. + const builtinDefault = this.merged.get(DEFAULT_AGENT_PROFILE_NAME) ?? this.getDefault(); + const effectiveDefault = + systemMd !== undefined ? systemMdProfile(systemMd, builtinDefault) : builtinDefault; + + if (systemMd !== undefined) { + entries.push(this.systemMdEntry(systemMd, effectiveDefault)); + } + + for (const roots of [userRoots, extraRoots, projectRoots]) { + if (roots.length === 0) continue; + const discovered = await discoverAgentFiles(roots, warn); + for (const definition of discovered.agents) { + this.warnInactivePatterns(definition); + entries.push(this.entryFromDefinition(definition, effectiveDefault)); + } + } + + // ── Explicit source (fatal) ────────────────────────────────────── + for (const file of this.options.explicitFiles ?? []) { + const path = resolveAgentPath(file, this.options.workDir, this.options.osHomeDir); + const text = await fs.readFile(path, 'utf-8'); + const definition = parseAgentFileText({ path, source: 'explicit', text }); + this.warnInactivePatterns(definition); + entries.push(this.entryFromDefinition(definition, effectiveDefault)); + } + + this.applyFileEntries(entries); + } + + /** + * Surface dead tool patterns (bare wildcards, incomplete `mcp__` literals, + * unknown tool names) at load time, so a typo in a hand-written agent file + * warns instead of silently shrinking the profile's tool set. + */ + private warnInactivePatterns(definition: AgentFileDefinition): void { + const warn = this.warn; + if (warn === undefined) return; + const fields: readonly (readonly [string, readonly string[] | undefined])[] = [ + ['tools', definition.tools], + ['disallowedTools', definition.disallowedTools], + ]; + for (const [field, patterns] of fields) { + if (patterns === undefined) continue; + for (const issue of findInactiveToolPatterns(patterns, isKnownBuiltinToolName)) { + warn(`agent file ${definition.path}: ${field} entry ${describeInactiveToolPattern(issue)}`); + } + } + } + + private systemMdEntry( + definition: AgentFileDefinition, + effectiveDefault: ResolvedAgentProfile, + ): FileProfileEntry { + return { + definition, + profile: effectiveDefault, + priority: SOURCE_PRIORITY['user'], + // SYSTEM.md permanently replaces the builtin default prompt. + override: true, + }; + } + + private entryFromDefinition( + definition: AgentFileDefinition, + effectiveDefault: ResolvedAgentProfile, + ): FileProfileEntry { + return { + definition, + profile: agentProfileFromFile(definition, effectiveDefault.tools, (context) => + effectiveDefault.systemPrompt(context), + ), + priority: SOURCE_PRIORITY[definition.source], + override: definition.override || definition.source === 'explicit', + }; + } + + private applyFileEntries(entries: readonly FileProfileEntry[]): void { + const warn = this.warn; + const merged = new Map(this.merged); + const byName = new Map(); + for (const entry of [...entries].toSorted((a, b) => b.priority - a.priority)) { + const candidates = byName.get(entry.definition.name) ?? []; + candidates.push(entry); + byName.set(entry.definition.name, candidates); + } + const winners: FileProfileEntry[] = []; + for (const candidates of byName.values()) { + for (const candidate of candidates) { + if (merged.has(candidate.definition.name) && !candidate.override) { + warn?.( + `agent file profile "${candidate.definition.name}" ignored: a same-name builtin profile exists; set "override: true" in the frontmatter to replace it`, + ); + continue; + } + merged.set(candidate.definition.name, candidate.profile); + winners.push(candidate); + break; + } + } + + // Link the file profiles' delegation allowlists against the merged set. + for (const winner of winners) { + winner.profile.subagents = this.linkSubagentAllowlist(winner.definition, merged, warn); + } + + // Extend the builtin default's delegation set with every file-defined + // profile so the main agent can delegate to custom agents. (A file + // profile that replaced the default carries its own allowlist instead.) + const defaultIsBuiltin = !winners.some( + (winner) => winner.definition.name === DEFAULT_AGENT_PROFILE_NAME, + ); + if (defaultIsBuiltin && winners.length > 0) { + const builtinDefault = this.getDefault(); + const fileRecord: Record = {}; + for (const winner of winners) fileRecord[winner.definition.name] = winner.profile; + merged.set(DEFAULT_AGENT_PROFILE_NAME, { + ...builtinDefault, + subagents: { ...builtinDefault.subagents, ...fileRecord }, + }); + } + + this.merged = merged; + } + + private linkSubagentAllowlist( + definition: AgentFileDefinition, + merged: ReadonlyMap, + warn: ((message: string, error?: unknown) => void) | undefined, + ): Record { + // An omitted allowlist means "any type"; a lone `*` was already + // normalized away by the parser. + const names = definition.subagents ?? [...merged.keys()]; + const record: Record = {}; + for (const name of names) { + const target = merged.get(name); + if (target === undefined) { + warn?.( + `agent file profile "${definition.name}" declares subagent "${name}" but that agent profile was not found`, + ); + continue; + } + record[name] = target; + } + return record; + } + + private get warn(): ((message: string, error?: unknown) => void) | undefined { + return this.options.warn; + } +} diff --git a/packages/agent-core/src/profile/agentfile/discovery.ts b/packages/agent-core/src/profile/agentfile/discovery.ts new file mode 100644 index 0000000000..4003c0b204 --- /dev/null +++ b/packages/agent-core/src/profile/agentfile/discovery.ts @@ -0,0 +1,125 @@ +/** + * Filesystem agent-file discovery. + * + * Discovers and parses agent files. Invalid files are isolated from the rest + * of the discovery pass. Failure policy: below a root, ANY readdir failure + * (notably EACCES) skips just that directory — one unreadable subdirectory + * must not zero the whole source, mirroring the skill discovery's + * per-directory tolerance; at a root, a missing directory is simply "no + * agents here", and any other failure skips just that root. Skip warnings are + * capped (`MAX_SKIP_WARNINGS`) so a misconfigured root (e.g. an extra dir + * pointing at a docs-heavy tree) cannot spam one line per non-agent file; + * the returned `skipped` list keeps the full parse-failure detail regardless, + * and the capping summary names a few suppressed paths so the rest stay + * findable. + */ + +import { promises as fs } from 'node:fs'; +import { join } from 'pathe'; + +import { AgentFileParseError, parseAgentFileText } from './parser'; +import { isDirectoryPath, isFilePath, isMissingPathError } from './paths'; +import type { + AgentFileDefinition, + AgentFileDiscoveryResult, + AgentFileRoot, + SkippedAgentFile, +} from './types'; + +const MAX_AGENT_SCAN_DEPTH = 8; +const MAX_SKIP_WARNINGS = 5; + +export interface DiscoverAgentFilesWarn { + (message: string, error?: unknown): void; +} + +export async function discoverAgentFiles( + roots: readonly AgentFileRoot[], + warn?: DiscoverAgentFilesWarn, +): Promise { + const byName = new Map(); + const skipped: SkippedAgentFile[] = []; + + let emittedWarnings = 0; + let suppressedWarnings = 0; + const suppressedSubjects: string[] = []; + const warnCapped = (subject: string, message: string, error?: unknown): void => { + if (emittedWarnings < MAX_SKIP_WARNINGS) { + emittedWarnings += 1; + warn?.(message, error); + } else { + suppressedWarnings += 1; + if (suppressedSubjects.length < 3) suppressedSubjects.push(subject); + } + }; + + async function parseAndRegister(filePath: string, root: AgentFileRoot): Promise { + try { + const text = await fs.readFile(filePath, 'utf-8'); + const agent = parseAgentFileText({ path: filePath, source: root.source, text }); + if (!byName.has(agent.name)) { + byName.set(agent.name, agent); + } + } catch (error) { + if (error instanceof AgentFileParseError) { + skipped.push({ path: filePath, reason: error.message }); + warnCapped(filePath, `Skipping invalid agent file at ${filePath}: ${error.message}`, error); + } else { + warnCapped(filePath, `Skipping agent file at ${filePath} due to unexpected error`, error); + } + } + } + + async function walk(dirPath: string, root: AgentFileRoot, depth: number): Promise { + if (depth > MAX_AGENT_SCAN_DEPTH) return; + + let entries: readonly string[]; + try { + entries = (await fs.readdir(dirPath)).toSorted(); + } catch (error) { + if (depth > 0) { + warnCapped(dirPath, `Skipping unreadable directory ${dirPath}: ${errorMessage(error)}`, error); + return; + } + if (isMissingPathError(error)) return; + warnCapped(dirPath, `Skipping unreadable agent root ${dirPath}: ${errorMessage(error)}`, error); + return; + } + + for (const entry of entries) { + if (entry.startsWith('.') || entry === 'node_modules') continue; + const entryPath = join(dirPath, entry); + try { + if (await isDirectoryPath(entryPath)) { + await walk(entryPath, root, depth + 1); + continue; + } + if (!entry.endsWith('.md') || !(await isFilePath(entryPath))) continue; + await parseAndRegister(entryPath, root); + } catch (error) { + warnCapped(entryPath, `Skipping unreadable agent path ${entryPath}: ${errorMessage(error)}`, error); + } + } + } + + for (const root of roots) { + await walk(root.path, root, 0); + } + + if (suppressedWarnings > 0) { + const examples = suppressedSubjects.map((subject) => `"${subject}"`).join(', '); + warn?.( + `Suppressed ${suppressedWarnings} further agent-discovery skip warnings (e.g. ${examples}); fix or remove the offending files/directories to silence them`, + ); + } + + return { + agents: [...byName.values()].toSorted((a, b) => a.name.localeCompare(b.name)), + skipped, + scannedRoots: roots.map((root) => root.path), + }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/agent-core/src/profile/agentfile/from-file.ts b/packages/agent-core/src/profile/agentfile/from-file.ts new file mode 100644 index 0000000000..23b37c3f02 --- /dev/null +++ b/packages/agent-core/src/profile/agentfile/from-file.ts @@ -0,0 +1,134 @@ +/** + * `AgentFileDefinition` → `ResolvedAgentProfile` adapter. + * + * The file body is a prompt template rendered against the agent-file variable + * table: `${var}` placeholders substitute live context, and `${base_prompt}` + * embeds the builtin default profile's prompt so a file can wrap the builtin + * behavior instead of replacing it. The variable names and semantics match + * the v2 engine (and the user docs), so the same agent file works on both + * engines. The renderer is a plain `${var}` substitution — NOT the nunjucks + * renderer the builtin YAML profiles use — so a literal `{{...}}` or an + * unknown `${...}` in a user template can never crash rendering. + * + * `tools` resolves to the effective allowlist here: an omitted `tools` means + * the default profile's tool set (v1 profiles have no "every tool" sentinel). + * `disallowedTools` passes through to the profile verbatim and is evaluated + * by the tool manager on top of the allowlist — exact builtin/user names and + * `mcp__…` glob patterns both work, including partial server denies such as + * `mcp__github__*` under an `mcp__*` allow. `subagents` stays an allowlist + * of names on the definition; the catalog links it into the resolved record + * after merging. + */ + +import type { ResolvedAgentProfile, SystemPromptContext } from '../types'; + +import type { AgentFileDefinition } from './types'; + +const PROMPT_VARIABLE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g; + +function renderTemplateVars(template: string, vars: Record): string { + return template.replace(PROMPT_VARIABLE, (match: string, name: string) => { + const value = vars[name]; + return typeof value === 'string' ? value : match; + }); +} + +const WINDOWS_NOTES = + 'IMPORTANT: You are on Windows. The Bash tool runs through Git Bash, so use Unix shell syntax inside Bash commands — `/dev/null` not `NUL`, and forward slashes in paths. For file operations, always prefer the built-in tools (Read, Write, Edit, Glob, Grep) over Bash commands — they work reliably across all platforms.'; + +const ADDITIONAL_DIRS_SECTION_PROSE = + 'The following directories have been added to the workspace. You can read, write, search, and glob files in these directories as part of your workspace scope.'; + +const SKILLS_SECTION_PROSE = + 'Skills are reusable, composable capabilities that enhance your abilities. Each skill is either a self-contained directory with a `SKILL.md` file or a standalone `.md` file that contains instructions, examples, and/or reference material.\n\n' + + 'Identify the skills relevant to your current task and read the skill file for its instructions; only read further skill details when needed, to conserve the context window.\n\n' + + '## Available skills\n\n' + + 'Skills are grouped by scope (`Project`, `User`, `Extra`, `Built-in`) so you can tell where each came from. When the user refers to "the skill in this project" or "the user-scope skill", use the scope heading to disambiguate. When multiple scopes define a skill with the same name, the more specific scope takes precedence: **Project overrides User overrides Extra overrides Built-in**.'; + +export function agentFilePromptVars( + context: SystemPromptContext, + options: { readonly skillActive: boolean }, +): Record { + const shellName = context.osEnv.shellName ?? ''; + const shellPath = context.osEnv.shellPath ?? ''; + const skills = options.skillActive + ? typeof context.skills === 'string' + ? context.skills + : (context.skills?.getModelSkillListing() ?? '') + : ''; + const additionalDirsInfo = context.additionalDirsInfo ?? ''; + const now = + context.now instanceof Date + ? context.now.toISOString() + : (context.now ?? new Date().toISOString()); + return { + role_additional: context.roleAdditional ?? '', + os: context.osEnv.osKind ?? '', + windows_notes: context.osEnv.osKind === 'Windows' ? `\n\n${WINDOWS_NOTES}\n\n` : '', + shell: shellName.length > 0 ? `${shellName} (\`${shellPath}\`)` : '', + now, + cwd: context.cwd, + cwd_listing: context.cwdListing ?? '', + agents_md: context.agentsMd ?? '', + additional_dirs_info: additionalDirsInfo, + additional_dirs_section: + additionalDirsInfo.length > 0 + ? `\n\n## Additional Directories\n\n${ADDITIONAL_DIRS_SECTION_PROSE}\n\n${additionalDirsInfo}\n\n` + : '', + skills, + skills_section: + skills.length > 0 ? `\n\n# Skills\n\n${SKILLS_SECTION_PROSE}\n\n${skills}\n\n` : '', + }; +} + +export function renderAgentFileTemplate( + template: string, + context: SystemPromptContext, + options: { readonly skillActive: boolean }, + basePrompt?: (context: SystemPromptContext) => string, +): string { + const vars = agentFilePromptVars(context, options); + if (basePrompt !== undefined && template.includes('${base_prompt}')) { + vars['base_prompt'] = basePrompt(context); + } + return renderTemplateVars(template, vars); +} + +export function skillActiveForAgentFile(definition: AgentFileDefinition): boolean { + return ( + (definition.tools === undefined || definition.tools.includes('Skill')) && + !(definition.disallowedTools ?? []).includes('Skill') + ); +} + +/** + * The effective tool allowlist for a file-defined profile: the file's own + * `tools`, or the default profile's set when unrestricted. The denylist is + * NOT folded in here — it rides the profile's `disallowedTools` so the tool + * manager can evaluate glob patterns against resolved MCP tool names. + */ +export function agentFileTools( + definition: AgentFileDefinition, + defaultTools: readonly string[], +): string[] { + return definition.tools === undefined ? [...defaultTools] : [...definition.tools]; +} + +export function agentProfileFromFile( + definition: AgentFileDefinition, + defaultTools: readonly string[], + basePrompt: (context: SystemPromptContext) => string, +): ResolvedAgentProfile { + const skillActive = skillActiveForAgentFile(definition); + return { + name: definition.name, + description: definition.description, + systemPrompt: (context) => + renderAgentFileTemplate(definition.prompt, context, { skillActive }, basePrompt), + tools: agentFileTools(definition, defaultTools), + disallowedTools: + definition.disallowedTools === undefined ? undefined : [...definition.disallowedTools], + whenToUse: definition.whenToUse, + modelPreference: definition.modelPreference, + }; +} diff --git a/packages/agent-core/src/profile/agentfile/index.ts b/packages/agent-core/src/profile/agentfile/index.ts new file mode 100644 index 0000000000..b1f12bd804 --- /dev/null +++ b/packages/agent-core/src/profile/agentfile/index.ts @@ -0,0 +1,9 @@ +export * from './types'; +export * from './parser'; +export * from './paths'; +export * from './roots'; +export * from './discovery'; +export * from './from-file'; +export * from './system-file'; +export * from './validate'; +export * from './catalog'; diff --git a/packages/agent-core/src/profile/agentfile/parser.ts b/packages/agent-core/src/profile/agentfile/parser.ts new file mode 100644 index 0000000000..64bc1c05d9 --- /dev/null +++ b/packages/agent-core/src/profile/agentfile/parser.ts @@ -0,0 +1,189 @@ +/** + * Agent-file parsing primitives. + * + * Parses a single agent Markdown file (frontmatter + body) into an + * `AgentFileDefinition`. Pure functions with no IO: callers read bytes however + * they like and pass the decoded text in, mirroring the skill parser. Unknown + * frontmatter fields are ignored so later format extensions stay + * forward-compatible. Compatibility conventions match other agent CLIs: a + * missing `name` falls back to the file name (OpenCode), a lone `*` in + * `tools` / `subagents` means unrestricted like an omitted field, and list + * fields accept either a bare comma-separated string or the YAML list form + * (Claude Code). + */ + +import { FrontmatterError, parseFrontmatter } from '../../skill/parser'; + +import type { AgentFileDefinition, AgentFileSource } from './types'; + +export class AgentFileParseError extends Error { + readonly reason?: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'AgentFileParseError'; + if (cause !== undefined) this.reason = cause; + } +} + +export interface ParseAgentFileOptions { + readonly path: string; + readonly source: AgentFileSource; + readonly text: string; +} + +const AGENT_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export function parseAgentFileText(options: ParseAgentFileOptions): AgentFileDefinition { + let parsed; + try { + parsed = parseFrontmatter(options.text); + } catch (error) { + if (error instanceof FrontmatterError) { + throw new AgentFileParseError( + `Invalid frontmatter in ${options.path}: ${error.message}`, + error, + ); + } + throw error; + } + + const frontmatter = parsed.data; + if (frontmatter === null) { + throw new AgentFileParseError(`Missing frontmatter in ${options.path}`); + } + if (!isRecord(frontmatter)) { + throw new AgentFileParseError( + `Frontmatter in ${options.path} must be a mapping at the top level`, + ); + } + + const nameField = frontmatter['name']; + if (nameField !== undefined && nameField !== null && typeof nameField !== 'string') { + throw new AgentFileParseError( + `Frontmatter field "name" in ${options.path} must be a non-empty string`, + ); + } + const name = nonEmptyString(nameField) ?? deriveNameFromPath(options.path); + if (name === undefined) { + throw new AgentFileParseError(`Missing required frontmatter field "name" in ${options.path}`); + } + if (!AGENT_NAME_PATTERN.test(name)) { + throw new AgentFileParseError( + `Invalid agent name "${name}" in ${options.path}: expected kebab-case (e.g. "code-reviewer")`, + ); + } + + const description = requiredNonEmptyString( + frontmatter['description'], + 'description', + options.path, + ); + + const override = parseBoolean(frontmatter['override'], 'override', options.path); + const rawTools = parseStringList(frontmatter['tools'], 'tools', options.path); + const tools = rawTools?.length === 1 && rawTools[0] === '*' ? undefined : rawTools; + const disallowedTools = parseStringList( + frontmatter['disallowedTools'], + 'disallowedTools', + options.path, + ); + 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) { + throw new AgentFileParseError(`Missing prompt body in ${options.path}`); + } + + return { + name, + description, + whenToUse: nonEmptyString(frontmatter['whenToUse']), + override, + 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; + throw new AgentFileParseError( + `Frontmatter field "${field}" in ${filePath} must be a boolean`, + ); +} + +function parseStringList( + value: unknown, + field: string, + filePath: string, +): readonly string[] | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value === 'string') { + return value + .split(',') + .map((item) => item.trim()) + .filter((item) => item !== ''); + } + if (!Array.isArray(value)) { + throw new AgentFileParseError( + `Frontmatter field "${field}" in ${filePath} must be a comma-separated string or a list of strings`, + ); + } + const out: string[] = []; + for (const item of value) { + if (typeof item !== 'string' || item.trim() === '') { + throw new AgentFileParseError( + `Frontmatter field "${field}" in ${filePath} must be a list of non-empty strings`, + ); + } + out.push(item.trim()); + } + return out; +} + +function requiredNonEmptyString(value: unknown, field: string, filePath: string): string { + if (value !== undefined && value !== null && typeof value !== 'string') { + throw new AgentFileParseError( + `Frontmatter field "${field}" in ${filePath} must be a non-empty string`, + ); + } + const parsed = nonEmptyString(value); + if (parsed === undefined) { + throw new AgentFileParseError(`Missing required frontmatter field "${field}" in ${filePath}`); + } + return parsed; +} + +function deriveNameFromPath(filePath: string): string | undefined { + const base = filePath.split(/[\\/]/).pop() ?? ''; + const name = base.replace(/\.[^.]*$/, ''); + return name !== '' ? name : undefined; +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/agent-core/src/profile/agentfile/paths.ts b/packages/agent-core/src/profile/agentfile/paths.ts new file mode 100644 index 0000000000..3e5f6c80f6 --- /dev/null +++ b/packages/agent-core/src/profile/agentfile/paths.ts @@ -0,0 +1,56 @@ +/** + * Shared path primitives for agent-file discovery: `~` expansion, + * base-relative resolution, and fs type probes used by the root resolvers, + * the directory walker, and the explicit-file source. Callers pick the + * resolution base: discovery roots resolve against the project root, + * explicit files against the session workDir. + */ + +import { promises as fs } from 'node:fs'; +import { isAbsolute, join, resolve } from 'pathe'; + +export function resolveAgentPath(path: string, baseDir: string, osHomeDir: string): string { + if (path === '~') return osHomeDir; + if (path.startsWith('~/')) return join(osHomeDir, path.slice(2)); + if (isAbsolute(path)) return path; + return resolve(baseDir, path); +} + +export async function isDirectoryPath(p: string): Promise { + try { + const resolved = await fs.realpath(p); + return (await fs.stat(resolved)).isDirectory(); + } catch (error) { + if (isMissingPathError(error)) return false; + throw error; + } +} + +export async function isFilePath(p: string): Promise { + try { + const resolved = await fs.realpath(p); + return (await fs.stat(resolved)).isFile(); + } catch (error) { + if (isMissingPathError(error)) return false; + throw error; + } +} + +export async function pathExists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch (error) { + if (isMissingPathError(error)) return false; + throw error; + } +} + +export function isMissingPathError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error.code === 'ENOENT' || error.code === 'ENOTDIR') + ); +} diff --git a/packages/agent-core/src/profile/agentfile/roots.ts b/packages/agent-core/src/profile/agentfile/roots.ts new file mode 100644 index 0000000000..e84dcf64de --- /dev/null +++ b/packages/agent-core/src/profile/agentfile/roots.ts @@ -0,0 +1,108 @@ +/** + * Agent-root resolution primitives: user, project, and configured discovery + * roots, mirroring the skill scanner's directory conventions + * (`skills` ↔ `agents`, `.kimi-code` ↔ `.agents`). + */ + +import { promises as fs } from 'node:fs'; +import { dirname, join, resolve } from 'pathe'; + +import { isDirectoryPath, pathExists, resolveAgentPath } from './paths'; +import type { AgentFileRoot, AgentFileSource } from './types'; + +export interface AgentRootWarn { + (message: string, error?: unknown): void; +} + +// Relative to brandHomeDir, which already IS the brand data dir (~/.kimi-code +// or $KIMI_CODE_HOME) — no '.kimi-code' segment here, or it would nest twice. +const USER_BRAND_DIRS = ['agents'] as const; +const USER_GENERIC_DIRS = ['.agents/agents'] as const; +const PROJECT_BRAND_DIRS = ['.kimi-code/agents'] as const; +const PROJECT_GENERIC_DIRS = ['.agents/agents'] as const; + +export async function userAgentRoots( + brandHomeDir: string, + osHomeDir: string, + warn?: AgentRootWarn, +): Promise { + const roots: AgentFileRoot[] = []; + await pushFirstExisting(roots, USER_BRAND_DIRS, brandHomeDir, 'user', warn); + await pushFirstExisting(roots, USER_GENERIC_DIRS, osHomeDir, 'user', warn); + return roots; +} + +export async function projectAgentRoots( + workDir: string, + warn?: AgentRootWarn, +): Promise { + const projectRoot = await findProjectRoot(workDir, warn); + const roots: AgentFileRoot[] = []; + await pushFirstExisting(roots, PROJECT_BRAND_DIRS, projectRoot, 'project', warn); + await pushFirstExisting(roots, PROJECT_GENERIC_DIRS, projectRoot, 'project', warn); + return roots; +} + +export async function configuredAgentRoots( + dirs: readonly string[], + workDir: string, + osHomeDir: string, + source: AgentFileSource, + warn?: AgentRootWarn, +): Promise { + const projectRoot = await findProjectRoot(workDir, warn); + const roots: AgentFileRoot[] = []; + for (const dir of dirs) { + await pushExistingRoot(roots, resolveAgentPath(dir, projectRoot, osHomeDir), source, warn); + } + return roots; +} + +async function findProjectRoot(workDir: string, warn?: AgentRootWarn): Promise { + const start = resolve(workDir); + let current = start; + while (true) { + const marker = join(current, '.git'); + try { + if (await pathExists(marker)) return current; + } catch (error) { + warn?.(`Skipping unreadable project marker ${marker}: ${errorMessage(error)}`, error); + } + const parent = dirname(current); + if (parent === current) return start; + current = parent; + } +} + +async function pushFirstExisting( + out: AgentFileRoot[], + dirs: readonly string[], + base: string, + source: AgentFileSource, + warn?: AgentRootWarn, +): Promise { + for (const dir of dirs) { + if (await pushExistingRoot(out, join(base, dir), source, warn)) return; + } +} + +async function pushExistingRoot( + out: AgentFileRoot[], + dir: string, + source: AgentFileSource, + warn?: AgentRootWarn, +): Promise { + try { + if (!(await isDirectoryPath(dir))) return false; + const resolved = (await fs.realpath(dir)).replaceAll('\\', '/'); + if (!out.some((root) => root.path === resolved)) out.push({ path: resolved, source }); + return true; + } catch (error) { + warn?.(`Skipping unreadable agent root ${dir}: ${errorMessage(error)}`, error); + return false; + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/agent-core/src/profile/agentfile/system-file.ts b/packages/agent-core/src/profile/agentfile/system-file.ts new file mode 100644 index 0000000000..1f0033d697 --- /dev/null +++ b/packages/agent-core/src/profile/agentfile/system-file.ts @@ -0,0 +1,82 @@ +/** + * `SYSTEM.md` global main-agent prompt override. + * + * `/SYSTEM.md` (default `~/.kimi-code/SYSTEM.md`, moves with + * `KIMI_CODE_HOME`) permanently replaces the builtin default profile's system + * prompt while the file exists and is non-empty. Only the prompt is replaced + * — tools and description are copied from the builtin default — and explicit + * intent still wins: higher-priority sources (project `agent.md`, + * `--agent-file`) override it, and binding a different profile ignores it. + * The body is a prompt template rendered against the agent-file variable + * table: `${var}` placeholders substitute live context, and `${base_prompt}` + * embeds the builtin default prompt. A missing or empty file yields no + * definition; a read failure degrades to `warn` instead of rejecting, + * matching the directory-source policy that a transient fs error must never + * poison a session. + */ + +import { promises as fs } from 'node:fs'; +import { join } from 'pathe'; + +import type { ResolvedAgentProfile } from '../types'; + +import { renderAgentFileTemplate } from './from-file'; +import { isFilePath } from './paths'; +import type { AgentFileDefinition } from './types'; + +export const SYSTEM_MD_FILENAME = 'SYSTEM.md'; + +/** + * Loads `/SYSTEM.md` as a synthetic agent-file definition for the + * default profile, or `undefined` when the file is absent or empty. + */ +export async function loadSystemMdDefinition( + brandHome: string, + warn: (message: string) => void, +): Promise { + const path = join(brandHome, SYSTEM_MD_FILENAME); + let text: string; + try { + if (!(await isFilePath(path))) return undefined; + text = await fs.readFile(path, 'utf-8'); + } catch (error) { + warn(`agent SYSTEM.md load failed: ${String(error)} [${path}]`); + return undefined; + } + if (text.trim().length === 0) return undefined; + return { + name: 'agent', + description: '', + override: true, + prompt: text.trim(), + path, + source: 'user', + }; +} + +/** + * Builds the SYSTEM.md profile variant: the builtin default with its system + * prompt replaced by the file body. Description and tools come from the + * builtin default. + */ +export function systemMdProfile( + definition: AgentFileDefinition, + builtinDefault: ResolvedAgentProfile, +): ResolvedAgentProfile { + const skillActive = builtinDefault.tools.includes('Skill'); + return { + name: builtinDefault.name, + description: builtinDefault.description, + systemPrompt: (context) => + renderAgentFileTemplate(definition.prompt, context, { skillActive }, (ctx) => + builtinDefault.systemPrompt(ctx), + ), + tools: [...builtinDefault.tools], + disallowedTools: + builtinDefault.disallowedTools === undefined + ? undefined + : [...builtinDefault.disallowedTools], + whenToUse: builtinDefault.whenToUse, + modelPreference: builtinDefault.modelPreference, + }; +} diff --git a/packages/agent-core/src/profile/agentfile/types.ts b/packages/agent-core/src/profile/agentfile/types.ts new file mode 100644 index 0000000000..6a3bc3cdda --- /dev/null +++ b/packages/agent-core/src/profile/agentfile/types.ts @@ -0,0 +1,40 @@ +/** + * Agent-file model types: the parsed single-file definition + * (`AgentFileDefinition`), scan roots (`AgentFileRoot`) tagged with their + * source, and the discovery result carrying per-file skip diagnostics. + * Pure data. + */ + +import type { AgentModelPreference } from '../types'; + +export type AgentFileSource = 'project' | 'user' | 'extra' | 'explicit'; + +export interface AgentFileRoot { + readonly path: string; + readonly source: AgentFileSource; +} + +export interface AgentFileDefinition { + readonly name: string; + readonly description: string; + readonly whenToUse?: string; + readonly override: boolean; + readonly tools?: readonly string[]; + readonly disallowedTools?: readonly string[]; + readonly subagents?: readonly string[]; + readonly modelPreference?: AgentModelPreference; + readonly prompt: string; + readonly path: string; + readonly source: AgentFileSource; +} + +export interface SkippedAgentFile { + readonly path: string; + readonly reason: string; +} + +export interface AgentFileDiscoveryResult { + readonly agents: readonly AgentFileDefinition[]; + readonly skipped: readonly SkippedAgentFile[]; + readonly scannedRoots: readonly string[]; +} diff --git a/packages/agent-core/src/profile/agentfile/validate.ts b/packages/agent-core/src/profile/agentfile/validate.ts new file mode 100644 index 0000000000..942873f3f6 --- /dev/null +++ b/packages/agent-core/src/profile/agentfile/validate.ts @@ -0,0 +1,59 @@ +/** + * Static inspection of agent-file tool patterns. + * + * Three entry shapes are dead on arrival under the tool manager's matching + * semantics, so they surface as warnings instead of silently shrinking the + * active tool set: `wildcard-not-mcp` (non-MCP entries match builtin/user + * tools by exact name only, so a wildcard outside an `mcp__…` pattern can + * never match — a bare `*` in a denylist is a no-op), `incomplete-mcp-name` + * (an `mcp__…` literal without glob magic must be a full + * `mcp____` name; `mcp__github__*` is the working form for a + * whole server), and `unknown-tool` (a literal naming no registered or + * built-in tool, almost always a typo such as `read` instead of `Read`). + * Mirrors the v2 engine's `findInactiveToolPatterns`. + */ + +import { isMcpToolName } from '../../mcp/tool-naming'; + +export type InactiveToolPatternKind = 'wildcard-not-mcp' | 'incomplete-mcp-name' | 'unknown-tool'; + +export interface InactiveToolPattern { + readonly pattern: string; + readonly kind: InactiveToolPatternKind; +} + +const GLOB_MAGIC = /[*?[\]{}]/; + +export function findInactiveToolPatterns( + patterns: readonly string[], + isKnownToolName?: (name: string) => boolean, +): InactiveToolPattern[] { + const issues: InactiveToolPattern[] = []; + for (const pattern of patterns) { + if (isMcpToolName(pattern)) { + if (!GLOB_MAGIC.test(pattern) && !pattern.slice('mcp__'.length).includes('__')) { + issues.push({ pattern, kind: 'incomplete-mcp-name' }); + } + continue; + } + if (GLOB_MAGIC.test(pattern)) { + issues.push({ pattern, kind: 'wildcard-not-mcp' }); + continue; + } + if (isKnownToolName !== undefined && !isKnownToolName(pattern)) { + issues.push({ pattern, kind: 'unknown-tool' }); + } + } + return issues; +} + +export function describeInactiveToolPattern(issue: InactiveToolPattern): string { + switch (issue.kind) { + case 'wildcard-not-mcp': + return `"${issue.pattern}" never matches: wildcards only work inside mcp__… patterns (a bare * disables nothing)`; + case 'incomplete-mcp-name': + return `"${issue.pattern}" never matches: an mcp__ literal must be a full mcp____ name, or use a glob like mcp__github__* for a whole server`; + case 'unknown-tool': + return `"${issue.pattern}" matches no registered or built-in tool (a typo?)`; + } +} diff --git a/packages/agent-core/src/profile/index.ts b/packages/agent-core/src/profile/index.ts index 80e303814b..6e490f779a 100644 --- a/packages/agent-core/src/profile/index.ts +++ b/packages/agent-core/src/profile/index.ts @@ -3,3 +3,4 @@ export * from './context'; export * from './load'; export * from './resolve'; export * from './default'; +export * from './agentfile/index'; diff --git a/packages/agent-core/src/profile/resolve.ts b/packages/agent-core/src/profile/resolve.ts index e73b7b4fcf..7615dd6ff4 100644 --- a/packages/agent-core/src/profile/resolve.ts +++ b/packages/agent-core/src/profile/resolve.ts @@ -1,5 +1,6 @@ import { renderPrompt } from '../utils/render-prompt'; import type { + AgentModelPreference, RawAgentProfile, RawSubagentProfile, ResolvedAgentProfile, @@ -15,6 +16,7 @@ interface MergedAgentProfile { readonly tools: string[]; readonly whenToUse?: string | undefined; readonly subagents?: Record | undefined; + readonly modelPreference?: AgentModelPreference | undefined; } /** @@ -100,6 +102,7 @@ function resolveMergedProfile( tools: profile.tools !== undefined ? [...profile.tools] : [...(parent?.tools ?? [])], whenToUse: profile.whenToUse ?? parent?.whenToUse, subagents: cloneSubagents(profile.subagents), + modelPreference: profile.modelPreference ?? parent?.modelPreference, }; cache.set(profile.name, merged); @@ -113,6 +116,7 @@ function toResolvedProfile(merged: MergedAgentProfile): ResolvedAgentProfile { systemPrompt: createSystemPromptRenderer(merged), tools: [...merged.tools], whenToUse: merged.whenToUse, + modelPreference: merged.modelPreference, }; } diff --git a/packages/agent-core/src/profile/types.ts b/packages/agent-core/src/profile/types.ts index 27d407c3b3..642bebac5b 100644 --- a/packages/agent-core/src/profile/types.ts +++ b/packages/agent-core/src/profile/types.ts @@ -9,6 +9,15 @@ export const RawSubagentProfileSchema = z.object({ export type RawSubagentProfile = z.infer; +/** + * Symbolic model preference a profile declares for subagent spawning: the + * `Agent` / `AgentSwarm` tools use it as the default for their `model` + * parameter when the call does not pass one explicitly. + */ +export const AgentModelPreferenceSchema = z.enum(['primary', 'secondary']); + +export type AgentModelPreference = z.infer; + export const RawAgentProfileSchema = z.object({ extends: z.string().optional(), name: z.string().min(1), @@ -21,6 +30,7 @@ export const RawAgentProfileSchema = z.object({ tools: z.array(z.string()).optional(), whenToUse: z.string().optional(), subagents: z.record(z.string(), RawSubagentProfileSchema).optional(), + modelPreference: AgentModelPreferenceSchema.optional(), }); export type RawAgentProfile = z.infer; @@ -51,6 +61,13 @@ export interface ResolvedAgentProfile { description?: string; systemPrompt: SystemPromptRenderer; tools: string[]; + /** + * Denylist with the same matching rules as `tools` (exact builtin/user + * names plus `mcp__…` glob patterns), applied on top of the `tools` + * allowlist when the profile takes effect. + */ + disallowedTools?: string[]; whenToUse?: string; subagents?: Record; + modelPreference?: AgentModelPreference; } diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 69849e1a9c..c8d9dbfcfb 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -63,6 +63,10 @@ export interface CreateSessionPayload { readonly additionalDirs?: readonly string[]; readonly client?: ClientTelemetryInfo | undefined; readonly drainAgentTasksOnStop?: boolean; + /** Main-agent profile name (`--agent`): a builtin or agentfile-defined profile. */ + readonly agentProfile?: string | undefined; + /** Explicit agentfiles (`--agent-file`); an invalid file fails session creation. */ + readonly agentFiles?: readonly string[] | undefined; } export interface CloseSessionPayload { diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 06e97a5e44..93a80cf26d 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -378,6 +378,12 @@ export class KimiCore implements PromisableMethods { hooks: [...(config.hooks ?? []), ...this.plugins.enabledHooks()], permissionRules: config.permission?.rules, skills: this.resolveSessionSkillConfig(config), + agents: { + userHomeDir: this.userHomeDir, + explicitFiles: options.agentFiles, + extraDirs: config.extraAgentDirs, + profileName: options.agentProfile, + }, mcpConfig, experimentalFlags: this.experimentalFlags, imageLimits: this.imageLimits, @@ -529,6 +535,10 @@ export class KimiCore implements PromisableMethods { hooks: [...(config.hooks ?? []), ...this.plugins.enabledHooks()], permissionRules: config.permission?.rules, skills: this.resolveSessionSkillConfig(config), + agents: { + userHomeDir: this.userHomeDir, + extraDirs: config.extraAgentDirs, + }, mcpConfig, experimentalFlags: this.experimentalFlags, imageLimits: this.imageLimits, diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 92f9cd291e..ba2d93d57a 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -36,13 +36,21 @@ import { } from '../mcp'; import type { EnabledPluginSessionStart, PluginCommandDef } from '../plugin'; import { - DEFAULT_AGENT_PROFILES, DEFAULT_INIT_PROMPT, + SessionAgentProfileCatalog, loadAgentsMd, prepareSystemPromptContext, type ResolvedAgentProfile, } from '../profile'; import type { ProviderManager } from './provider-manager'; +import { + resolveSecondaryModel, + wrapSubagentModelError, +} from './subagent-binding'; +import { + SECONDARY_DERIVED_MODEL_ALIAS, + secondaryModelPatch, +} from '../config/secondary-model'; import { registerBuiltinSkills, SessionSkillRegistry, @@ -74,6 +82,7 @@ export interface SessionOptions { readonly hooks?: readonly HookDef[]; readonly permissionRules?: readonly PermissionRule[]; readonly skills?: SessionSkillConfig; + readonly agents?: SessionAgentCatalogConfig; readonly mcpConfig?: SessionMcpConfig; readonly telemetry?: TelemetryClient | undefined; readonly pluginSessionStarts?: readonly EnabledPluginSessionStart[]; @@ -102,6 +111,20 @@ export interface SessionSkillConfig { readonly builtinDir?: string; } +/** + * File-defined agent (agentfile) discovery for a session. Mirrors the skill + * discovery layout: user brand dir `/agents` and + * `~/.agents/agents`, project `.kimi-code/agents` and `.agents/agents`, plus + * configured extra dirs and explicit single files (`--agent-file`, fatal + * when invalid). `profileName` selects the main agent's profile (`--agent`). + */ +export interface SessionAgentCatalogConfig { + readonly userHomeDir?: string; + readonly explicitFiles?: readonly string[]; + readonly extraDirs?: readonly string[]; + readonly profileName?: string; +} + export interface AgentMeta { readonly homedir?: string; readonly type: AgentType; @@ -181,6 +204,7 @@ export class Session { readonly hookEngine: HookEngine; readonly experimentalFlags: ExperimentalFlagResolver; readonly imageLimits: ImageLimits; + readonly agentCatalog: SessionAgentProfileCatalog; private toolKaos: Kaos; private persistenceKaos: Kaos; private additionalDirs: readonly string[]; @@ -240,10 +264,25 @@ export class Session { this.mcp.onStatusChange((entry) => { this.onMcpServerStatusChange(entry); }); + this.agentCatalog = new SessionAgentProfileCatalog({ + workDir: options.kaos.getcwd(), + brandHomeDir: options.kimiHomeDir ?? join(homedir(), '.kimi-code'), + osHomeDir: options.agents?.userHomeDir ?? homedir(), + extraDirs: options.agents?.extraDirs ?? options.config?.extraAgentDirs, + explicitFiles: options.agents?.explicitFiles, + warn: (message, error) => { + this.log.warn(message, error === undefined ? undefined : { error }); + }, + }); this.skillsReady = this.loadSkills() .catch((error: unknown) => { this.log.error('skills load failed', error); }) + // Agentfile discovery rides the same readiness gate: every createAgent + // caller already awaits it, so profile binding and the Agent tool's + // subagent list always see the fully merged catalog. A fatal source + // (an invalid --agent-file) rejects here and fails session creation. + .then(() => this.agentCatalog.ready) .then(() => { this.refreshAgentBuiltinTools(); }); @@ -338,12 +377,38 @@ export class Session { } async createMain() { + // Await the catalog (chained into skillsReady) before resolving the + // profile so a fatal agentfile source surfaces here, and so `--agent` + // sees file-defined profiles. + await this.skillsReady; + const profileName = this.options.agents?.profileName; + const profile = + profileName === undefined + ? this.agentCatalog.getDefault() + : this.agentCatalog.get(profileName); + if (profile === undefined) { + const available = this.agentCatalog + .list() + .map((candidate) => candidate.name) + .join(', '); + throw new KimiError( + ErrorCodes.AGENT_NOT_FOUND, + `Agent profile "${profileName}" was not found. Available profiles: ${available}`, + ); + } const { agent } = await this.createAgent({ type: 'main' }, { - profile: DEFAULT_AGENT_PROFILES['agent'], + profile, }); if (this.options.drainAgentTasksOnStop) { agent.printDrainAgentTasksOnStop = true; } + for (const warning of this.computeSecondaryModelWarnings()) { + agent.emitEvent({ + type: 'warning', + message: warning.message, + code: warning.code, + }); + } await this.triggerSessionStart('startup'); return agent; } @@ -370,8 +435,8 @@ export class Session { // default profile so the resumed session is usable. Native sessions always // replay a non-empty system prompt and never enter this branch. const main = this.getReadyAgent('main'); - const profile = DEFAULT_AGENT_PROFILES['agent']; - if (main !== undefined && profile !== undefined && main.config.systemPrompt === '') { + const profile = this.agentCatalog.getDefault(); + if (main !== undefined && main.config.systemPrompt === '') { await this.bootstrapAgentProfile(main, profile); } await this.triggerSessionStart('resume'); @@ -698,9 +763,57 @@ export class Session { severity: 'warning', }); } + warnings.push(...this.computeSecondaryModelWarnings()); return warnings; } + private secondaryModelWarnings: SessionWarning[] | undefined; + + /** + * Upfront validation of the `[secondary_model]` recipe, mirroring the v2 + * warning service: the pointer is otherwise only validated lazily at spawn + * time, where a typo becomes a mid-conversation tool failure dumped on the + * parent model. Advisory only — spawn-time resolution (with the wrapped + * error) remains the backstop. Computed once per session. + */ + private computeSecondaryModelWarnings(): SessionWarning[] { + if (this.secondaryModelWarnings !== undefined) return [...this.secondaryModelWarnings]; + const warnings: SessionWarning[] = []; + const secondary = resolveSecondaryModel(this.options.config, this.experimentalFlags); + if (secondary?.model !== undefined) { + const boundAlias = + secondaryModelPatch(secondary) === undefined + ? secondary.model + : SECONDARY_DERIVED_MODEL_ALIAS; + try { + const resolved = this.options.providerManager?.resolveProviderConfig(boundAlias); + const supported = resolved?.supportEfforts ?? []; + if ( + secondary.defaultEffort !== undefined && + supported.length > 0 && + !supported.includes(secondary.defaultEffort) + ) { + warnings.push({ + code: 'secondary-model-effort-not-listed', + message: + `Secondary model default_effort "${secondary.defaultEffort}" is not in the resolved model's ` + + `support_efforts (${supported.join(', ')}). Subagents will resolve thinking without it.`, + severity: 'warning', + }); + } + } catch (error) { + const wrapped = wrapSubagentModelError(error, boundAlias, undefined); + warnings.push({ + code: 'secondary-model-invalid', + message: `${wrapped instanceof Error ? wrapped.message : String(wrapped)} Subagent spawns will fail until this is fixed.`, + severity: 'warning', + }); + } + } + this.secondaryModelWarnings = warnings; + return [...warnings]; + } + private async computeAgentsMdWarning(): Promise { if (this.agentsMdWarning !== undefined) { return this.agentsMdWarning; @@ -1069,13 +1182,11 @@ export class Session { const profileName = agent.config.profileName; if (profileName === undefined) return undefined; if (meta.type === 'sub') { - const parentProfileName = parentAgent?.config.profileName; - return ( - DEFAULT_AGENT_PROFILES[parentProfileName ?? 'agent']?.subagents?.[profileName] ?? - DEFAULT_AGENT_PROFILES['agent']?.subagents?.[profileName] - ); + return this.agentCatalog.delegatableSubagents(parentAgent?.config.profileName ?? 'agent')[ + profileName + ]; } - return DEFAULT_AGENT_PROFILES[profileName]; + return this.agentCatalog.get(profileName); } private nextGeneratedAgentId(): string { @@ -1111,6 +1222,7 @@ export class Session { } export * from './subagent-host'; +export * from './subagent-binding'; export * from './store'; function initCompletionReminder(agentsMd: string): string { diff --git a/packages/agent-core/src/session/subagent-batch.ts b/packages/agent-core/src/session/subagent-batch.ts index 7646de3874..492ae8f665 100644 --- a/packages/agent-core/src/session/subagent-batch.ts +++ b/packages/agent-core/src/session/subagent-batch.ts @@ -6,6 +6,7 @@ import type { SpawnSubagentOptions, SubagentHandle, } from './subagent-host'; +import type { SubagentModelChoice } from './subagent-binding'; import { isUserCancellation } from '../utils/abort'; /* @@ -51,6 +52,7 @@ type BaseQueuedSubagentTask = { readonly runInBackground: boolean; readonly timeout?: number; readonly signal?: AbortSignal; + readonly modelChoice?: SubagentModelChoice; }; export type SpawnQueuedSubagentTask = BaseQueuedSubagentTask & { @@ -324,6 +326,7 @@ export class SubagentBatch { const spawnOptions: SpawnSubagentOptions = { profileName: task.profileName, swarmItem: task.swarmItem, + modelChoice: task.modelChoice, ...runOptions, }; handle = await this.launcher.spawn(spawnOptions); diff --git a/packages/agent-core/src/session/subagent-binding.ts b/packages/agent-core/src/session/subagent-binding.ts new file mode 100644 index 0000000000..78e3d998b6 --- /dev/null +++ b/packages/agent-core/src/session/subagent-binding.ts @@ -0,0 +1,115 @@ +import { + SECONDARY_DERIVED_MODEL_ALIAS, + SECONDARY_MODEL_ENV, + secondaryModelPatch, + type KimiConfig, + type SecondaryModelConfig, +} from '../config'; +import { ErrorCodes, KimiError } from '../errors'; +import type { ExperimentalFlagResolver } from '../flags'; +import type { AgentModelPreference } from '../profile'; + +/** + * Subagent model binding — the secondary-model half of the spawn decision. + * + * When the `secondary-model` experiment is enabled and `[secondary_model]` is + * configured, newly spawned subagents bind to it by default instead of + * inheriting the caller's model. The caller (the parent model, through the + * `Agent` / `AgentSwarm` tool `model` parameter) or the spawned profile (via + * `model_preference`) can force `primary`. A recipe with patch fields binds + * the synthesized derived entry ({@link SECONDARY_DERIVED_MODEL_ALIAS}, + * materialized by `applySecondaryModelConfig`); a pointer-only recipe binds + * the pointed entry directly. `default_effort` is passed as the explicit + * subagent thinking effort; without it the child resolves thinking naturally + * (global thinking config → the bound model's default effort) rather than + * inheriting the caller's level. When unset, spawning behavior is unchanged: + * subagents inherit the caller's model and effort. + */ + +export type SubagentModelChoice = AgentModelPreference; + +export interface SubagentModelBinding { + readonly modelAlias: string | undefined; + readonly thinkingEffort?: string; +} + +export function resolveSecondaryModel( + config: KimiConfig | undefined, + flags: ExperimentalFlagResolver, +): SecondaryModelConfig | undefined { + if (!flags.enabled('secondary-model')) return undefined; + return config?.secondaryModel; +} + +/** + * Resolve which model a newly spawned subagent binds to. `requested` is the + * explicit per-spawn choice (tool argument or profile preference); `own` is + * the caller's current model state, used when inheriting. + */ +export function resolveSubagentBinding( + config: KimiConfig | undefined, + flags: ExperimentalFlagResolver, + own: { readonly modelAlias: string | undefined; readonly thinkingEffort: string }, + requested?: SubagentModelChoice, +): SubagentModelBinding { + const secondary = resolveSecondaryModel(config, flags); + if (requested !== 'primary' && secondary?.model !== undefined) { + return { + modelAlias: + secondaryModelPatch(secondary) === undefined + ? secondary.model + : SECONDARY_DERIVED_MODEL_ALIAS, + thinkingEffort: secondary.defaultEffort, + }; + } + return { modelAlias: own.modelAlias, thinkingEffort: own.thinkingEffort }; +} + +/** + * The "Available models" block appended to the `Agent` / `AgentSwarm` tool + * descriptions so the parent model knows it can pick. `undefined` when the + * secondary model is not configured or the caller's model is not bound yet. + */ +export function buildSubagentModelDescriptions( + config: KimiConfig | undefined, + flags: ExperimentalFlagResolver, + 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'); +} + +/** + * Point a spawn-time model resolution failure at the secondary-model + * configuration when the bound model is not the caller's own — otherwise the + * parent model sees a bare "model not configured" error with no hint that it + * comes from `[secondary_model]`. + */ +export function wrapSubagentModelError( + error: unknown, + boundModel: string, + callerModelAlias: string | undefined, +): unknown { + if (boundModel === callerModelAlias) return error; + if (!(error instanceof KimiError) || error.code !== ErrorCodes.CONFIG_INVALID) return error; + const displayModel = + boundModel === SECONDARY_DERIVED_MODEL_ALIAS + ? `the derived entry "${SECONDARY_DERIVED_MODEL_ALIAS}"` + : `"${boundModel}"`; + return new KimiError( + 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, + details: { + ...error.details, + secondaryModel: boundModel, + }, + }, + ); +} diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index bb80a18209..5c2cb0b4f7 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -11,7 +11,6 @@ import { DenyAllPermissionPolicy } from '../agent/permission/policies/deny-all'; import { InMemoryAgentRecordPersistence } from '../agent/records'; import { isAbortError } from '../loop/errors'; import { - DEFAULT_AGENT_PROFILES, prepareSystemPromptContext, type ResolvedAgentProfile, } from '../profile'; @@ -21,6 +20,12 @@ import { } from '../utils/abort'; import { collectGitContext } from './git-context'; import type { Session } from './index'; +import { + resolveSubagentBinding, + wrapSubagentModelError, + type SubagentModelBinding, + type SubagentModelChoice, +} from './subagent-binding'; import { SubagentBatch, resolveSwarmMaxConcurrency, @@ -121,6 +126,12 @@ export interface RunSubagentOptions { export interface SpawnSubagentOptions extends RunSubagentOptions { readonly profileName: string; readonly swarmItem?: string; + /** + * Explicit per-spawn model choice from the tool call. The profile's own + * `modelPreference` applies when this is omitted; both only take effect + * with the `secondary-model` experiment enabled. + */ + readonly modelChoice?: SubagentModelChoice; } type SubagentCompletion = { @@ -161,7 +172,7 @@ export class SessionSubagentHost { const completion = this.runWithActiveChild(id, options, async (runOptions) => { this.emitSubagentSpawned(parent, id, profile.name, runOptions); try { - await this.configureChild(parent, agent, profile); + await this.configureChild(parent, agent, profile, options.modelChoice); return await this.runPromptTurn(parent, id, agent, profile.name, runOptions); } catch (error) { this.emitSubagentFailed(parent, id, runOptions, error); @@ -182,7 +193,7 @@ export class SessionSubagentHost { const completion = this.runWithActiveChild(agentId, options, async (runOptions) => { this.emitSubagentSpawned(parent, agentId, profileName, runOptions); try { - child.config.update({ modelAlias: parent.config.modelAlias }); + this.reInheritParentModel(parent, child); return await this.runPromptTurn(parent, agentId, child, profileName, runOptions); } catch (error) { this.emitSubagentFailed(parent, agentId, runOptions, error); @@ -198,7 +209,7 @@ export class SessionSubagentHost { const completion = this.runWithActiveChild(agentId, options, async (runOptions) => { try { runOptions.signal.throwIfAborted(); - child.config.update({ modelAlias: parent.config.modelAlias }); + this.reInheritParentModel(parent, child); this.emitSubagentStarted(parent, agentId); const turnId = child.turn.retry('agent-host'); if (turnId === null) { @@ -308,15 +319,24 @@ export class SessionSubagentHost { } private resolveProfile(parent: Agent, profileName: string): ResolvedAgentProfile { - const profile = - DEFAULT_AGENT_PROFILES[parent.config.profileName ?? 'agent']?.subagents?.[profileName] ?? - DEFAULT_AGENT_PROFILES['agent']?.subagents?.[profileName]; + const profile = this.session.agentCatalog.delegatableSubagents(parent.config.profileName)[ + profileName + ]; if (profile === undefined) { throw new Error(`Subagent profile "${profileName}" was not found`); } return profile; } + /** + * The subagent types the given profile may delegate to (its own linked set, + * or the default profile's when it declares none). Backs the `Agent` tool's + * "Available agent types" description. + */ + delegatableSubagents(callerProfileName?: string): Record { + return this.session.agentCatalog.delegatableSubagents(callerProfileName); + } + private runWithActiveChild( childId: string, options: RunSubagentOptions, @@ -400,12 +420,15 @@ export class SessionSubagentHost { parent: Agent, child: Agent, profile: ResolvedAgentProfile, + modelChoice?: SubagentModelChoice, ): Promise { - // A subagent always inherits the parent agent's model. + const binding = this.resolveSpawnBinding(parent, profile, modelChoice); child.config.update({ cwd: parent.config.cwd, - modelAlias: parent.config.modelAlias, - thinkingEffort: parent.config.thinkingEffort, + modelAlias: binding.modelAlias, + ...(binding.thinkingEffort !== undefined + ? { thinkingEffort: binding.thinkingEffort } + : {}), }); const context = await prepareSystemPromptContext( @@ -417,6 +440,47 @@ export class SessionSubagentHost { child.tools.inheritUserTools(parent.tools); } + /** + * The model a newly spawned subagent binds to: the configured secondary + * model by default (when the experiment is on), otherwise the parent's + * model and effort, inherited as before. The bound alias is validated up + * front so a dangling `[secondary_model]` pointer fails the spawn with a + * wrapped, actionable error instead of a mid-turn provider failure. + */ + private resolveSpawnBinding( + parent: Agent, + profile: ResolvedAgentProfile, + modelChoice?: SubagentModelChoice, + ): SubagentModelBinding { + const binding = resolveSubagentBinding( + this.session.options.config, + this.session.experimentalFlags, + { modelAlias: parent.config.modelAlias, thinkingEffort: parent.config.thinkingEffort }, + modelChoice ?? profile.modelPreference, + ); + if (binding.modelAlias !== undefined) { + const providerManager = this.session.options.providerManager; + try { + providerManager?.resolveProviderConfig(binding.modelAlias); + } catch (error) { + throw wrapSubagentModelError(error, binding.modelAlias, parent.config.modelAlias); + } + } + return binding; + } + + /** + * Resume/retry historically re-synced the child to the parent's current + * model so subagents follow mid-session `/model` switches. With the + * `secondary-model` experiment on, a resumed subagent instead keeps the + * model it was bound to at spawn (v2 semantics: no child-follows-parent + * invariant). + */ + private reInheritParentModel(parent: Agent, child: Agent): void { + if (this.session.experimentalFlags.enabled('secondary-model')) return; + child.config.update({ modelAlias: parent.config.modelAlias }); + } + /** * Hold the run open until the child agent's background tasks (background * Bash, nested background agents) settle — the print-mode (`kimi -p`) diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts index 7bcaa599a6..aac81bc6ed 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts @@ -31,6 +31,12 @@ export const AgentSwarmToolInputSchema = z .describe( 'Subagent type used for every new subagent spawned from items; defaults to coder when omitted. Resumed subagents always keep their original type, so passing subagent_type together with resume_agent_ids is allowed — it only affects the item-based spawns.', ), + model: z + .enum(['primary', 'secondary']) + .optional() + .describe( + 'Model for every new subagent spawned from items: "secondary" uses the configured secondary model (the default when one is set), "primary" uses the model you are running on. Resumed subagents keep their bound model.', + ), prompt_template: z .string() .trim() @@ -85,7 +91,7 @@ interface SwarmRunResult { export class AgentSwarmTool implements BuiltinTool { readonly name = 'AgentSwarm' as const; - readonly description = AGENT_SWARM_DESCRIPTION; + readonly description: string; readonly parameters: Record = toInputJsonSchema(AgentSwarmToolInputSchema); constructor( @@ -94,7 +100,13 @@ export class AgentSwarmTool implements BuiltinTool { // `0` = no timeout, preserved on purpose (`0 ?? DEFAULT` stays `0`); // SubagentBatch arms no timer for non-positive timeouts. private readonly subagentTimeoutMs?: number, - ) {} + subagentModelDescription?: string, + ) { + this.description = + subagentModelDescription === undefined + ? AGENT_SWARM_DESCRIPTION + : `${AGENT_SWARM_DESCRIPTION}\n\n${subagentModelDescription}`; + } resolveExecution(args: AgentSwarmToolInput): ToolExecution { const agentCount = (args.items?.length ?? 0) + Object.keys(args.resume_agent_ids ?? {}).length; @@ -149,6 +161,7 @@ export class AgentSwarmTool implements BuiltinTool { swarmItem: spec.item, signal, timeout: this.subagentTimeoutMs ?? DEFAULT_SUBAGENT_TIMEOUT_MS, + modelChoice: args.model, }; if (spec.kind === 'resume') { return { diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.ts b/packages/agent-core/src/tools/builtin/collaboration/agent.ts index e12a8c16df..5ce2309f29 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.ts @@ -66,6 +66,12 @@ export const AgentToolInputSchema = z.preprocess( .describe( 'One of the available agent types (see "Available agent types" in this tool description). Defaults to "coder" when omitted.', ), + model: z + .enum(['primary', 'secondary']) + .optional() + .describe( + 'Model for the new subagent: "secondary" uses the configured secondary model (the default when one is set), "primary" uses the model you are running on. Only applies when spawning a new agent — a resumed agent keeps its bound model.', + ), resume: z .string() .optional() @@ -116,6 +122,7 @@ export class AgentTool implements BuiltinTool { log?: Logger; allowBackground?: boolean | undefined; subagentTimeoutMs?: number | undefined; + subagentModelDescription?: string | undefined; }, ) { const log = options?.log; @@ -127,9 +134,14 @@ export class AgentTool implements BuiltinTool { const baseDescription = `${AGENT_DESCRIPTION_BASE}\n\n${ this.allowBackground ? AGENT_BACKGROUND_DESCRIPTION : AGENT_BACKGROUND_DISABLED_DESCRIPTION }`; - this.description = typeLines - ? `${baseDescription}\n\nAvailable agent types (pass via subagent_type):\n${typeLines}` - : baseDescription; + const sections = [baseDescription]; + if (typeLines) { + sections.push(`Available agent types (pass via subagent_type):\n${typeLines}`); + } + if (options?.subagentModelDescription !== undefined) { + sections.push(options.subagentModelDescription); + } + this.description = sections.join('\n\n'); this.log = log; } @@ -212,6 +224,7 @@ export class AgentTool implements BuiltinTool { ? await this.subagentHost.resume(resumeAgentId!, runOptions) : await this.subagentHost.spawn({ profileName: requestedProfileName ?? 'coder', + modelChoice: args.model, ...runOptions, }); } catch (error) { @@ -381,8 +394,16 @@ function buildSubagentDescriptions(subagents: ResolvedAgentProfile['subagents']) (part): part is string => part !== undefined && part.length > 0, ); const header = details.length === 0 ? `- ${name}` : `- ${name}: ${details.join(' ')}`; - if (subagent.tools.length === 0) return header; - return `${header}\n Tools: ${subagent.tools.join(', ')}`; + const deniedExact = new Set( + (subagent.disallowedTools ?? []).filter((tool) => !tool.startsWith('mcp__')), + ); + const shownTools = subagent.tools.filter((tool) => !deniedExact.has(tool)); + const lines = [header]; + if (shownTools.length > 0) lines.push(` Tools: ${shownTools.join(', ')}`); + if (subagent.disallowedTools !== undefined && subagent.disallowedTools.length > 0) { + lines.push(` Disabled: ${subagent.disallowedTools.join(', ')}`); + } + return lines.join('\n'); }) .join('\n'); } diff --git a/packages/agent-core/test/agent/records/index.test.ts b/packages/agent-core/test/agent/records/index.test.ts index 91b4aed47a..6bfc1a7713 100644 --- a/packages/agent-core/test/agent/records/index.test.ts +++ b/packages/agent-core/test/agent/records/index.test.ts @@ -265,6 +265,26 @@ describe('AgentRecords persistence metadata', () => { expect(agent.goal.getGoal().goal?.goalId).toBe('g1'); }); + it('replays the deny list of a tools.set_active_tools record', async () => { + const persistence = new InMemoryAgentRecordPersistence([ + { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 }, + { + type: 'tools.set_active_tools', + names: ['Read', 'Write', 'Bash'], + disallowedNames: ['Write'], + } as AgentRecord, + ]); + const { agent } = testAgent({ persistence }); + agent.config.update({ modelAlias: 'mock-model' }); + + await agent.records.replay(); + + const names = agent.tools.loopTools.map((tool) => tool.name); + expect(names).toContain('Read'); + expect(names).toContain('Bash'); + expect(names).not.toContain('Write'); + }); + it('restores goal.* records during replay', async () => { const persistence = new InMemoryAgentRecordPersistence([ { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 }, diff --git a/packages/agent-core/test/agent/tool.test.ts b/packages/agent-core/test/agent/tool.test.ts index 13ad93cb7d..fc9791bb9d 100644 --- a/packages/agent-core/test/agent/tool.test.ts +++ b/packages/agent-core/test/agent/tool.test.ts @@ -125,6 +125,7 @@ describe('Agent tools', () => { completion, }), resume: vi.fn(), + delegatableSubagents: vi.fn(() => ({})), } as unknown as SessionSubagentHost; const ctx = testAgent({ subagentHost }); ctx.configure({ tools: ['Agent'] }); @@ -259,8 +260,39 @@ describe('Agent tools', () => { expect(managedBash!.description).toContain('run_in_background=true'); }); + it('removes denied exact tool names from the active set', () => { + const ctx = testAgent(); + ctx.configure(); + ctx.agent.tools.setActiveTools(['Read', 'Bash', 'Grep'], ['Bash']); + + const names = ctx.agent.tools.loopTools.map((tool) => tool.name); + expect(names).not.toContain('Bash'); + expect(names).toContain('Read'); + expect(names).toContain('Grep'); + expect(ctx.agent.tools.data().find((info) => info.name === 'Bash')?.active).toBe(false); + }); + + it('applies a profile disallowedTools denylist through useProfile', () => { + const ctx = testAgent(); + ctx.configure(); + ctx.agent.useProfile({ + name: 'restricted', + systemPrompt: () => 'sys', + tools: ['Read', 'Write', 'Edit', 'Bash', 'Grep'], + disallowedTools: ['Write', 'Edit'], + }); + + const names = ctx.agent.tools.loopTools.map((tool) => tool.name); + expect(names).toContain('Read'); + expect(names).toContain('Bash'); + expect(names).not.toContain('Write'); + expect(names).not.toContain('Edit'); + }); + it('exposes AgentSwarm when a subagent host is available', () => { - const subagentHost = {} as unknown as SessionSubagentHost; + const subagentHost = { + delegatableSubagents: vi.fn(() => ({})), + } as unknown as SessionSubagentHost; const ctx = testAgent({ subagentHost, diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index 6e4c46de0a..71ea6f5f4d 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -2815,8 +2815,13 @@ function agentSwarmCall(): ToolCall { function mockSubagentHost>( host: T, ): T & SessionSubagentHost { - return { spawn: vi.fn(), resume: vi.fn(), runQueued: vi.fn(), ...host } as unknown as T & - SessionSubagentHost; + return { + spawn: vi.fn(), + resume: vi.fn(), + runQueued: vi.fn(), + delegatableSubagents: vi.fn(() => ({})), + ...host, + } as unknown as T & SessionSubagentHost; } interface ApiErrorTelemetryCase { diff --git a/packages/agent-core/test/config/secondary-model.test.ts b/packages/agent-core/test/config/secondary-model.test.ts new file mode 100644 index 0000000000..ce291e888b --- /dev/null +++ b/packages/agent-core/test/config/secondary-model.test.ts @@ -0,0 +1,212 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { afterEach, describe, expect, it } from 'vitest'; +import { join } from 'pathe'; + +import { getDefaultConfig, loadRuntimeConfig, writeConfigFile } from '../../src/config'; +import { + applySecondaryModelConfig, + SECONDARY_DERIVED_MODEL_ALIAS, + secondaryModelPatch, + stripSecondaryModelConfig, +} from '../../src/config/secondary-model'; +import { parseConfigString } from '../../src/config/toml'; +import type { KimiConfig, ModelAlias } from '../../src/config/schema'; + +const baseAlias: ModelAlias = { + provider: 'p1', + model: 'cheap-chat', + maxContextSize: 131072, + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + defaultEffort: 'low', +}; + +function configWithSecondary(): KimiConfig { + return { + providers: { + p1: { type: 'kimi', apiKey: 'sk-test' }, + }, + defaultModel: 'main', + models: { + main: { provider: 'p1', model: 'flagship', maxContextSize: 262144 }, + cheap: baseAlias, + }, + secondaryModel: { + model: 'cheap', + maxContextSize: 65536, + defaultEffort: 'high', + }, + }; +} + +describe('secondaryModelPatch', () => { + it('returns undefined when no patch field is set', () => { + expect(secondaryModelPatch(undefined)).toBeUndefined(); + expect(secondaryModelPatch({})).toBeUndefined(); + expect(secondaryModelPatch({ model: 'cheap' })).toBeUndefined(); + }); + + it('returns every field except model as the patch', () => { + expect( + secondaryModelPatch({ model: 'cheap', maxContextSize: 1024, defaultEffort: 'low' }), + ).toEqual({ maxContextSize: 1024, defaultEffort: 'low' }); + }); +}); + +describe('applySecondaryModelConfig', () => { + it('returns the config unchanged when nothing is configured', () => { + const base = getDefaultConfig(); + expect(applySecondaryModelConfig(base, {})).toBe(base); + }); + + it('synthesizes the derived entry from the pointed model and the patch', () => { + const config = applySecondaryModelConfig(configWithSecondary(), {}); + const derived = config.models?.[SECONDARY_DERIVED_MODEL_ALIAS]; + expect(derived).toEqual({ + ...baseAlias, + overrides: { + maxContextSize: 65536, + defaultEffort: 'high', + }, + }); + // The pointed entry and the rest of the registry stay untouched. + expect(config.models?.['cheap']).toEqual(baseAlias); + expect(config.models?.['main']).toEqual({ + provider: 'p1', + model: 'flagship', + maxContextSize: 262144, + }); + }); + + it('merges the patch over the base entry overrides', () => { + const base = configWithSecondary(); + base.models = { + cheap: { ...baseAlias, overrides: { maxContextSize: 32768, displayName: 'base-name' } }, + }; + const config = applySecondaryModelConfig(base, {}); + expect(config.models?.[SECONDARY_DERIVED_MODEL_ALIAS]?.overrides).toEqual({ + maxContextSize: 65536, + displayName: 'base-name', + defaultEffort: 'high', + }); + }); + + it('synthesizes nothing for a pointer-only recipe', () => { + const base = configWithSecondary(); + base.secondaryModel = { model: 'cheap' }; + const config = applySecondaryModelConfig(base, {}); + expect(config.models?.[SECONDARY_DERIVED_MODEL_ALIAS]).toBeUndefined(); + }); + + it('synthesizes nothing when the pointed entry does not exist', () => { + const base = configWithSecondary(); + base.secondaryModel = { model: 'missing', maxContextSize: 1024 }; + const config = applySecondaryModelConfig(base, {}); + expect(config.models?.[SECONDARY_DERIVED_MODEL_ALIAS]).toBeUndefined(); + }); + + it('applies the env overrides without touching the on-disk section shape', () => { + const config = applySecondaryModelConfig(getDefaultConfig(), { + KIMI_SECONDARY_MODEL: 'cheap', + KIMI_SECONDARY_EFFORT: 'low', + }); + expect(config.secondaryModel).toEqual({ model: 'cheap', defaultEffort: 'low' }); + }); + + it('lets the env override the configured recipe fields', () => { + const config = applySecondaryModelConfig(configWithSecondary(), { + KIMI_SECONDARY_MODEL: 'main', + }); + expect(config.secondaryModel?.model).toBe('main'); + // Untouched recipe fields survive the env overlay. + expect(config.secondaryModel?.defaultEffort).toBe('high'); + }); +}); + +describe('stripSecondaryModelConfig', () => { + it('removes the derived entry and rolls back a default_model pointer at it', () => { + const config = applySecondaryModelConfig(configWithSecondary(), {}); + config.defaultModel = SECONDARY_DERIVED_MODEL_ALIAS; + const stripped = stripSecondaryModelConfig(config, {}); + expect(stripped.models?.[SECONDARY_DERIVED_MODEL_ALIAS]).toBeUndefined(); + expect(stripped.defaultModel).toBeUndefined(); + }); + + it('restores env-injected recipe fields from raw on write', () => { + const onDisk = parseConfigString( + [ + '[secondary_model]', + 'model = "cheap"', + 'default_effort = "low"', + ].join('\n'), + ); + const runtime = applySecondaryModelConfig(onDisk, { + KIMI_SECONDARY_MODEL: 'main', + KIMI_SECONDARY_EFFORT: 'high', + }); + expect(runtime.secondaryModel).toEqual({ model: 'main', defaultEffort: 'high' }); + const stripped = stripSecondaryModelConfig(runtime, { + KIMI_SECONDARY_MODEL: 'main', + KIMI_SECONDARY_EFFORT: 'high', + }); + expect(stripped.secondaryModel).toEqual({ model: 'cheap', defaultEffort: 'low' }); + }); + + it('keeps file-sourced recipe fields when no env override is active', () => { + const runtime = configWithSecondary(); + const stripped = stripSecondaryModelConfig(runtime, {}); + expect(stripped.secondaryModel).toEqual(runtime.secondaryModel); + }); +}); + +describe('[secondary_model] TOML wiring', () => { + it('parses the snake_case section into camelCase config', () => { + const config = parseConfigString( + [ + '[secondary_model]', + 'model = "cheap"', + 'max_context_size = 65536', + 'default_effort = "high"', + ].join('\n'), + ); + expect(config.secondaryModel).toEqual({ + model: 'cheap', + maxContextSize: 65536, + defaultEffort: 'high', + }); + }); + + it('round-trips the section through writeConfigFile without the derived entry', async () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-secondary-model-')); + try { + const filePath = join(dir, 'config.toml'); + writeFileSync( + filePath, + [ + '[providers.p1]', + 'type = "kimi"', + 'api_key = "sk-test"', + '', + '[models.cheap]', + 'provider = "p1"', + 'model = "cheap-chat"', + 'max_context_size = 131072', + '', + '[secondary_model]', + 'model = "cheap"', + 'max_context_size = 65536', + ].join('\n'), + ); + const runtime = loadRuntimeConfig(filePath, {}); + expect(runtime.models?.[SECONDARY_DERIVED_MODEL_ALIAS]).toBeDefined(); + await writeConfigFile(filePath, runtime); + const persisted = readFileSync(filePath, 'utf-8'); + expect(persisted).toContain('[secondary_model]'); + expect(persisted).toContain('max_context_size = 65536'); + expect(persisted).not.toContain(SECONDARY_DERIVED_MODEL_ALIAS); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/agent-core/test/mcp/tool-manager-mcp.test.ts b/packages/agent-core/test/mcp/tool-manager-mcp.test.ts index cdd3bde505..f7f14bbe3f 100644 --- a/packages/agent-core/test/mcp/tool-manager-mcp.test.ts +++ b/packages/agent-core/test/mcp/tool-manager-mcp.test.ts @@ -610,4 +610,54 @@ describe('ToolManager MCP integration', () => { expect(tm.loopTools.map((t) => t.name)).toEqual(['mcp__s__echo']); }); + + it('a server-scoped MCP deny glob hides that server under a broad allow', async () => { + const tm = new ToolManager(fakeAgent()); + const githubClient = fakeClient(); + const slackClient = fakeClient(); + tm.registerMcpServer('github', githubClient, await discoverTools(githubClient)); + tm.registerMcpServer('slack', slackClient, await discoverTools(slackClient)); + tm.setActiveTools(['mcp__*'], ['mcp__github__*']); + + expect(tm.loopTools.map((t) => t.name).toSorted()).toEqual([ + 'mcp__slack__echo', + 'mcp__slack__noop', + ]); + expect([...tm.toolInfos()].filter((i) => i.source === 'mcp')).toEqual([ + { name: 'mcp__github__echo', description: 'Echoes back', active: false, source: 'mcp' }, + { name: 'mcp__github__noop', description: 'Does nothing', active: false, source: 'mcp' }, + { name: 'mcp__slack__echo', description: 'Echoes back', active: true, source: 'mcp' }, + { name: 'mcp__slack__noop', description: 'Does nothing', active: true, source: 'mcp' }, + ]); + }); + + it('an exact MCP deny hides one tool while the server glob allows the rest', async () => { + const tm = new ToolManager(fakeAgent()); + const client = fakeClient(); + tm.registerMcpServer('s', client, await discoverTools(client)); + tm.setActiveTools(['mcp__s__*'], ['mcp__s__echo']); + + expect(tm.loopTools.map((t) => t.name)).toEqual(['mcp__s__noop']); + }); + + it('a full mcp__* deny hides every MCP tool', async () => { + const tm = new ToolManager(fakeAgent()); + const client = fakeClient(); + tm.registerMcpServer('s', client, await discoverTools(client)); + tm.setActiveTools(['mcp__*'], ['mcp__*']); + + expect(tm.loopTools.some((t) => t.name.startsWith('mcp__'))).toBe(false); + }); + + it('records the deny list alongside the active tools', async () => { + const calls: unknown[] = []; + const tm = new ToolManager(fakeAgent(calls)); + tm.setActiveTools(['Read', 'Bash', 'Grep'], ['Bash']); + + expect(calls[0]).toMatchObject({ + type: 'tools.set_active_tools', + names: ['Read', 'Bash', 'Grep'], + disallowedNames: ['Bash'], + }); + }); }); diff --git a/packages/agent-core/test/profile/agentfile.test.ts b/packages/agent-core/test/profile/agentfile.test.ts new file mode 100644 index 0000000000..6a793df511 --- /dev/null +++ b/packages/agent-core/test/profile/agentfile.test.ts @@ -0,0 +1,620 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { testKaos } from '../fixtures/test-kaos'; +import type { ProviderConfig } from '@moonshot-ai/kosong'; + +import type { SDKSessionRPC } from '../../src/rpc'; +import { Session } from '../../src/session'; +import { ProviderManager } from '../../src/session/provider-manager'; +import { + DEFAULT_AGENT_PROFILES, + SessionAgentProfileCatalog, + agentProfileFromFile, + parseAgentFileText, + type SystemPromptContext, +} from '../../src/profile'; +import { AgentFileParseError } from '../../src/profile/agentfile/parser'; + +const MOCK_PROVIDER = { + type: 'kimi', + apiKey: 'test-key', + model: 'mock-model', +} as const satisfies ProviderConfig; + +const tempDirs: string[] = []; + +afterEach(async () => { + for (const dir of tempDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } +}); + +async function makeTempDir(prefix = 'kimi-agentfile-'): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +const promptContext: SystemPromptContext = { + osEnv: { + osKind: 'Linux', + osArch: 'x86_64', + osVersion: 'test', + shellName: 'bash', + shellPath: '/bin/bash', + }, + cwd: '/workspace', + now: '2026-05-09T00:00:00.000Z', + cwdListing: 'README.md', + agentsMd: 'Project instructions.', + skills: 'Available test skills.', +}; + +function agentFileText(frontmatter: Record, body = 'You are a reviewer.'): string { + const lines = Object.entries(frontmatter).map(([key, value]) => { + if (Array.isArray(value)) { + // Quote every item: a bare `*` (or `*`-leading) entry would otherwise + // parse as a YAML alias reference. + return `${key}:\n${value.map((item) => ` - ${JSON.stringify(item)}`).join('\n')}`; + } + return `${key}: ${String(value)}`; + }); + return `---\n${lines.join('\n')}\n---\n\n${body}\n`; +} + +describe('parseAgentFileText', () => { + const parse = (text: string, path = '/agents/reviewer.md') => + parseAgentFileText({ path, source: 'project', text }); + + it('parses a minimal agent file', () => { + const definition = parse(agentFileText({ name: 'reviewer', description: 'Reviews code.' })); + expect(definition).toMatchObject({ + name: 'reviewer', + description: 'Reviews code.', + override: false, + prompt: 'You are a reviewer.', + source: 'project', + }); + }); + + it('derives the name from the file name when omitted', () => { + const definition = parse(agentFileText({ description: 'Reviews code.' }), '/agents/code-reviewer.md'); + expect(definition.name).toBe('code-reviewer'); + }); + + it('rejects non-kebab-case names', () => { + expect(() => parse(agentFileText({ name: 'Code Reviewer', description: 'd' }))).toThrow( + AgentFileParseError, + ); + }); + + it('rejects a missing description', () => { + expect(() => parse(agentFileText({ name: 'reviewer' }))).toThrow(/description/); + }); + + it('rejects missing frontmatter and an empty prompt body', () => { + expect(() => parse('You are a reviewer.')).toThrow(/Missing frontmatter/); + expect(() => parse(agentFileText({ description: 'd' }, ' '))).toThrow(/prompt body/); + }); + + it('accepts tools as a YAML list and as a comma-separated string', () => { + const asList = parse( + agentFileText({ description: 'd', tools: ['Read', 'Grep'] }), + ); + expect(asList.tools).toEqual(['Read', 'Grep']); + const asString = parse( + `---\ndescription: d\ntools: Read, Grep\n---\n\nbody\n`, + ); + expect(asString.tools).toEqual(['Read', 'Grep']); + }); + + it('normalizes a lone * in tools and subagents to unrestricted', () => { + const definition = parse(`---\ndescription: d\ntools: '*'\nsubagents: '*'\n---\n\nbody\n`); + expect(definition.tools).toBeUndefined(); + expect(definition.subagents).toBeUndefined(); + }); + + it('parses disallowedTools, subagents, override and model_preference', () => { + const definition = parse( + agentFileText({ + description: 'd', + override: true, + disallowedTools: ['Bash'], + subagents: ['explore'], + model_preference: 'secondary', + }), + ); + expect(definition).toMatchObject({ + override: true, + disallowedTools: ['Bash'], + subagents: ['explore'], + modelPreference: 'secondary', + }); + }); + + it('rejects an invalid model_preference', () => { + expect(() => parse(agentFileText({ description: 'd', model_preference: 'cheapest' }))).toThrow( + /model_preference/, + ); + }); + + it('ignores unknown frontmatter fields', () => { + const definition = parse(agentFileText({ description: 'd', future_field: 'x' })); + expect(definition.name).toBe('reviewer'); + }); +}); + +describe('agentProfileFromFile', () => { + const defaultTools = DEFAULT_AGENT_PROFILES['agent']!.tools; + const basePrompt = () => 'BASE PROMPT'; + + it('renders ${var} placeholders from the prompt context', () => { + const definition = parseAgentFileText({ + path: '/agents/reviewer.md', + source: 'project', + text: agentFileText( + { description: 'd' }, + 'Work in ${cwd}. OS: ${os}. Instructions: ${agents_md}', + ), + }); + const profile = agentProfileFromFile(definition, defaultTools, basePrompt); + expect(profile.systemPrompt(promptContext)).toBe( + 'Work in /workspace. OS: Linux. Instructions: Project instructions.', + ); + }); + + it('leaves unknown placeholders and nunjucks syntax verbatim', () => { + const definition = parseAgentFileText({ + path: '/agents/reviewer.md', + source: 'project', + text: agentFileText({ description: 'd' }, 'Keep ${unknown_var} and {{ not_a_var }} literal.'), + }); + const profile = agentProfileFromFile(definition, defaultTools, basePrompt); + expect(profile.systemPrompt(promptContext)).toBe('Keep ${unknown_var} and {{ not_a_var }} literal.'); + }); + + it('expands ${base_prompt} with the builtin default prompt', () => { + const definition = parseAgentFileText({ + path: '/agents/reviewer.md', + source: 'project', + text: agentFileText({ description: 'd' }, 'Wrapper.\n${base_prompt}\nEnd.'), + }); + const profile = agentProfileFromFile(definition, defaultTools, basePrompt); + expect(profile.systemPrompt(promptContext)).toBe('Wrapper.\nBASE PROMPT\nEnd.'); + }); + + it('injects skills only when the Skill tool survives the tool list', () => { + const withSkill = agentProfileFromFile( + parseAgentFileText({ + path: '/agents/a.md', + source: 'project', + text: agentFileText({ description: 'd' }, '${skills_section}done'), + }), + defaultTools, + basePrompt, + ); + expect(withSkill.systemPrompt(promptContext)).toContain('Available test skills.'); + + const withoutSkill = agentProfileFromFile( + parseAgentFileText({ + path: '/agents/b.md', + source: 'project', + text: agentFileText({ description: 'd', tools: ['Read'] }, '${skills_section}done'), + }), + defaultTools, + basePrompt, + ); + expect(withoutSkill.systemPrompt(promptContext)).toBe('done'); + }); + + it('defaults tools to the default set and passes disallowedTools through', () => { + const unrestricted = agentProfileFromFile( + parseAgentFileText({ + path: '/agents/a.md', + source: 'project', + text: agentFileText({ description: 'd' }), + }), + defaultTools, + basePrompt, + ); + expect(unrestricted.tools).toEqual(defaultTools); + expect(unrestricted.disallowedTools).toBeUndefined(); + + // The denylist rides the profile (evaluated by the tool manager against + // resolved tool names) instead of being subtracted here. + const restricted = agentProfileFromFile( + parseAgentFileText({ + path: '/agents/b.md', + source: 'project', + text: agentFileText({ description: 'd', disallowedTools: ['Bash', 'mcp__github__*'] }), + }), + defaultTools, + basePrompt, + ); + expect(restricted.tools).toEqual(defaultTools); + expect(restricted.disallowedTools).toEqual(['Bash', 'mcp__github__*']); + }); +}); + +describe('SessionAgentProfileCatalog', () => { + async function makeLayout() { + const workDir = await makeTempDir(); + await mkdir(join(workDir, '.git')); + const brandHome = await makeTempDir(); + const osHome = await makeTempDir(); + return { workDir, brandHome, osHome }; + } + + function catalog(options: { + workDir: string; + brandHomeDir: string; + osHomeDir: string; + extraDirs?: readonly string[]; + explicitFiles?: readonly string[]; + warnings?: string[]; + }): SessionAgentProfileCatalog { + return new SessionAgentProfileCatalog({ + workDir: options.workDir, + brandHomeDir: options.brandHomeDir, + osHomeDir: options.osHomeDir, + extraDirs: options.extraDirs, + explicitFiles: options.explicitFiles, + warn: (message) => options.warnings?.push(message), + }); + } + + async function writeAgent(dir: string, fileName: string, text: string): Promise { + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, fileName), text, 'utf-8'); + } + + it('discovers agents from the user brand dir and the project dir', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeAgent(join(brandHome, 'agents'), 'user-agent.md', agentFileText({ description: 'User agent.' })); + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'project-agent.md', + agentFileText({ description: 'Project agent.' }), + ); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await c.ready; + expect(c.get('user-agent')?.description).toBe('User agent.'); + expect(c.get('project-agent')?.description).toBe('Project agent.'); + // The builtins are always present. + expect(c.get('coder')).toBeDefined(); + expect(c.getDefault().name).toBe('agent'); + }); + + it('lets the project source shadow the user source on the same name', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeAgent(join(brandHome, 'agents'), 'shared.md', agentFileText({ description: 'From user.' })); + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'shared.md', + agentFileText({ description: 'From project.' }), + ); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await c.ready; + expect(c.get('shared')?.description).toBe('From project.'); + }); + + it('requires override: true before a file replaces a same-name builtin', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + const warnings: string[] = []; + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'coder.md', + agentFileText({ description: 'Rogue coder.' }), + ); + + const rejected = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome, warnings }); + await rejected.ready; + expect(rejected.get('coder')?.description).toBe(DEFAULT_AGENT_PROFILES['coder']!.description); + expect(warnings.some((w) => w.includes('override: true'))).toBe(true); + + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'coder.md', + agentFileText({ description: 'Rogue coder.', override: true }, 'Rogue prompt.'), + ); + const accepted = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await accepted.ready; + expect(accepted.get('coder')?.description).toBe('Rogue coder.'); + expect(accepted.get('coder')?.systemPrompt(promptContext)).toBe('Rogue prompt.'); + }); + + it('replaces the default profile prompt with SYSTEM.md', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeFile(join(brandHome, 'SYSTEM.md'), 'Custom system for ${os}.', 'utf-8'); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await c.ready; + expect(c.getDefault().systemPrompt(promptContext)).toBe('Custom system for Linux.'); + // Only the prompt changed; tools and description stay builtin. + expect(c.getDefault().tools).toEqual(DEFAULT_AGENT_PROFILES['agent']!.tools); + }); + + it('lets a project agent.md win over SYSTEM.md', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeFile(join(brandHome, 'SYSTEM.md'), 'From SYSTEM.md', 'utf-8'); + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'agent.md', + agentFileText({ description: 'Project default.', override: true }, 'From project agent.md'), + ); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await c.ready; + expect(c.getDefault().systemPrompt(promptContext)).toBe('From project agent.md'); + }); + + it('fails ready for an invalid explicit agent file', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + const c = catalog({ + workDir, + brandHomeDir: brandHome, + osHomeDir: osHome, + explicitFiles: ['missing.md'], + }); + await expect(c.ready).rejects.toThrow(); + }); + + it('treats an explicit file as an implicit builtin override', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + const explicitPath = join(workDir, 'explicit-coder.md'); + await writeFile( + explicitPath, + agentFileText({ name: 'coder', description: 'Explicit coder.' }, 'Explicit prompt.'), + 'utf-8', + ); + + const c = catalog({ + workDir, + brandHomeDir: brandHome, + osHomeDir: osHome, + explicitFiles: ['explicit-coder.md'], + }); + await c.ready; + expect(c.get('coder')?.description).toBe('Explicit coder.'); + }); + + it('extends the default delegation set with file-defined agents', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'reviewer.md', + agentFileText({ description: 'Reviews code.' }), + ); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await c.ready; + const delegatable = c.delegatableSubagents('agent'); + expect(Object.keys(delegatable)).toContain('reviewer'); + expect(Object.keys(delegatable)).toContain('coder'); + // The builtin profile objects are shared constants; the extension must + // not leak into them. + expect(Object.keys(DEFAULT_AGENT_PROFILES['agent']!.subagents ?? {})).not.toContain('reviewer'); + }); + + it('links a file profile subagents allowlist, unrestricted when omitted', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'leader.md', + agentFileText({ description: 'Leads.', subagents: ['explore'] }), + ); + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'worker.md', + agentFileText({ description: 'Works.' }), + ); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await c.ready; + expect(Object.keys(c.delegatableSubagents('leader'))).toEqual(['explore']); + const workerDelegatable = c.delegatableSubagents('worker'); + expect(Object.keys(workerDelegatable)).toContain('leader'); + expect(Object.keys(workerDelegatable)).toContain('coder'); + }); + + it('renders a file profile prompt through the builtin base prompt', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'reviewer.md', + agentFileText({ description: 'd' }, 'Custom.\n${base_prompt}'), + ); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await c.ready; + const rendered = c.get('reviewer')!.systemPrompt(promptContext); + expect(rendered).toContain('Custom.'); + expect(rendered).toContain('Project instructions.'); + expect(rendered.length).toBeGreaterThan('Custom.\n'.length); + }); + + it('backs ${base_prompt} with the SYSTEM.md override when present', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeFile(join(brandHome, 'SYSTEM.md'), 'Org default on ${os}.', 'utf-8'); + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'reviewer.md', + agentFileText({ description: 'd' }, '[${base_prompt}]'), + ); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await c.ready; + // The agent file's ${base_prompt} expands to the effective default — + // the SYSTEM.md override — not the builtin default. + expect(c.get('reviewer')!.systemPrompt(promptContext)).toBe('[Org default on Linux.]'); + }); + + it('chains ${base_prompt} through SYSTEM.md down to the builtin default', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeFile(join(brandHome, 'SYSTEM.md'), 'Org layer.\n${base_prompt}', 'utf-8'); + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'reviewer.md', + agentFileText({ description: 'd' }, 'File layer.\n${base_prompt}'), + ); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await c.ready; + const rendered = c.get('reviewer')!.systemPrompt(promptContext); + // Two hops: file → SYSTEM.md → builtin default (which renders agents_md). + expect(rendered).toContain('File layer.'); + expect(rendered).toContain('Org layer.'); + expect(rendered).toContain('Project instructions.'); + }); + + it('never recurses when a file overrides the default and references ${base_prompt}', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'agent.md', + agentFileText({ description: 'Override default.', override: true }, 'Override.\n${base_prompt}'), + ); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await c.ready; + const rendered = c.getDefault().systemPrompt(promptContext); + // The overriding file's base is the effective default slot (builtin + // here), structurally disjoint from the merge — no self-reference. + expect(rendered).toContain('Override.'); + expect(rendered).toContain('Project instructions.'); + }); + + it('warns about dead tool patterns in agent files', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + const warnings: string[] = []; + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'reviewer.md', + agentFileText({ + description: 'd', + tools: ['Read', 'read', '*', 'mcp__github', 'mcp__github__*'], + disallowedTools: ['Bash'], + }), + ); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome, warnings }); + await c.ready; + // Typo (unknown tool), bare wildcard, incomplete mcp literal. + expect(warnings.some((w) => w.includes('"read"') && w.includes('no registered or built-in tool'))).toBe(true); + expect(warnings.some((w) => w.includes('"*"') && w.includes('wildcards only work inside mcp__'))).toBe(true); + expect(warnings.some((w) => w.includes('"mcp__github"') && w.includes('mcp____'))).toBe(true); + // Valid entries produce no warnings. + expect(warnings.some((w) => w.includes('"Read"'))).toBe(false); + expect(warnings.some((w) => w.includes('"mcp__github__*"'))).toBe(false); + expect(warnings.some((w) => w.includes('"Bash"'))).toBe(false); + }); +}); + +describe('Session agentfile wiring', () => { + function createSessionRpc(): SDKSessionRPC { + return { + emitEvent: vi.fn(async () => {}), + requestApproval: vi.fn(async () => ({ decision: 'cancelled' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ content: [] })), + } as unknown as SDKSessionRPC; + } + + function testProviderManager(): ProviderManager { + return new ProviderManager({ + config: { + providers: { test: { type: MOCK_PROVIDER.type, apiKey: MOCK_PROVIDER.apiKey } }, + defaultProvider: 'test', + defaultModel: MOCK_PROVIDER.model, + models: { + [MOCK_PROVIDER.model]: { + provider: 'test', + model: MOCK_PROVIDER.model, + maxContextSize: 1_000_000, + }, + }, + }, + }); + } + + it('binds a file-defined main profile selected by profileName', async () => { + const workDir = await makeTempDir(); + const sessionDir = await makeTempDir(); + const brandHome = await makeTempDir(); + const osHome = await makeTempDir(); + await mkdir(join(workDir, '.kimi-code', 'agents'), { recursive: true }); + await writeFile( + join(workDir, '.kimi-code', 'agents', 'reviewer.md'), + agentFileText({ description: 'Reviews code.' }, 'You review code in ${cwd}.'), + 'utf-8', + ); + + const session = new Session({ + id: 'test-agentfile-main', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + kimiHomeDir: brandHome, + rpc: createSessionRpc(), + providerManager: testProviderManager(), + agents: { userHomeDir: osHome, profileName: 'reviewer' }, + }); + try { + const main = await session.createMain(); + expect(main.config.profileName).toBe('reviewer'); + expect(main.config.systemPrompt).toContain(`You review code in ${workDir}`); + // The default delegation set includes the file-defined agent. + expect(Object.keys(session.agentCatalog.delegatableSubagents('agent'))).toContain('reviewer'); + } finally { + await session.close(); + } + }); + + it('fails createMain for an unknown profileName', async () => { + const workDir = await makeTempDir(); + const sessionDir = await makeTempDir(); + const brandHome = await makeTempDir(); + const session = new Session({ + id: 'test-agentfile-unknown', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + kimiHomeDir: brandHome, + rpc: createSessionRpc(), + providerManager: testProviderManager(), + agents: { userHomeDir: brandHome, profileName: 'nope' }, + }); + try { + await expect(session.createMain()).rejects.toThrow(/Agent profile "nope" was not found/); + } finally { + await session.close(); + } + }); + + it('fails createMain for an invalid explicit agent file', async () => { + const workDir = await makeTempDir(); + const sessionDir = await makeTempDir(); + const brandHome = await makeTempDir(); + const session = new Session({ + id: 'test-agentfile-fatal', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + kimiHomeDir: brandHome, + rpc: createSessionRpc(), + providerManager: testProviderManager(), + agents: { userHomeDir: brandHome, explicitFiles: [join(workDir, 'missing.md')] }, + }); + try { + await expect(session.createMain()).rejects.toThrow(); + } finally { + // close() -> flushMetadata() awaits the same readiness gate, which + // carries the fatal agentfile error too; production swallows it via + // core-impl's `session.close().catch(() => {})` cleanup path. + await session.close().catch(() => {}); + } + }); +}); diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index bcaace75d6..3c4a71465f 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -8,7 +8,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Agent, AgentOptions } from '../../src/agent'; import { AGENT_WIRE_PROTOCOL_VERSION } from '../../src/agent/records'; -import type { ResolvedAgentProfile } from '../../src/profile'; +import type { KimiConfig } from '../../src/config'; +import { ErrorCodes, KimiError } from '../../src/errors'; +import { FlagResolver } from '../../src/flags'; +import { SessionAgentProfileCatalog, type ResolvedAgentProfile } from '../../src/profile'; import type { SDKSessionRPC } from '../../src/rpc'; import { Session } from '../../src/session'; import { collectGitContext } from '../../src/session/git-context'; @@ -491,6 +494,7 @@ describe('SessionSubagentHost', () => { agents: new Map([['main', parent.agent]]), ensureAgentResumed: vi.fn(async () => parent.agent), createAgent, + agentCatalog: testAgentCatalog(), } as never, 'main', ); @@ -517,6 +521,7 @@ describe('SessionSubagentHost', () => { agents: new Map([['main', parent.agent]]), ensureAgentResumed: vi.fn(async () => parent.agent), createAgent, + agentCatalog: testAgentCatalog(), } as never, 'main', ); @@ -1170,6 +1175,202 @@ describe('SessionSubagentHost', () => { expect(child.agent.config.modelAlias).toBe(parent.agent.config.modelAlias); expect(child.agent.config.modelAlias).not.toBe('stale-model-from-initial-spawn'); }); + + describe('secondary model binding', () => { + const secondaryFlags = () => + new FlagResolver({ KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL: '1' }); + const LONG_SUMMARY = + 'Completed the delegated task end to end and reported a technically complete summary so the parent agent can continue without repeating prior work. ' + + 'The report covers the investigation, the changes made, and the verification results in enough detail for the caller to act on directly.'; + // Harness model registry entries resolvable through the child's + // ProviderManager: the secondary alias and the synthesized derived entry + // (in production `applySecondaryModelConfig` injects the latter into the + // session runtime config). + const withSecondaryModels = (config?: KimiConfig): KimiConfig => ({ + providers: { 'test-provider': { type: 'kimi', apiKey: 'test-key' } }, + ...config, + models: { + 'cheap-model': { + provider: 'test-provider', + model: 'cheap-model', + maxContextSize: 1_000_000, + }, + '__secondary__': { + provider: 'test-provider', + model: 'cheap-model', + maxContextSize: 65536, + }, + }, + }); + + async function spawnChild(options: { + config?: KimiConfig; + experimentalFlags?: FlagResolver; + providerManager?: Session['options']['providerManager']; + modelChoice?: 'primary' | 'secondary'; + profilePreference?: 'primary' | 'secondary'; + }) { + const parent = testAgent(); + parent.configure(); + const child = testAgent({ initialConfig: withSecondaryModels() }); + child.configure({ tools: ['Read'] }); + child.mockNextResponse({ type: 'text', text: LONG_SUMMARY }); + + const session = fakeSession(parent.agent, child.agent, {}, { + config: options.config, + experimentalFlags: options.experimentalFlags, + providerManager: options.providerManager, + }); + const host = new SessionSubagentHost(session, 'main'); + if (options.profilePreference !== undefined) { + vi.spyOn( + host as unknown as { + resolveProfile: (parent: Agent, name: string) => ResolvedAgentProfile; + }, + 'resolveProfile', + ).mockReturnValue( + profile({ + name: 'coder', + tools: ['Read'], + systemPrompt: 'coder prompt', + modelPreference: options.profilePreference, + }), + ); + } + const handle = await host.spawn({ + profileName: 'coder', + modelChoice: options.modelChoice, + parentToolCallId: 'call_agent', + prompt: 'Do work', + description: 'Do work', + runInBackground: false, + signal, + }); + await handle.completion; + return { parent, child, handle }; + } + + it('binds the secondary model when configured', async () => { + const { parent, child } = await spawnChild({ + experimentalFlags: secondaryFlags(), + config: { + providers: {}, + secondaryModel: { model: 'cheap-model' }, + }, + }); + expect(child.agent.config.modelAlias).toBe('cheap-model'); + expect(child.agent.config.modelAlias).not.toBe(parent.agent.config.modelAlias); + }); + + it('binds the derived entry when the recipe carries patch fields', async () => { + const { child } = await spawnChild({ + experimentalFlags: secondaryFlags(), + config: { + providers: {}, + secondaryModel: { model: 'cheap-model', defaultEffort: 'low' }, + }, + }); + // default_effort is part of the subagent-only patch, so the spawn binds + // the synthesized derived entry rather than the pointed alias. + expect(child.agent.config.modelAlias).toBe('__secondary__'); + }); + + it('inherits the parent model when the experiment is off', async () => { + const { parent, child } = await spawnChild({ + config: { providers: {}, secondaryModel: { model: 'cheap-model' } }, + }); + expect(child.agent.config.modelAlias).toBe(parent.agent.config.modelAlias); + }); + + it('inherits the parent model for an explicit model: primary choice', async () => { + const { parent, child } = await spawnChild({ + experimentalFlags: secondaryFlags(), + config: { providers: {}, secondaryModel: { model: 'cheap-model' } }, + modelChoice: 'primary', + }); + expect(child.agent.config.modelAlias).toBe(parent.agent.config.modelAlias); + }); + + it('honors the profile model_preference over the configured secondary model', async () => { + const { parent, child } = await spawnChild({ + experimentalFlags: secondaryFlags(), + config: { providers: {}, secondaryModel: { model: 'cheap-model' } }, + profilePreference: 'primary', + }); + expect(child.agent.config.modelAlias).toBe(parent.agent.config.modelAlias); + }); + + it('fails the spawn with a wrapped error when the secondary model does not resolve', async () => { + const parent = testAgent(); + parent.configure(); + const child = testAgent(); + child.configure({ tools: ['Read'] }); + const providerManager = { + resolveProviderConfig: vi.fn(() => { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + 'Model "missing-model" is not configured in config.toml.', + ); + }), + } as unknown as Session['options']['providerManager']; + const session = fakeSession(parent.agent, child.agent, {}, { + experimentalFlags: secondaryFlags(), + config: { providers: {}, secondaryModel: { model: 'missing-model' } }, + providerManager, + }); + const host = new SessionSubagentHost(session, 'main'); + + await expect( + host.spawn({ + profileName: 'coder', + parentToolCallId: 'call_agent', + prompt: 'Do work', + description: 'Do work', + runInBackground: false, + signal, + }).then((handle) => handle.completion), + ).rejects.toThrow(/\[secondary_model\]\.model/); + }); + + it('keeps the spawned model on resume when the experiment is on', async () => { + const parent = testAgent(); + parent.configure(); + parent.agent.permission.setMode('yolo'); + + const child = testAgent({ initialConfig: withSecondaryModels() }); + child.configure({ tools: ['Read'] }); + child.agent.config.update({ modelAlias: 'cheap-model' }); + child.agent.useProfile( + profile({ name: 'coder', tools: ['Read'], systemPrompt: 'coder prompt' }), + ); + child.agent.context.appendUserMessage([{ type: 'text', text: 'Earlier context' }]); + child.mockNextResponse({ type: 'text', text: LONG_SUMMARY }); + + const session = fakeSession(parent.agent, child.agent, { + 'agent-0': { + homedir: '/tmp/kimi-session/agents/agent-0', + type: 'sub', + parentAgentId: 'main', + }, + }, { + experimentalFlags: secondaryFlags(), + config: { providers: {}, secondaryModel: { model: 'cheap-model' } }, + }); + const host = new SessionSubagentHost(session, 'main'); + + const handle = await host.resume('agent-0', { + parentToolCallId: 'call_agent', + prompt: 'Continue from context', + description: 'Continue work', + runInBackground: false, + signal, + }); + await handle.completion; + // With the experiment on, resume no longer realigns the child to the + // parent's model: the subagent keeps the model it was bound to at spawn. + expect(child.agent.config.modelAlias).toBe('cheap-model'); + }); + }); }); describe('Session resume permission parent chain', () => { @@ -1576,10 +1777,25 @@ describe('Session.createAgent', () => { }); }); +function testAgentCatalog(): SessionAgentProfileCatalog { + // A real catalog seeded with the builtin profiles; discovery roots point + // at nonexistent dirs so no file profiles leak into the merge. + return new SessionAgentProfileCatalog({ + workDir: '/nonexistent-kimi-test-workdir', + brandHomeDir: '/nonexistent-kimi-test-brandhome', + osHomeDir: '/nonexistent-kimi-test-oshome', + }); +} + function fakeSession( parent: Agent, child: Agent, metadataAgents: Session['metadata']['agents'] = {}, + sessionOptions?: { + config?: KimiConfig; + experimentalFlags?: FlagResolver; + providerManager?: Session['options']['providerManager']; + }, ) { const agents = new Map([['main', parent]]); if (metadataAgents['agent-0'] !== undefined) { @@ -1587,7 +1803,13 @@ function fakeSession( } return { agents, - options: { kimiHomeDir: undefined }, + options: { + kimiHomeDir: undefined, + config: sessionOptions?.config, + providerManager: sessionOptions?.providerManager, + }, + experimentalFlags: sessionOptions?.experimentalFlags ?? new FlagResolver({}), + agentCatalog: testAgentCatalog(), metadata: { createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', @@ -1665,6 +1887,7 @@ function profile(input: { readonly systemPrompt: string; readonly description?: string | undefined; readonly subagents?: Record | undefined; + readonly modelPreference?: 'primary' | 'secondary' | undefined; }): ResolvedAgentProfile { return { name: input.name, @@ -1672,6 +1895,7 @@ function profile(input: { systemPrompt: () => input.systemPrompt, tools: [...input.tools], subagents: input.subagents, + modelPreference: input.modelPreference, }; } diff --git a/packages/agent-core/test/tools/agent.test.ts b/packages/agent-core/test/tools/agent.test.ts index ec5109c2ef..0df5eab03a 100644 --- a/packages/agent-core/test/tools/agent.test.ts +++ b/packages/agent-core/test/tools/agent.test.ts @@ -22,7 +22,7 @@ function context(args: Input, toolCallId = 'call_agent') { function mockSubagentHost & Partial>( host: T, ): T & SessionSubagentHost { - return { resume: vi.fn(), ...host } as unknown as T & SessionSubagentHost; + return { resume: vi.fn(), delegatableSubagents: vi.fn(() => ({})), ...host } as unknown as T & SessionSubagentHost; } function agentTool( @@ -136,12 +136,30 @@ describe('AgentTool', () => { expect(tool.description).toContain('Default to a foreground subagent'); }); - it('does not expose a model parameter in the JSON schema', () => { + it('exposes a primary/secondary model parameter in the JSON schema', () => { const host = mockSubagentHost({ spawn: vi.fn() }); const tool = agentTool(host); - const properties = (tool.parameters as { properties: Record }).properties; + const properties = ( + tool.parameters as { + properties: Record; + } + ).properties; + + expect(properties['model']?.enum).toEqual(['primary', 'secondary']); + expect(properties['model']?.description).toContain('secondary'); + }); + + it('appends the subagent model description only when provided', () => { + const host = mockSubagentHost({ spawn: vi.fn() }); + const withoutModels = agentTool(host); + expect(withoutModels.description).not.toContain('Available models'); - expect(properties).not.toHaveProperty('model'); + const withModels = agentTool(host, createBackgroundManager().manager, undefined, { + subagentModelDescription: + 'Available models (pass via model):\n- secondary: cheap (default)\n- primary: flagship', + }); + expect(withModels.description).toContain('Available models (pass via model):'); + expect(withModels.description).toContain('secondary: cheap'); }); it('renders the tool set for each subagent type', () => { @@ -278,6 +296,48 @@ describe('AgentTool', () => { ); }); + it('passes the model choice through to spawn, but not to resume', async () => { + const host = mockSubagentHost({ + spawn: vi.fn().mockResolvedValue({ + agentId: 'agent-child', + profileName: 'coder', + resumed: false, + completion: Promise.resolve({ result: 'child result' }), + }), + resume: vi.fn().mockResolvedValue({ + agentId: 'agent-existing', + profileName: 'coder', + resumed: true, + completion: Promise.resolve({ result: 'resumed result' }), + }), + }); + const tool = agentTool(host); + + await executeTool(tool, + context({ + prompt: 'Investigate', + description: 'Find cause', + model: 'primary', + }), + ); + expect(host.spawn).toHaveBeenCalledWith( + expect.objectContaining({ modelChoice: 'primary' }), + ); + + await executeTool(tool, + context({ + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + model: 'secondary', + }), + ); + expect(host.resume).toHaveBeenCalledWith( + 'agent-existing', + expect.not.objectContaining({ modelChoice: expect.anything() }), + ); + }); + it('resumes a foreground subagent when resume is provided', async () => { const host = mockSubagentHost({ spawn: vi.fn(), diff --git a/packages/agent-core/test/tools/builtin-current.test.ts b/packages/agent-core/test/tools/builtin-current.test.ts index f9bae6ea6f..48d85813bd 100644 --- a/packages/agent-core/test/tools/builtin-current.test.ts +++ b/packages/agent-core/test/tools/builtin-current.test.ts @@ -81,6 +81,7 @@ function mockSubagentHost>( resume: vi.fn(), runQueued: vi.fn(), getSwarmItem: vi.fn(), + delegatableSubagents: vi.fn(() => ({})), ...host, } as unknown as T & SessionSubagentHost; } diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 0968d96a18..3feb97c887 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -107,6 +107,16 @@ export interface CreateSessionOptions { readonly kaos?: Kaos | undefined; readonly persistenceKaos?: Kaos | undefined; readonly additionalDirs?: readonly string[]; + /** + * Main-agent profile name (`--agent`): a builtin profile or one defined by + * an agentfile discovered from the user/project agent directories. + */ + readonly agentProfile?: string | undefined; + /** + * Explicit agentfiles (`--agent-file`) loaded for this session with the + * highest precedence; an invalid file fails session creation. + */ + readonly agentFiles?: readonly string[] | undefined; readonly sessionStartedProperties?: TelemetryProperties; /** * Print-mode (`kimi -p`) only: when the main agent ends a turn while From da65991b7168c38ffe3df23c3e494b1cf7bde3a2 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 27 Jul 2026 13:27:23 +0800 Subject: [PATCH 02/30] fix(cli): guard optional agentFiles in the prompt runner runPrompt is also driven programmatically (headless goal flow) with options that never pass through the CLI parser defaults, so agentFiles can be undefined; mirror the addDirs optional-chaining pattern. Also extend the SDK experimental-feature assertion with the secondary-model flag. --- apps/kimi-code/src/cli/run-prompt.ts | 2 +- packages/node-sdk/test/config.test.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index db8db55c26..d03fe63be0 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -372,7 +372,7 @@ async function resolvePromptSession( permission: 'auto', additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, agentProfile: opts.agent, - agentFiles: opts.agentFiles.length > 0 ? opts.agentFiles : undefined, + agentFiles: opts.agentFiles?.length ? opts.agentFiles : undefined, drainAgentTasksOnStop: true, }); installHeadlessHandlers(session); diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index 9484facf1f..3fae01040d 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -345,6 +345,17 @@ describe('KimiHarness config API', () => { enabled: false, source: 'default', }, + { + id: 'secondary-model', + title: 'Secondary model for subagents', + description: + 'Let newly spawned subagents use a separately configured secondary model by default, with an explicit primary-model override for quality-sensitive tasks.', + surface: 'core', + env: 'KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', + defaultEnabled: false, + enabled: false, + source: 'default', + }, ]); }); From 2d86f42f40b41487b275c11e05ef79b8cb998e3d Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 27 Jul 2026 15:23:40 +0800 Subject: [PATCH 03/30] fix(agent-core): preserve custom agent bindings on v1 --- .changeset/sdk-agent-profile-options.md | 2 +- apps/kimi-code/src/cli/commands.ts | 4 +- apps/kimi-code/src/cli/run-prompt.ts | 39 +++++++- apps/kimi-code/test/cli/options.test.ts | 17 ++++ apps/kimi-code/test/cli/run-prompt.test.ts | 65 ++++++++++++++ docs/en/reference/kimi-command.md | 8 +- docs/zh/reference/kimi-command.md | 8 +- packages/agent-core/src/agent/config/index.ts | 9 ++ packages/agent-core/src/agent/config/types.ts | 2 + packages/agent-core/src/agent/index.ts | 6 +- packages/agent-core/src/agent/tool/index.ts | 6 +- .../agent-core/src/config/secondary-model.ts | 9 +- packages/agent-core/src/index.ts | 1 + .../src/profile/agentfile/catalog.ts | 2 +- packages/agent-core/src/profile/resolve.ts | 2 +- packages/agent-core/src/rpc/core-api.ts | 6 +- packages/agent-core/src/rpc/core-impl.ts | 2 + packages/agent-core/src/session/index.ts | 19 +++- .../agent-core/src/session/subagent-host.ts | 37 ++++++-- .../src/tools/builtin/collaboration/agent.ts | 2 +- packages/agent-core/test/agent/tool.test.ts | 60 +++++++++++++ .../test/config/secondary-model.test.ts | 1 + .../agent-core/test/profile/agentfile.test.ts | 62 ++++++++++++- .../test/session/subagent-host.test.ts | 2 +- packages/node-sdk/src/index.ts | 1 + packages/node-sdk/src/kimi-harness.ts | 2 + packages/node-sdk/src/types.ts | 6 +- .../test/create-session-transport.test.ts | 90 ++++++++++++++++++- 28 files changed, 430 insertions(+), 40 deletions(-) diff --git a/.changeset/sdk-agent-profile-options.md b/.changeset/sdk-agent-profile-options.md index 175fa7d270..99b7e63f68 100644 --- a/.changeset/sdk-agent-profile-options.md +++ b/.changeset/sdk-agent-profile-options.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code-sdk": patch --- -Add optional `agentProfile` and `agentFiles` fields to session creation options for selecting a custom main-agent profile. +Add SDK session profile selection for creation and validate the bound profile when resuming a session. diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 6ed5551939..f3634297b6 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -77,7 +77,7 @@ export function createProgram( .addOption( new Option( '--agent ', - 'Agent profile to use for this invocation (v2 engine only). Custom profiles are discovered from agent directories or loaded via --agent-file.', + 'Agent profile to use for this print-mode invocation on either engine. Custom profiles are discovered from agent directories or loaded via --agent-file.', ) .argParser((value: string, previous: string | undefined) => { if (previous !== undefined) { @@ -90,7 +90,7 @@ export function createProgram( .addOption( new Option( '--agent-file ', - 'Load an agent definition from a Markdown file and select it (v2 engine only).', + 'Load an agent definition from a Markdown file and select it for this print-mode invocation on either engine.', ) .argParser((value: string, previous: string[] | undefined) => { if ((previous?.length ?? 0) > 0) { diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index d03fe63be0..22132dabe0 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -1,3 +1,6 @@ +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; + import { setCrashPhase, setTelemetryContext, @@ -9,6 +12,8 @@ import chalk from 'chalk'; import { createKimiHarness, log, + parseAgentFileText, + resolveAgentPath, type Event, type GoalSnapshot, type SessionStatus, @@ -296,6 +301,8 @@ async function resolvePromptSession( stderr: PromptOutput, setRestorePermission: (restorePermission: () => Promise) => void, ): Promise { + const agentProfile = await resolveAgentProfileSelection(opts, workDir); + if (opts.session !== undefined) { const sessions = await harness.listSessions({ sessionId: opts.session, workDir }); const target = sessions[0]; @@ -316,6 +323,7 @@ async function resolvePromptSession( const session = await harness.resumeSession({ id: opts.session, additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, + agentProfile, }); const status = await session.getStatus(); const restorePermission = await forcePromptPermission( @@ -343,6 +351,7 @@ async function resolvePromptSession( const session = await harness.resumeSession({ id: previous.id, additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, + agentProfile, }); const status = await session.getStatus(); const restorePermission = await forcePromptPermission( @@ -371,7 +380,7 @@ async function resolvePromptSession( model, permission: 'auto', additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, - agentProfile: opts.agent, + agentProfile, agentFiles: opts.agentFiles?.length ? opts.agentFiles : undefined, drainAgentTasksOnStop: true, }); @@ -385,6 +394,34 @@ async function resolvePromptSession( }; } +async function resolveAgentProfileSelection( + opts: CLIOptions, + workDir: string, +): Promise { + if (opts.agent !== undefined) return opts.agent; + const agentFile = opts.agentFiles?.[0]; + if (agentFile === undefined) return undefined; + + const path = resolveAgentPath(agentFile, workDir, homedir()); + let text: string; + try { + text = await readFile(path, 'utf8'); + } catch (error) { + throw new Error( + `Failed to read agent file "${path}": ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + try { + return parseAgentFileText({ path, source: 'explicit', text }).name; + } catch (error) { + throw new Error( + `Invalid agent file "${path}": ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } +} + async function forcePromptPermission( session: PromptSession, previousPermission: SessionStatus['permission'], diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 8af79a9659..e7facf1ad8 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -1,3 +1,10 @@ +/** + * Scenario: top-level CLI option parsing, validation, and help discovery. + * Responsibilities: accepted arguments map to CLIOptions and invalid combinations fail early. + * Wiring: Commander is real; command handlers and output sinks are local test boundaries. + * Run: pnpm -C apps/kimi-code exec vitest run test/cli/options.test.ts + */ + import { describe, expect, it } from 'vitest'; import { createProgram } from '#/cli/commands'; @@ -393,6 +400,16 @@ describe('CLI options parsing', () => { }); describe('--agent / --agent-file', () => { + it('describes agent selectors as available in print mode on either engine', () => { + const help = createProgram('0.1.0-test', () => {}, () => {}).helpInformation(); + const normalizedHelp = help.replaceAll(/\s+/g, ' '); + + expect(normalizedHelp).toContain( + 'Agent profile to use for this print-mode invocation on either engine.', + ); + expect(help).not.toContain('v2 engine only'); + }); + it('parses a single --agent', () => { const opts = parse(['-p', 'hi', '--agent', 'reviewer']); expect(opts.agent).toBe('reviewer'); diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index 4bad127d89..922574de87 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -1,3 +1,14 @@ +/** + * Scenario: print-mode session startup and resume routing. + * Responsibilities: CLI options are translated into the SDK session contract and output is rendered. + * Wiring: the SDK/telemetry/process boundaries are mocked; the print driver is real. + * Run: pnpm -C apps/kimi-code exec vitest run test/cli/run-prompt.test.ts + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import type { createKimiDeviceId as createKimiDeviceIdFn } from '@moonshot-ai/kimi-code-oauth'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -282,6 +293,32 @@ describe('runPrompt', () => { expect(mocks.harnessClose).toHaveBeenCalled(); }); + it('selects the profile declared by an explicit agent file for a fresh v1 session', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kimi-run-prompt-agent-')); + const agentFile = join(dir, 'reviewer.md'); + await writeFile( + agentFile, + '---\nname: reviewer\ndescription: Reviews code.\n---\n\nReview the requested change.\n', + 'utf-8', + ); + + try { + await runPrompt(opts({ agentFiles: [agentFile] }), '1.2.3-test', { + stdout: writer(), + stderr: writer(), + }); + + expect(mocks.harnessCreateSession).toHaveBeenCalledWith( + expect.objectContaining({ + agentProfile: 'reviewer', + agentFiles: [agentFile], + }), + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + it('completes even if harness.close() never resolves (cleanup is time-bounded)', async () => { vi.useFakeTimers(); try { @@ -629,6 +666,20 @@ describe('runPrompt', () => { expect(mocks.harnessCreateSession).not.toHaveBeenCalled(); }); + it('forwards the selected agent profile when resuming a concrete v1 session', async () => { + await runPrompt(opts({ session: 'ses_existing', agent: 'reviewer' }), '1.2.3-test', { + stdout: writer(), + stderr: writer(), + }); + + expect(mocks.harnessResumeSession).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'ses_existing', + agentProfile: 'reviewer', + }), + ); + }); + it('allows resuming a concrete session when Windows workdir uses backslashes', async () => { const cwd = vi.spyOn(process, 'cwd').mockReturnValue(String.raw`C:\Users\kimi\project`); mocks.harnessListSessions.mockResolvedValueOnce([ @@ -901,6 +952,20 @@ describe('runPrompt', () => { expect(mocks.harnessCreateSession).not.toHaveBeenCalled(); }); + it('forwards the selected agent profile when continuing a previous v1 session', async () => { + await runPrompt(opts({ continue: true, agent: 'reviewer' }), '1.2.3-test', { + stdout: writer(), + stderr: writer(), + }); + + expect(mocks.harnessResumeSession).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'ses_previous', + agentProfile: 'reviewer', + }), + ); + }); + it('continues a previous session without a configured default model', async () => { mocks.harnessGetConfig.mockResolvedValueOnce({ providers: {}, telemetry: true }); mocks.session.getStatus.mockResolvedValueOnce({ permission: 'manual', model: 'saved-model' }); diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index 0c29c35ba0..1ba50409a9 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 (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` | +| `--agent ` | | Start the session with the specified agent as the main Agent (`kimi -p` only, on either engine) | +| `--agent-file ` | | Load a custom agent from a Markdown file for this launch and select it (`kimi -p` only, on either engine). 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,10 +98,10 @@ There are two ways to specify Skills directories, with different semantics: ### Custom Agents -`--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: +`--agent` and `--agent-file` select which agent drives the session. Both are available under `kimi -p` on either engine; interactive launches reject them with a clear error: ```sh -KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p --agent reviewer "Review the changes on this branch" +kimi -p --agent reviewer "Review the changes on this branch" ``` `--agent-file` registers a single agent file at the highest priority for this launch only and selects it; the flag cannot be repeated, and `--agent` and `--agent-file` are mutually exclusive. The selection is fixed at the session's first bind: resuming with the same `--agent` is a no-op, and switching to a different one fails with an "already bound" error. See [Agents and Sub-Agents](../customization/agents.md#custom-agents) for the agent file format and discovery directories. diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index f68b451239..d1290f270f 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 启动会话(仅实验性 `kimi -p`) | -| `--agent-file ` | | 从 Markdown 文件加载自定义 Agent(仅本次启动、仅实验性 `kimi -p`)并选中它。不可重复传入,也不能与 `--agent` 同时使用 | +| `--agent ` | | 以指定 Agent 作为主 Agent 启动会话(仅 `kimi -p`,两个引擎均支持) | +| `--agent-file ` | | 从 Markdown 文件加载自定义 Agent(仅本次启动、仅 `kimi -p`,两个引擎均支持)并选中它。不可重复传入,也不能与 `--agent` 同时使用 | | `--add-dir ` | | 为本次会话添加额外的工作目录。相对路径按当前工作目录解析。可重复传入 | `-r` / `--resume` 是 `--session` 的隐藏别名;`--yes` 和 `--auto-approve` 是 `--yolo` 的隐藏别名,在帮助信息中不显示。 @@ -98,10 +98,10 @@ kimi --plan ### 自定义 Agent -`--agent` 和 `--agent-file` 用于选择驱动会话的 Agent。目前二者仅在 `KIMI_CODE_EXPERIMENTAL_FLAG=1` 时的 `kimi -p` 下可用,其他启动方式会以明确错误拒绝: +`--agent` 和 `--agent-file` 用于选择驱动会话的 Agent。二者在两个引擎的 `kimi -p` 下均可用,交互式启动会以明确错误拒绝: ```sh -KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p --agent reviewer "审查这个分支上的改动" +kimi -p --agent reviewer "审查这个分支上的改动" ``` `--agent-file` 以最高优先级注册单个 Agent 文件(仅本次启动)并选中它;该 flag 不可重复传入,且 `--agent` 与 `--agent-file` 互斥。选择在会话首次绑定后即固定:以相同的 `--agent` 恢复会话是 no-op,换成不同的 Agent 会报 "already bound" 错误。Agent 文件格式与发现目录详见 [Agent 与子 Agent](../customization/agents.md#自定义-agent)。 diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index f0799f7f5b..725186b098 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -31,6 +31,7 @@ export class ConfigState { private _cwd: string; private _modelAlias: string | undefined; private _profileName: string | undefined; + private _subagentNames: readonly string[] | undefined; // `undefined` until an effort has actually been resolved: a bare modelAlias // update must then fall through to the model's own default instead of // treating the never-chosen initial "off" as an explicit user choice. @@ -99,6 +100,9 @@ export class ConfigState { if (changed.profileName) { this._profileName = changed.profileName; } + if (changed.subagentNames !== undefined) { + this._subagentNames = [...changed.subagentNames]; + } if (unforcedThinkingEffort !== undefined && thinkingEffort !== undefined) { this._unforcedThinkingEffort = unforcedThinkingEffort; this._thinkingEffort = thinkingEffort; @@ -137,6 +141,7 @@ export class ConfigState { modelAlias: this._modelAlias, modelCapabilities: resolved?.modelCapabilities ?? UNKNOWN_CAPABILITY, profileName: this.profileName, + subagentNames: this.subagentNames, thinkingEffort: this.thinkingEffort, systemPrompt: this.systemPrompt, }; @@ -249,6 +254,10 @@ export class ConfigState { return this._profileName; } + get subagentNames(): readonly string[] | undefined { + return this._subagentNames; + } + get systemPrompt(): string { return this._systemPrompt; } diff --git a/packages/agent-core/src/agent/config/types.ts b/packages/agent-core/src/agent/config/types.ts index 7b4d731dbf..bb29ca2444 100644 --- a/packages/agent-core/src/agent/config/types.ts +++ b/packages/agent-core/src/agent/config/types.ts @@ -6,6 +6,7 @@ export interface AgentConfigData { modelAlias?: string; modelCapabilities: ModelCapability; profileName?: string; + subagentNames?: readonly string[]; thinkingEffort: string; systemPrompt: string; } @@ -14,6 +15,7 @@ export type AgentConfigUpdateData = Partial<{ cwd: string; modelAlias: string; profileName: string; + subagentNames: readonly string[]; thinkingEffort: string; systemPrompt: string; }>; diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 28841d73f8..c347d5728c 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -435,9 +435,10 @@ export class Agent { profile: ResolvedAgentProfile, context?: PreparedSystemPromptContext, brandHome?: string, + subagentNames?: readonly string[], ): void { this.setActiveProfile(profile, brandHome); - this.updateSystemPromptFromProfile(profile, context); + this.updateSystemPromptFromProfile(profile, context, subagentNames); this.tools.setActiveTools(profile.tools, profile.disallowedTools); } @@ -465,6 +466,7 @@ export class Agent { private updateSystemPromptFromProfile( profile: ResolvedAgentProfile, context?: PreparedSystemPromptContext, + subagentNames?: readonly string[], ): void { const systemPrompt = profile.systemPrompt({ osEnv: this.kaos.osEnv, @@ -474,7 +476,7 @@ export class Agent { agentsMd: context?.agentsMd, additionalDirsInfo: context?.additionalDirsInfo, }); - this.config.update({ profileName: profile.name, systemPrompt }); + this.config.update({ profileName: profile.name, systemPrompt, subagentNames }); } async resume(options?: AgentRecordsReplayOptions): Promise<{ warning?: string }> { diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index a3752a0106..fb9ccd19fb 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -785,9 +785,9 @@ export class ToolManager { this.agent.skills?.registry.getSkillRoots() ?? [], ); const allowBackground = - this.enabledTools.has('TaskList') && - this.enabledTools.has('TaskOutput') && - this.enabledTools.has('TaskStop'); + this.isExactToolEnabled('TaskList') && + this.isExactToolEnabled('TaskOutput') && + this.isExactToolEnabled('TaskStop'); const goalToolsEnabled = this.agent.type === 'main'; this.builtinTools = new Map( [ diff --git a/packages/agent-core/src/config/secondary-model.ts b/packages/agent-core/src/config/secondary-model.ts index e80f4de15c..d3dbb7c41d 100644 --- a/packages/agent-core/src/config/secondary-model.ts +++ b/packages/agent-core/src/config/secondary-model.ts @@ -45,7 +45,10 @@ export function secondaryModelPatch( secondary: SecondaryModelConfig | undefined, ): ModelAliasOverrides | undefined { if (secondary === undefined) return undefined; - const { model: _model, ...patch } = secondary; + const { model: _model, ...rawPatch } = secondary; + const patch = Object.fromEntries( + Object.entries(rawPatch).filter(([, value]) => value !== undefined), + ) as ModelAliasOverrides; return Object.keys(patch).length > 0 ? patch : undefined; } @@ -63,8 +66,8 @@ export function applySecondaryModelConfig(config: KimiConfig, env: Env = process if (envModel !== undefined || envEffort !== undefined) { secondary = { ...secondary, - ...(envModel !== undefined ? { model: envModel } : {}), - ...(envEffort !== undefined ? { defaultEffort: envEffort } : {}), + model: envModel ?? secondary?.model, + defaultEffort: envEffort ?? secondary?.defaultEffort, }; } diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 46b97172a5..52a5c66dac 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -32,6 +32,7 @@ export type { SessionLogHandle, } from './logging/types'; export { USER_PROMPT_ORIGIN } from './agent/context'; +export { parseAgentFileText, resolveAgentPath } from './profile/agentfile'; export { renderToolResultForModel } from './agent/context/tool-result-render'; export type { RenderableToolResult } from './agent/context/tool-result-render'; export type { diff --git a/packages/agent-core/src/profile/agentfile/catalog.ts b/packages/agent-core/src/profile/agentfile/catalog.ts index 282f78dac6..2487715924 100644 --- a/packages/agent-core/src/profile/agentfile/catalog.ts +++ b/packages/agent-core/src/profile/agentfile/catalog.ts @@ -38,7 +38,7 @@ export interface SessionAgentCatalogOptions { readonly osHomeDir: string; readonly extraDirs?: readonly string[]; readonly explicitFiles?: readonly string[]; - readonly warn?: ((message: string, error?: unknown) => void) | undefined; + readonly warn?: (message: string, error?: unknown) => void; } const SOURCE_PRIORITY: Readonly> = { diff --git a/packages/agent-core/src/profile/resolve.ts b/packages/agent-core/src/profile/resolve.ts index 7615dd6ff4..3dc6b91085 100644 --- a/packages/agent-core/src/profile/resolve.ts +++ b/packages/agent-core/src/profile/resolve.ts @@ -16,7 +16,7 @@ interface MergedAgentProfile { readonly tools: string[]; readonly whenToUse?: string | undefined; readonly subagents?: Record | undefined; - readonly modelPreference?: AgentModelPreference | undefined; + readonly modelPreference?: AgentModelPreference; } /** diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index c8d9dbfcfb..b77a038f46 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -64,9 +64,9 @@ export interface CreateSessionPayload { readonly client?: ClientTelemetryInfo | undefined; readonly drainAgentTasksOnStop?: boolean; /** Main-agent profile name (`--agent`): a builtin or agentfile-defined profile. */ - readonly agentProfile?: string | undefined; + readonly agentProfile?: string; /** Explicit agentfiles (`--agent-file`); an invalid file fails session creation. */ - readonly agentFiles?: readonly string[] | undefined; + readonly agentFiles?: readonly string[]; } export interface CloseSessionPayload { @@ -85,6 +85,8 @@ export interface ResumeSessionPayload { readonly sessionId: string; readonly mcpServers?: Readonly>; readonly additionalDirs?: readonly string[]; + /** Re-select the session's already-bound main profile; a different name fails. */ + readonly agentProfile?: string; /** Include persisted subagent states in the returned replay snapshot. */ readonly includeSubagents?: boolean; /** diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 93a80cf26d..7a2830756a 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -491,6 +491,7 @@ export class KimiCore implements PromisableMethods { ]); const active = this.sessions.get(summary.id); if (active !== undefined) { + await active.assertMainProfileSelection(input.agentProfile); if (overrides.kaos !== undefined) { active.setToolKaos(overrides.kaos.withCwd(summary.workDir)); } @@ -553,6 +554,7 @@ export class KimiCore implements PromisableMethods { try { const resumeResult = await session.resume(); warning = resumeResult.warning; + await session.assertMainProfileSelection(input.agentProfile); await this.refreshSessionRuntimeConfig(session, config); } catch (error) { await session.close().catch(() => {}); diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index ba2d93d57a..23c95813a1 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -36,6 +36,7 @@ import { } from '../mcp'; import type { EnabledPluginSessionStart, PluginCommandDef } from '../plugin'; import { + DEFAULT_AGENT_PROFILE_NAME, DEFAULT_INIT_PROMPT, SessionAgentProfileCatalog, loadAgentsMd, @@ -443,6 +444,17 @@ export class Session { return { warning }; } + async assertMainProfileSelection(requestedProfileName: string | undefined): Promise { + if (requestedProfileName === undefined) return; + const main = await this.ensureAgentResumed('main'); + const currentProfileName = main.config.profileName ?? DEFAULT_AGENT_PROFILE_NAME; + if (currentProfileName === requestedProfileName) return; + throw new KimiError( + ErrorCodes.REQUEST_INVALID, + `agent is already bound to profile "${currentProfileName}"; cannot switch to "${requestedProfileName}" in this session`, + ); + } + async close(): Promise { try { await Promise.allSettled( @@ -740,7 +752,8 @@ export class Session { this.options.kimiHomeDir, { additionalDirs: this.additionalDirs }, ); - agent.useProfile(profile, context, this.options.kimiHomeDir); + const subagentNames = Object.keys(this.agentCatalog.delegatableSubagents(profile.name)); + agent.useProfile(profile, context, this.options.kimiHomeDir, subagentNames); const { agentsMdWarning } = context; if (agentsMdWarning !== undefined) { this.agentsMdWarning = agentsMdWarning; @@ -1041,6 +1054,8 @@ export class Session { const parentAgent = parentAgentId !== null ? this.getReadyAgent(parentAgentId) : undefined; const cwd = parentAgent?.config.cwd ?? this.toolKaos.getcwd(); let agent!: Agent; + const subagentHost = + config.subagentHost ?? new SessionSubagentHost(this, id, () => agent); agent = new Agent({ ...config, type, @@ -1055,7 +1070,7 @@ export class Session { rpc: proxyWithExtraPayload(this.rpc, { agentId: id }), modelProvider: this.options.providerManager, hookEngine: config.hookEngine ?? this.hookEngine, - subagentHost: config.subagentHost ?? new SessionSubagentHost(this, id), + subagentHost, mcp: this.mcp, permission: this.permissionOptions(parentAgentId, config.permission), telemetry: withTelemetryProperties(this.telemetry, { agent_id: id }), diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 5c2cb0b4f7..92e6d5d25e 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -139,6 +139,8 @@ type SubagentCompletion = { readonly usage?: TokenUsage; }; +type OwnerAgentResolver = () => Agent; + export type SubagentHandle = { readonly agentId: string; readonly profileName: string; @@ -158,6 +160,7 @@ export class SessionSubagentHost { constructor( private readonly session: Session, private readonly ownerAgentId: string, + private readonly getOwnerAgent?: OwnerAgentResolver, ) {} async spawn(options: SpawnSubagentOptions): Promise { @@ -319,9 +322,10 @@ export class SessionSubagentHost { } private resolveProfile(parent: Agent, profileName: string): ResolvedAgentProfile { - const profile = this.session.agentCatalog.delegatableSubagents(parent.config.profileName)[ - profileName - ]; + const profile = this.resolveDelegatableSubagents( + parent.config.profileName, + parent.config.subagentNames, + )[profileName]; if (profile === undefined) { throw new Error(`Subagent profile "${profileName}" was not found`); } @@ -334,7 +338,23 @@ export class SessionSubagentHost { * "Available agent types" description. */ delegatableSubagents(callerProfileName?: string): Record { - return this.session.agentCatalog.delegatableSubagents(callerProfileName); + const owner = this.getOwnerAgent?.() ?? this.session.getReadyAgent(this.ownerAgentId); + return this.resolveDelegatableSubagents(callerProfileName, owner?.config.subagentNames); + } + + private resolveDelegatableSubagents( + callerProfileName: string | undefined, + persistedNames: readonly string[] | undefined, + ): Record { + const catalogProfiles = this.session.agentCatalog.delegatableSubagents(callerProfileName); + if (persistedNames === undefined) return catalogProfiles; + + return Object.fromEntries( + persistedNames.flatMap((name) => { + const profile = catalogProfiles[name]; + return profile === undefined ? [] : [[name, profile]]; + }), + ); } private runWithActiveChild( @@ -426,9 +446,7 @@ export class SessionSubagentHost { child.config.update({ cwd: parent.config.cwd, modelAlias: binding.modelAlias, - ...(binding.thinkingEffort !== undefined - ? { thinkingEffort: binding.thinkingEffort } - : {}), + thinkingEffort: binding.thinkingEffort, }); const context = await prepareSystemPromptContext( @@ -436,7 +454,10 @@ export class SessionSubagentHost { this.session.options.kimiHomeDir, { additionalDirs: child.getAdditionalDirs() }, ); - child.useProfile(profile, context, this.session.options.kimiHomeDir); + const subagentNames = Object.keys( + this.session.agentCatalog.delegatableSubagents(profile.name), + ); + child.useProfile(profile, context, this.session.options.kimiHomeDir, subagentNames); child.tools.inheritUserTools(parent.tools); } diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.ts b/packages/agent-core/src/tools/builtin/collaboration/agent.ts index 5ce2309f29..e8f796ac4e 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.ts @@ -122,7 +122,7 @@ export class AgentTool implements BuiltinTool { log?: Logger; allowBackground?: boolean | undefined; subagentTimeoutMs?: number | undefined; - subagentModelDescription?: string | undefined; + subagentModelDescription?: string; }, ) { const log = options?.log; diff --git a/packages/agent-core/test/agent/tool.test.ts b/packages/agent-core/test/agent/tool.test.ts index fc9791bb9d..b3aa2193ed 100644 --- a/packages/agent-core/test/agent/tool.test.ts +++ b/packages/agent-core/test/agent/tool.test.ts @@ -260,6 +260,66 @@ describe('Agent tools', () => { expect(managedBash!.description).toContain('run_in_background=true'); }); + it('disables Bash background mode when an active task management tool is denied', async () => { + const ctx = testAgent(); + ctx.configure(); + ctx.agent.tools.setActiveTools( + ['Bash', 'TaskList', 'TaskOutput', 'TaskStop'], + ['TaskOutput'], + ); + + const bash = ctx.agent.tools.loopTools.find((tool) => tool.name === 'Bash'); + expect(bash).toBeDefined(); + expect(bash!.description).toContain('Background execution is disabled for this agent.'); + await expect( + executeTool(bash!, { + turnId: '0', + toolCallId: 'call_bash', + args: { command: 'sleep 10', run_in_background: true, description: 'watch' }, + signal, + }), + ).resolves.toMatchObject({ + isError: true, + output: + 'Background execution is not available for this agent because TaskOutput and TaskStop are not enabled.', + }); + }); + + it('disables Agent background mode when an active task management tool is denied', async () => { + const subagentHost = { + delegatableSubagents: vi.fn(() => ({})), + spawn: vi.fn(), + } as unknown as SessionSubagentHost; + const ctx = testAgent({ subagentHost }); + ctx.configure(); + ctx.agent.tools.setActiveTools( + ['Agent', 'TaskList', 'TaskOutput', 'TaskStop'], + ['TaskStop'], + ); + + const agent = ctx.agent.tools.loopTools.find((tool) => tool.name === 'Agent'); + expect(agent).toBeDefined(); + expect(agent!.description).toContain('Background agent execution is disabled for this agent.'); + await expect( + executeTool(agent!, { + turnId: '0', + toolCallId: 'call_agent', + args: { + prompt: 'Investigate deeply', + description: 'Investigate deeply', + subagent_type: 'coder', + run_in_background: true, + }, + signal, + }), + ).resolves.toMatchObject({ + isError: true, + output: + 'Background agent execution is not available for this agent because TaskList, TaskOutput, and TaskStop are not enabled.', + }); + expect(subagentHost.spawn).not.toHaveBeenCalled(); + }); + it('removes denied exact tool names from the active set', () => { const ctx = testAgent(); ctx.configure(); diff --git a/packages/agent-core/test/config/secondary-model.test.ts b/packages/agent-core/test/config/secondary-model.test.ts index ce291e888b..e8007b6841 100644 --- a/packages/agent-core/test/config/secondary-model.test.ts +++ b/packages/agent-core/test/config/secondary-model.test.ts @@ -45,6 +45,7 @@ describe('secondaryModelPatch', () => { expect(secondaryModelPatch(undefined)).toBeUndefined(); expect(secondaryModelPatch({})).toBeUndefined(); expect(secondaryModelPatch({ model: 'cheap' })).toBeUndefined(); + expect(secondaryModelPatch({ model: 'cheap', defaultEffort: undefined })).toBeUndefined(); }); it('returns every field except model as the patch', () => { diff --git a/packages/agent-core/test/profile/agentfile.test.ts b/packages/agent-core/test/profile/agentfile.test.ts index 6a793df511..7da3851a19 100644 --- a/packages/agent-core/test/profile/agentfile.test.ts +++ b/packages/agent-core/test/profile/agentfile.test.ts @@ -1,4 +1,11 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +/** + * Scenario: Markdown agent parsing, discovery, binding, and session resume. + * Responsibilities: file-defined profile identity and policies remain observable across lifecycle changes. + * Wiring: the real catalog/session/record store are used with isolated local filesystem fixtures. + * Run: pnpm -C packages/agent-core exec vitest run test/profile/agentfile.test.ts + */ + +import { mkdir, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'pathe'; @@ -617,4 +624,57 @@ describe('Session agentfile wiring', () => { await session.close().catch(() => {}); } }); + + it('keeps the bound delegation allowlist when its agent file is removed before resume', async () => { + const workDir = await makeTempDir(); + const sessionDir = await makeTempDir(); + const brandHome = await makeTempDir(); + const osHome = await makeTempDir(); + const agentDir = join(workDir, '.kimi-code', 'agents'); + const agentPath = join(agentDir, 'leader.md'); + await mkdir(agentDir, { recursive: true }); + await writeFile( + agentPath, + agentFileText( + { name: 'leader', description: 'Delegates read-only work.', subagents: ['explore'] }, + 'Delegate repository exploration only.', + ), + 'utf-8', + ); + + const options = { + id: 'test-agentfile-delegation-resume', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + kimiHomeDir: brandHome, + rpc: createSessionRpc(), + providerManager: testProviderManager(), + agents: { userHomeDir: osHome, profileName: 'leader' }, + }; + const created = new Session(options); + try { + await created.createMain(); + } finally { + await created.closeForReload(); + } + + await unlink(agentPath); + + const resumed = new Session({ + ...options, + agents: { userHomeDir: osHome }, + initializeMainAgent: false, + }); + try { + await resumed.resume(); + const main = await resumed.ensureAgentResumed('main'); + const delegatableNames = Object.keys( + main.subagentHost!.delegatableSubagents(main.config.profileName), + ); + + expect(delegatableNames).toEqual(['explore']); + } finally { + await resumed.close(); + } + }); }); diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 3c4a71465f..c6e3a592ad 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -1887,7 +1887,7 @@ function profile(input: { readonly systemPrompt: string; readonly description?: string | undefined; readonly subagents?: Record | undefined; - readonly modelPreference?: 'primary' | 'secondary' | undefined; + readonly modelPreference?: 'primary' | 'secondary'; }): ResolvedAgentProfile { return { name: input.name, diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index cab37997d5..3250b45758 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -68,6 +68,7 @@ export type { LogContext, LogLevel, LogPayload, Logger } from '@moonshot-ai/agen // config without spinning up a full KimiCore. export { effectiveModelAlias, loadRuntimeConfigSafe, resolveConfigPath } from '@moonshot-ai/agent-core'; export { limitAgentReplayByTurns } from '@moonshot-ai/agent-core'; +export { parseAgentFileText, resolveAgentPath } from '@moonshot-ai/agent-core'; // Process-wide HTTP proxy bootstrap — installed once at CLI startup so all // outbound fetch honors HTTP_PROXY / HTTPS_PROXY / NO_PROXY. diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 653933c783..a32ee5c103 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -140,6 +140,8 @@ export class KimiHarness { if (active !== undefined) { if (kaos !== undefined || persistenceKaos !== undefined) { await this.rpc.resumeSessionWithKaos({ ...resumeInput, id }, kaos ?? persistenceKaos as Kaos, persistenceKaos); + } else if (input.agentProfile !== undefined) { + await this.rpc.resumeSession({ ...resumeInput, id }); } return active; } diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 3feb97c887..d65e5d30c6 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -111,12 +111,12 @@ export interface CreateSessionOptions { * Main-agent profile name (`--agent`): a builtin profile or one defined by * an agentfile discovered from the user/project agent directories. */ - readonly agentProfile?: string | undefined; + readonly agentProfile?: string; /** * Explicit agentfiles (`--agent-file`) loaded for this session with the * highest precedence; an invalid file fails session creation. */ - readonly agentFiles?: readonly string[] | undefined; + readonly agentFiles?: readonly string[]; readonly sessionStartedProperties?: TelemetryProperties; /** * Print-mode (`kimi -p`) only: when the main agent ends a turn while @@ -138,6 +138,8 @@ export interface ResumeSessionInput { readonly kaos?: Kaos | undefined; readonly persistenceKaos?: Kaos | undefined; readonly additionalDirs?: readonly string[]; + /** Re-select the session's already-bound main profile; a different name fails. */ + readonly agentProfile?: string; /** Include persisted subagent states in the returned replay snapshot. */ readonly includeSubagents?: boolean; /** diff --git a/packages/node-sdk/test/create-session-transport.test.ts b/packages/node-sdk/test/create-session-transport.test.ts index 491b19f254..bdedb1ffea 100644 --- a/packages/node-sdk/test/create-session-transport.test.ts +++ b/packages/node-sdk/test/create-session-transport.test.ts @@ -1,5 +1,12 @@ +/** + * Scenario: KimiHarness session creation and resume transport behavior. + * Responsibilities: SDK options reach the in-process core and session identity remains stable. + * Wiring: the real SDK/core are used; model/network boundaries are configured but never called. + * Run: pnpm -C packages/node-sdk exec vitest run test/create-session-transport.test.ts + */ + import { existsSync } from 'node:fs'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -51,6 +58,16 @@ max_context_size = 1000 ); } +async function writeReviewerAgent(workDir: string): Promise { + const agentDir = join(workDir, '.kimi-code', 'agents'); + await mkdir(agentDir, { recursive: true }); + await writeFile( + join(agentDir, 'reviewer.md'), + '---\nname: reviewer\ndescription: Reviews code.\nsubagents:\n - explore\n---\n\nReview the requested change.\n', + 'utf-8', + ); +} + class StubRpc extends SDKRpcClientBase { resumeCalls: Array<{ input: ResumeSessionInput; kaos: Kaos; persistenceKaos?: Kaos }> = []; @@ -772,6 +789,77 @@ effort = "medium" persistenceKaos: undefined, }); }); + + it('rejects an active session resume when the requested profile differs from its binding', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + await writeTestModelConfig(homeDir); + await writeReviewerAgent(workDir); + const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); + + try { + const session = await harness.createSession({ + id: 'ses_active_profile_identity', + workDir, + agentProfile: 'reviewer', + }); + + await expect( + harness.resumeSession({ id: session.id, agentProfile: 'agent' }), + ).rejects.toThrow( + 'agent is already bound to profile "reviewer"; cannot switch to "agent" in this session', + ); + } finally { + await harness.close(); + } + }); + + it('returns the active session when the requested profile matches its binding', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + await writeTestModelConfig(homeDir); + await writeReviewerAgent(workDir); + const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); + + try { + const session = await harness.createSession({ + id: 'ses_matching_profile_identity', + workDir, + agentProfile: 'reviewer', + }); + + await expect( + harness.resumeSession({ id: session.id, agentProfile: 'reviewer' }), + ).resolves.toBe(session); + } finally { + await harness.close(); + } + }); + + it('rejects a persisted session resume when the requested profile differs from its binding', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + await writeTestModelConfig(homeDir); + await writeReviewerAgent(workDir); + const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); + + try { + const session = await harness.createSession({ + id: 'ses_persisted_profile_identity', + workDir, + agentProfile: 'reviewer', + }); + await session.close(); + + await expect( + harness.resumeSession({ id: session.id, agentProfile: 'agent' }), + ).rejects.toThrow( + 'agent is already bound to profile "reviewer"; cannot switch to "agent" in this session', + ); + } finally { + await harness.close(); + } + }); }); function coreSessionIds(harness: KimiHarness): readonly string[] { From ab4740c8c1f23ae6d25295af4ee744da51d1c4c4 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 27 Jul 2026 15:55:33 +0800 Subject: [PATCH 04/30] fix(agent-core): narrow secondary model error hints --- .../src/session/provider-manager.ts | 1 + .../src/session/subagent-binding.ts | 3 ++ .../test/session/subagent-host.test.ts | 54 +++++++++++++++---- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index e8028c76c1..4e196922f9 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -94,6 +94,7 @@ export class ProviderManager implements ModelProvider { throw new KimiError( ErrorCodes.CONFIG_INVALID, `Model "${model}" is not configured in config.toml. Add a [models."${model}"] entry with max_context_size.`, + { details: { model } }, ); } diff --git a/packages/agent-core/src/session/subagent-binding.ts b/packages/agent-core/src/session/subagent-binding.ts index 78e3d998b6..430f36c521 100644 --- a/packages/agent-core/src/session/subagent-binding.ts +++ b/packages/agent-core/src/session/subagent-binding.ts @@ -97,6 +97,9 @@ export function wrapSubagentModelError( ): unknown { if (boundModel === callerModelAlias) return error; if (!(error instanceof KimiError) || error.code !== ErrorCodes.CONFIG_INVALID) return error; + // ProviderManager tags only the missing-alias failure with details.model; + // malformed aliases and providers must keep their own actionable errors. + if (error.details?.['model'] !== boundModel) return error; const displayModel = boundModel === SECONDARY_DERIVED_MODEL_ALIAS ? `the derived entry "${SECONDARY_DERIVED_MODEL_ALIAS}"` diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index c6e3a592ad..6c47bdc5b3 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -15,6 +15,7 @@ import { SessionAgentProfileCatalog, type ResolvedAgentProfile } from '../../src import type { SDKSessionRPC } from '../../src/rpc'; import { Session } from '../../src/session'; import { collectGitContext } from '../../src/session/git-context'; +import { ProviderManager } from '../../src/session/provider-manager'; import { DEFAULT_SUBAGENT_TIMEOUT_MS, SessionSubagentHost, @@ -1305,18 +1306,14 @@ describe('SessionSubagentHost', () => { parent.configure(); const child = testAgent(); child.configure({ tools: ['Read'] }); - const providerManager = { - resolveProviderConfig: vi.fn(() => { - throw new KimiError( - ErrorCodes.CONFIG_INVALID, - 'Model "missing-model" is not configured in config.toml.', - ); - }), - } as unknown as Session['options']['providerManager']; + const config: KimiConfig = { + providers: {}, + secondaryModel: { model: 'missing-model' }, + }; const session = fakeSession(parent.agent, child.agent, {}, { experimentalFlags: secondaryFlags(), - config: { providers: {}, secondaryModel: { model: 'missing-model' } }, - providerManager, + config, + providerManager: new ProviderManager({ config }), }); const host = new SessionSubagentHost(session, 'main'); @@ -1332,6 +1329,43 @@ describe('SessionSubagentHost', () => { ).rejects.toThrow(/\[secondary_model\]\.model/); }); + it('preserves a provider configuration error when the secondary alias exists', async () => { + const parent = testAgent(); + parent.configure(); + const child = testAgent(); + child.configure({ tools: ['Read'] }); + const config: KimiConfig = { + providers: {}, + models: { + 'cheap-model': { + provider: 'missing-provider', + model: 'cheap-model', + maxContextSize: 1_000_000, + }, + }, + secondaryModel: { model: 'cheap-model' }, + }; + const session = fakeSession(parent.agent, child.agent, {}, { + experimentalFlags: secondaryFlags(), + config, + providerManager: new ProviderManager({ config }), + }); + const host = new SessionSubagentHost(session, 'main'); + + await expect( + host.spawn({ + profileName: 'coder', + parentToolCallId: 'call_agent', + prompt: 'Do work', + description: 'Do work', + runInBackground: false, + signal, + }).then((handle) => handle.completion), + ).rejects.toMatchObject({ + message: 'Provider "missing-provider" for model "cheap-model" is not configured.', + }); + }); + it('keeps the spawned model on resume when the experiment is on', async () => { const parent = testAgent(); parent.configure(); From 1d20929e66bb685a8e4ab7518fd2c59dc3d98ff5 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 27 Jul 2026 16:12:40 +0800 Subject: [PATCH 05/30] fix(agent-core): persist custom agent profile bindings --- .../src/profile/agentfile/catalog.ts | 93 ++++++++++++++++++- .../agent-core/src/profile/agentfile/types.ts | 26 ++++++ packages/agent-core/src/session/index.ts | 33 ++++++- .../agent-core/test/profile/agentfile.test.ts | 39 +++++--- 4 files changed, 174 insertions(+), 17 deletions(-) diff --git a/packages/agent-core/src/profile/agentfile/catalog.ts b/packages/agent-core/src/profile/agentfile/catalog.ts index 2487715924..683af40e3d 100644 --- a/packages/agent-core/src/profile/agentfile/catalog.ts +++ b/packages/agent-core/src/profile/agentfile/catalog.ts @@ -26,7 +26,12 @@ import { resolveAgentPath } from './paths'; import { configuredAgentRoots, projectAgentRoots, userAgentRoots } from './roots'; import { loadSystemMdDefinition, systemMdProfile } from './system-file'; import { describeInactiveToolPattern, findInactiveToolPatterns } from './validate'; -import type { AgentFileDefinition, AgentFileSource } from './types'; +import { + AgentProfileCatalogSnapshotSchema, + type AgentFileDefinition, + type AgentFileSource, + type AgentProfileCatalogSnapshot, +} from './types'; import { promises as fs } from 'node:fs'; import { parseAgentFileText } from './parser'; @@ -71,6 +76,7 @@ interface FileProfileEntry { export class SessionAgentProfileCatalog { private merged: Map; private readonly readyPromise: Promise; + private snapshotValue: AgentProfileCatalogSnapshot | undefined; constructor(private readonly options: SessionAgentCatalogOptions) { this.merged = new Map(Object.entries(DEFAULT_AGENT_PROFILES)); @@ -102,6 +108,49 @@ export class SessionAgentProfileCatalog { return [...this.merged.values()]; } + snapshot(): AgentProfileCatalogSnapshot | undefined { + return this.snapshotValue === undefined + ? undefined + : AgentProfileCatalogSnapshotSchema.parse(this.snapshotValue); + } + + /** Replace live discovery with the file-backed catalog bound at creation. */ + restoreSnapshot(snapshot: AgentProfileCatalogSnapshot): void { + const restored = AgentProfileCatalogSnapshotSchema.parse(snapshot); + this.merged = new Map(Object.entries(DEFAULT_AGENT_PROFILES)); + + const builtinDefault = this.getDefault(); + const systemMd = + restored.systemPromptTemplate === undefined + ? undefined + : this.snapshotSystemDefinition(restored.systemPromptTemplate); + const effectiveDefault = + systemMd === undefined ? builtinDefault : systemMdProfile(systemMd, builtinDefault); + const entries: FileProfileEntry[] = []; + if (systemMd !== undefined) { + entries.push(this.systemMdEntry(systemMd, effectiveDefault)); + } + for (const profile of restored.profiles) { + const definition: AgentFileDefinition = { + name: profile.name, + description: profile.description, + whenToUse: profile.whenToUse, + override: true, + tools: profile.tools, + disallowedTools: profile.disallowedTools, + subagents: profile.subagents, + modelPreference: profile.modelPreference, + prompt: profile.prompt, + path: ``, + source: 'explicit', + }; + entries.push(this.entryFromDefinition(definition, effectiveDefault)); + } + + this.applyFileEntries(entries); + this.snapshotValue = restored; + } + /** * The subagent types `callerProfileName` may delegate to: the caller's own * linked set, falling back to the default profile's set when the caller @@ -171,7 +220,8 @@ export class SessionAgentProfileCatalog { entries.push(this.entryFromDefinition(definition, effectiveDefault)); } - this.applyFileEntries(entries); + const winners = this.applyFileEntries(entries); + this.snapshotValue = this.snapshotFromEntries(winners, systemMd); } /** @@ -221,7 +271,7 @@ export class SessionAgentProfileCatalog { }; } - private applyFileEntries(entries: readonly FileProfileEntry[]): void { + private applyFileEntries(entries: readonly FileProfileEntry[]): readonly FileProfileEntry[] { const warn = this.warn; const merged = new Map(this.merged); const byName = new Map(); @@ -267,6 +317,43 @@ export class SessionAgentProfileCatalog { } this.merged = merged; + return winners; + } + + private snapshotFromEntries( + winners: readonly FileProfileEntry[], + systemMd: AgentFileDefinition | undefined, + ): AgentProfileCatalogSnapshot | undefined { + const profiles = winners + .filter((winner) => winner.definition !== systemMd) + .map(({ definition, profile }) => ({ + name: profile.name, + description: profile.description ?? definition.description, + whenToUse: profile.whenToUse, + tools: [...profile.tools], + disallowedTools: + profile.disallowedTools === undefined ? undefined : [...profile.disallowedTools], + subagents: Object.keys(profile.subagents ?? {}), + modelPreference: profile.modelPreference, + prompt: definition.prompt, + })); + if (systemMd === undefined && profiles.length === 0) return undefined; + return AgentProfileCatalogSnapshotSchema.parse({ + version: 1, + systemPromptTemplate: systemMd?.prompt, + profiles, + }); + } + + private snapshotSystemDefinition(prompt: string): AgentFileDefinition { + return { + name: DEFAULT_AGENT_PROFILE_NAME, + description: '', + override: true, + prompt, + path: '', + source: 'user', + }; } private linkSubagentAllowlist( diff --git a/packages/agent-core/src/profile/agentfile/types.ts b/packages/agent-core/src/profile/agentfile/types.ts index 6a3bc3cdda..70c9db03b2 100644 --- a/packages/agent-core/src/profile/agentfile/types.ts +++ b/packages/agent-core/src/profile/agentfile/types.ts @@ -6,6 +6,7 @@ */ import type { AgentModelPreference } from '../types'; +import { z } from 'zod'; export type AgentFileSource = 'project' | 'user' | 'extra' | 'explicit'; @@ -38,3 +39,28 @@ export interface AgentFileDiscoveryResult { readonly skipped: readonly SkippedAgentFile[]; readonly scannedRoots: readonly string[]; } + +const AgentProfileSnapshotSchema = z.object({ + name: z.string().min(1), + description: z.string(), + whenToUse: z.string().optional(), + tools: z.array(z.string()), + disallowedTools: z.array(z.string()).optional(), + subagents: z.array(z.string()), + modelPreference: z.enum(['primary', 'secondary']).optional(), + prompt: z.string(), +}); + +/** + * Normalized file-backed catalog state bound to a session. Unlike source + * paths, this is sufficient to rebuild custom profiles after their files are + * removed or changed: tool defaults and wildcard subagent sets are already + * resolved to concrete lists. + */ +export const AgentProfileCatalogSnapshotSchema = z.object({ + version: z.literal(1), + systemPromptTemplate: z.string().optional(), + profiles: z.array(AgentProfileSnapshotSchema), +}); + +export type AgentProfileCatalogSnapshot = z.infer; diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 23c95813a1..64076e81f0 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -36,11 +36,13 @@ import { } from '../mcp'; import type { EnabledPluginSessionStart, PluginCommandDef } from '../plugin'; import { + AgentProfileCatalogSnapshotSchema, DEFAULT_AGENT_PROFILE_NAME, DEFAULT_INIT_PROMPT, SessionAgentProfileCatalog, loadAgentsMd, prepareSystemPromptContext, + type AgentProfileCatalogSnapshot, type ResolvedAgentProfile, } from '../profile'; import type { ProviderManager } from './provider-manager'; @@ -166,6 +168,11 @@ export interface SessionMeta { custom: Record; } +interface PersistedSessionState extends SessionMeta { + /** Internal catalog binding; deliberately excluded from public SessionMeta. */ + readonly agentProfileCatalog?: unknown; +} + const BACKGROUND_KEEP_ALIVE_ON_EXIT_ENV = 'KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT'; const ACTIVE_TURN_CLOSE_TIMEOUT_MS = 8_000; @@ -222,6 +229,7 @@ export class Session { custom: {}, }; private writeMetadataPromise = Promise.resolve(); + private agentProfileSnapshot: AgentProfileCatalogSnapshot | undefined; private agentsMdWarning: string | undefined; private printSteerDeadline: number | undefined; private printSteerTurns = 0; @@ -382,6 +390,7 @@ export class Session { // profile so a fatal agentfile source surfaces here, and so `--agent` // sees file-defined profiles. await this.skillsReady; + this.agentProfileSnapshot = this.agentCatalog.snapshot(); const profileName = this.options.agents?.profileName; const profile = profileName === undefined @@ -939,7 +948,14 @@ export class Session { } writeMetadata() { - const text = JSON.stringify(this.metadata, null, 2); + const text = JSON.stringify( + { + ...this.metadata, + agentProfileCatalog: this.agentProfileSnapshot, + }, + null, + 2, + ); const write = async () => { await this.persistenceKaos.mkdir(this.options.homedir, { parents: true, existOk: true }); await this.persistenceKaos.writeText(this.metadataPath, text); @@ -950,7 +966,20 @@ export class Session { async readMetadata() { const text = await this.persistenceKaos.readText(this.metadataPath); - this.metadata = JSON.parse(text); + const persisted = JSON.parse(text) as PersistedSessionState; + const { agentProfileCatalog, ...metadata } = persisted; + this.metadata = metadata; + if (agentProfileCatalog !== undefined) { + const parsed = AgentProfileCatalogSnapshotSchema.safeParse(agentProfileCatalog); + if (parsed.success) { + this.agentProfileSnapshot = parsed.data; + this.agentCatalog.restoreSnapshot(parsed.data); + } else { + this.log.warn('stored agent profile catalog is invalid; using discovered profiles', { + error: parsed.error.message, + }); + } + } return this.metadata; } diff --git a/packages/agent-core/test/profile/agentfile.test.ts b/packages/agent-core/test/profile/agentfile.test.ts index 7da3851a19..419c05af07 100644 --- a/packages/agent-core/test/profile/agentfile.test.ts +++ b/packages/agent-core/test/profile/agentfile.test.ts @@ -625,19 +625,28 @@ describe('Session agentfile wiring', () => { } }); - it('keeps the bound delegation allowlist when its agent file is removed before resume', async () => { + it('restores a bound custom subagent when its agent files are removed before resume', async () => { const workDir = await makeTempDir(); const sessionDir = await makeTempDir(); const brandHome = await makeTempDir(); const osHome = await makeTempDir(); - const agentDir = join(workDir, '.kimi-code', 'agents'); - const agentPath = join(agentDir, 'leader.md'); + const agentDir = await makeTempDir(); + const leaderPath = join(agentDir, 'leader.md'); + const workerPath = join(agentDir, 'worker.md'); await mkdir(agentDir, { recursive: true }); await writeFile( - agentPath, + leaderPath, agentFileText( - { name: 'leader', description: 'Delegates read-only work.', subagents: ['explore'] }, - 'Delegate repository exploration only.', + { name: 'leader', description: 'Delegates focused work.', subagents: ['worker'] }, + 'Delegate repository work only.', + ), + 'utf-8', + ); + await writeFile( + workerPath, + agentFileText( + { name: 'worker', description: 'Performs focused work.', tools: ['Read'] }, + 'Perform the delegated work.', ), 'utf-8', ); @@ -649,7 +658,11 @@ describe('Session agentfile wiring', () => { kimiHomeDir: brandHome, rpc: createSessionRpc(), providerManager: testProviderManager(), - agents: { userHomeDir: osHome, profileName: 'leader' }, + agents: { + userHomeDir: osHome, + profileName: 'leader', + explicitFiles: [leaderPath, workerPath], + }, }; const created = new Session(options); try { @@ -658,7 +671,7 @@ describe('Session agentfile wiring', () => { await created.closeForReload(); } - await unlink(agentPath); + await Promise.all([unlink(leaderPath), unlink(workerPath)]); const resumed = new Session({ ...options, @@ -668,11 +681,13 @@ describe('Session agentfile wiring', () => { try { await resumed.resume(); const main = await resumed.ensureAgentResumed('main'); - const delegatableNames = Object.keys( - main.subagentHost!.delegatableSubagents(main.config.profileName), - ); + const worker = main.subagentHost!.delegatableSubagents(main.config.profileName)['worker']; - expect(delegatableNames).toEqual(['explore']); + expect(worker).toMatchObject({ + name: 'worker', + description: 'Performs focused work.', + tools: ['Read'], + }); } finally { await resumed.close(); } From d760a7d6a4a34a68cd450f150b0ca575a19a1e99 Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Mon, 27 Jul 2026 17:06:53 +0800 Subject: [PATCH 06/30] Delete .changeset/sdk-agent-profile-options.md Signed-off-by: 7Sageer --- .changeset/sdk-agent-profile-options.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/sdk-agent-profile-options.md diff --git a/.changeset/sdk-agent-profile-options.md b/.changeset/sdk-agent-profile-options.md deleted file mode 100644 index 99b7e63f68..0000000000 --- a/.changeset/sdk-agent-profile-options.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code-sdk": patch ---- - -Add SDK session profile selection for creation and validate the bound profile when resuming a session. From e05779d631ed7af0ecaa1ec86df9390ebfbe9285 Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Mon, 27 Jul 2026 17:07:35 +0800 Subject: [PATCH 07/30] Update v1-custom-agent-files.md Signed-off-by: 7Sageer --- .changeset/v1-custom-agent-files.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/v1-custom-agent-files.md b/.changeset/v1-custom-agent-files.md index 680950583a..64abf191fd 100644 --- a/.changeset/v1-custom-agent-files.md +++ b/.changeset/v1-custom-agent-files.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -Support Markdown-defined custom agents on the default engine: agents discovered from user (`~/.kimi-code/agents/`) and project (`.kimi-code/agents/`) directories can be delegated to as sub-agents, `$KIMI_CODE_HOME/SYSTEM.md` overrides the default main agent's system prompt, and `kimi -p --agent/--agent-file` select the main agent without the experimental flag. Add a file like `~/.kimi-code/agents/reviewer.md` to define your own agent. +Support Markdown-defined custom agents on tui. From e6fbd0dab8e83576f97ea1afdda8ce8cfe81e224 Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Mon, 27 Jul 2026 17:08:00 +0800 Subject: [PATCH 08/30] Update v1-secondary-model.md Signed-off-by: 7Sageer --- .changeset/v1-secondary-model.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/v1-secondary-model.md b/.changeset/v1-secondary-model.md index f4b11d3303..6dbb6cae24 100644 --- a/.changeset/v1-secondary-model.md +++ b/.changeset/v1-secondary-model.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Support a secondary model for subagents on the default engine (experimental): with `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1` and a `[secondary_model]` section in `config.toml`, newly spawned subagents bind the configured secondary model by default, and the `Agent`/`AgentSwarm` tools can pick `primary` or `secondary` per spawn. +Support a secondary model for subagents on agent-core. From 943efdaf6bc51f6b4fdeffcd566f508a1bbad3ef Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Mon, 27 Jul 2026 17:08:16 +0800 Subject: [PATCH 09/30] Update v1-custom-agent-files.md Signed-off-by: 7Sageer --- .changeset/v1-custom-agent-files.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/v1-custom-agent-files.md b/.changeset/v1-custom-agent-files.md index 64abf191fd..94b79e89ab 100644 --- a/.changeset/v1-custom-agent-files.md +++ b/.changeset/v1-custom-agent-files.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -Support Markdown-defined custom agents on tui. +Support Markdown-defined custom agents on agent-core. From e297ee734fe69f965f52e15dd34fdee5b29aeda1 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 27 Jul 2026 17:15:33 +0800 Subject: [PATCH 10/30] fix(agent-core): keep SYSTEM.md a prompt-only overlay for delegation --- .../src/profile/agentfile/catalog.ts | 30 ++++++++++----- .../src/profile/agentfile/system-file.ts | 8 ++-- .../agent-core/test/profile/agentfile.test.ts | 38 ++++++++++++++++++- 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/packages/agent-core/src/profile/agentfile/catalog.ts b/packages/agent-core/src/profile/agentfile/catalog.ts index 683af40e3d..ed39e8e672 100644 --- a/packages/agent-core/src/profile/agentfile/catalog.ts +++ b/packages/agent-core/src/profile/agentfile/catalog.ts @@ -67,6 +67,7 @@ function isKnownBuiltinToolName(name: string): boolean { } interface FileProfileEntry { + readonly kind: 'file' | 'system-prompt'; readonly definition: AgentFileDefinition; readonly profile: ResolvedAgentProfile; readonly priority: number; @@ -249,6 +250,7 @@ export class SessionAgentProfileCatalog { effectiveDefault: ResolvedAgentProfile, ): FileProfileEntry { return { + kind: 'system-prompt', definition, profile: effectiveDefault, priority: SOURCE_PRIORITY['user'], @@ -262,6 +264,7 @@ export class SessionAgentProfileCatalog { effectiveDefault: ResolvedAgentProfile, ): FileProfileEntry { return { + kind: 'file', definition, profile: agentProfileFromFile(definition, effectiveDefault.tools, (context) => effectiveDefault.systemPrompt(context), @@ -295,24 +298,31 @@ export class SessionAgentProfileCatalog { } } - // Link the file profiles' delegation allowlists against the merged set. + // Link regular file profiles' delegation allowlists against the merged + // set. SYSTEM.md is only a prompt overlay: treating its missing + // frontmatter as an unrestricted allowlist would let `agent` delegate to + // itself instead of preserving the builtin delegation policy. for (const winner of winners) { + if (winner.kind === 'system-prompt') continue; winner.profile.subagents = this.linkSubagentAllowlist(winner.definition, merged, warn); } - // Extend the builtin default's delegation set with every file-defined - // profile so the main agent can delegate to custom agents. (A file - // profile that replaced the default carries its own allowlist instead.) - const defaultIsBuiltin = !winners.some( + // Extend the builtin default — or its SYSTEM.md prompt-overlay variant — + // with every regular file profile. A real agent file that replaced the + // default carries its own allowlist instead. + const defaultWinner = winners.find( (winner) => winner.definition.name === DEFAULT_AGENT_PROFILE_NAME, ); - if (defaultIsBuiltin && winners.length > 0) { - const builtinDefault = this.getDefault(); + const defaultKeepsBuiltinDelegation = + defaultWinner === undefined || defaultWinner.kind === 'system-prompt'; + const fileWinners = winners.filter((winner) => winner.kind === 'file'); + if (defaultKeepsBuiltinDelegation && fileWinners.length > 0) { + const defaultProfile = merged.get(DEFAULT_AGENT_PROFILE_NAME) ?? this.getDefault(); const fileRecord: Record = {}; - for (const winner of winners) fileRecord[winner.definition.name] = winner.profile; + for (const winner of fileWinners) fileRecord[winner.definition.name] = winner.profile; merged.set(DEFAULT_AGENT_PROFILE_NAME, { - ...builtinDefault, - subagents: { ...builtinDefault.subagents, ...fileRecord }, + ...defaultProfile, + subagents: { ...defaultProfile.subagents, ...fileRecord }, }); } diff --git a/packages/agent-core/src/profile/agentfile/system-file.ts b/packages/agent-core/src/profile/agentfile/system-file.ts index 1f0033d697..060f6255b1 100644 --- a/packages/agent-core/src/profile/agentfile/system-file.ts +++ b/packages/agent-core/src/profile/agentfile/system-file.ts @@ -4,8 +4,8 @@ * `/SYSTEM.md` (default `~/.kimi-code/SYSTEM.md`, moves with * `KIMI_CODE_HOME`) permanently replaces the builtin default profile's system * prompt while the file exists and is non-empty. Only the prompt is replaced - * — tools and description are copied from the builtin default — and explicit - * intent still wins: higher-priority sources (project `agent.md`, + * — every other profile capability comes from the builtin default — and + * explicit intent still wins: higher-priority sources (project `agent.md`, * `--agent-file`) override it, and binding a different profile ignores it. * The body is a prompt template rendered against the agent-file variable * table: `${var}` placeholders substitute live context, and `${base_prompt}` @@ -56,7 +56,7 @@ export async function loadSystemMdDefinition( /** * Builds the SYSTEM.md profile variant: the builtin default with its system - * prompt replaced by the file body. Description and tools come from the + * prompt replaced by the file body. Every other capability comes from the * builtin default. */ export function systemMdProfile( @@ -77,6 +77,8 @@ export function systemMdProfile( ? undefined : [...builtinDefault.disallowedTools], whenToUse: builtinDefault.whenToUse, + subagents: + builtinDefault.subagents === undefined ? undefined : { ...builtinDefault.subagents }, modelPreference: builtinDefault.modelPreference, }; } diff --git a/packages/agent-core/test/profile/agentfile.test.ts b/packages/agent-core/test/profile/agentfile.test.ts index 419c05af07..50cf2f4cf4 100644 --- a/packages/agent-core/test/profile/agentfile.test.ts +++ b/packages/agent-core/test/profile/agentfile.test.ts @@ -343,8 +343,44 @@ describe('SessionAgentProfileCatalog', () => { const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); await c.ready; expect(c.getDefault().systemPrompt(promptContext)).toBe('Custom system for Linux.'); - // Only the prompt changed; tools and description stay builtin. + // Only the prompt changed; capabilities and description stay builtin. expect(c.getDefault().tools).toEqual(DEFAULT_AGENT_PROFILES['agent']!.tools); + expect(Object.keys(c.delegatableSubagents('agent'))).toEqual( + Object.keys(DEFAULT_AGENT_PROFILES['agent']!.subagents ?? {}), + ); + expect(c.delegatableSubagents('agent')).not.toHaveProperty('agent'); + }); + + it('extends SYSTEM.md delegation with custom agents without allowing self-delegation', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeFile(join(brandHome, 'SYSTEM.md'), 'Custom system.', 'utf-8'); + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'reviewer.md', + agentFileText({ description: 'Reviews code.' }), + ); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await c.ready; + const expectedNames = [ + ...Object.keys(DEFAULT_AGENT_PROFILES['agent']!.subagents ?? {}), + 'reviewer', + ]; + expect(Object.keys(c.delegatableSubagents('agent'))).toEqual(expectedNames); + expect(c.delegatableSubagents('agent')).not.toHaveProperty('agent'); + + const snapshot = c.snapshot(); + expect(snapshot).toBeDefined(); + const restoredLayout = await makeLayout(); + const restored = catalog({ + workDir: restoredLayout.workDir, + brandHomeDir: restoredLayout.brandHome, + osHomeDir: restoredLayout.osHome, + }); + await restored.ready; + restored.restoreSnapshot(snapshot!); + expect(Object.keys(restored.delegatableSubagents('agent'))).toEqual(expectedNames); + expect(restored.delegatableSubagents('agent')).not.toHaveProperty('agent'); }); it('lets a project agent.md win over SYSTEM.md', async () => { From 47f2fb3aeb4b933504d39d55669354f97068fea1 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 27 Jul 2026 17:15:33 +0800 Subject: [PATCH 11/30] docs: update agent file and secondary model availability wording --- docs/en/configuration/config-files.md | 2 +- docs/en/configuration/env-vars.md | 6 +++--- docs/en/customization/agents.md | 6 +++--- docs/en/reference/kimi-command.md | 6 +++--- docs/en/reference/tools.md | 4 ++-- docs/zh/configuration/config-files.md | 2 +- docs/zh/configuration/env-vars.md | 6 +++--- docs/zh/customization/agents.md | 6 +++--- docs/zh/reference/kimi-command.md | 6 +++--- docs/zh/reference/tools.md | 4 ++-- 10 files changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 6e08c3080b..8871d98c39 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -192,7 +192,7 @@ You can also switch models temporarily without touching the config file — by s 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. Enable it with `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`, or the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` (which `kimi -p` already requires to select the v2 engine). It takes effect on every engine, including the interactive TUI. +This feature is experimental and disabled by default. Enable it with `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`, or the master `KIMI_CODE_EXPERIMENTAL_FLAG=1`. It takes effect in every launch mode, including the interactive TUI. | Field | Type | Default | Description | | --- | --- | --- | --- | diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 1457746245..8cf72c632f 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -128,9 +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_CODE_EXPERIMENTAL_SECONDARY_MODEL` | Enable the experimental secondary-model feature in every launch mode, including the interactive TUI; the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | +| `KIMI_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 | 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 | 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 2ab6e3a297..dab777eb44 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -109,7 +109,7 @@ The body is the agent's system prompt, and it is rendered as a template each tim Unknown fields are ignored, so newer files stay readable by older versions. Fields from other agent tools (such as Claude Code's `model` or OpenCode's `mode`) are ignored the same way, the comma-separated `tools` form keeps Claude Code-style agent files loadable, and a missing `name` falls back to the file name so OpenCode-style files load too — a minimal file with `description` and a body works across tools. -`model_preference` applies only to newly spawned subagents when the secondary-model experiment is enabled — set `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`, or the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` (which `kimi -p` already requires to select the v2 engine). It takes effect on every engine, including the interactive TUI. The field never names a concrete model alias, and resumed subagents keep their existing model. The selected preference is shown to the main agent alongside the profile description so it can still pass an explicit `model` when a task needs a different choice. +`model_preference` applies only to newly spawned subagents when the secondary-model experiment is enabled — set `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`, or the master `KIMI_CODE_EXPERIMENTAL_FLAG=1`. It takes effect in every launch mode, including the interactive TUI. The field 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. @@ -121,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 are available in print mode (`kimi -p`), on either engine**; the interactive TUI rejects them with a clear error for now: +Two CLI flags select which agent drives the session. **Both are available in print mode (`kimi -p`)**; 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`. @@ -138,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. SYSTEM.md takes effect on every engine, including interactive TUI sessions. +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, tool set, and sub-agent delegation allowlist are inherited from the built-in defaults. SYSTEM.md takes effect in every launch mode, including interactive TUI sessions. 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 1ba50409a9..1c8ef32f60 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 (`kimi -p` only, on either engine) | -| `--agent-file ` | | Load a custom agent from a Markdown file for this launch and select it (`kimi -p` only, on either engine). Cannot be repeated or combined with `--agent` | +| `--agent ` | | Start the session with the specified agent as the main Agent (`kimi -p` only) | +| `--agent-file ` | | Load a custom agent from a Markdown file for this launch and select it (`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 are available under `kimi -p` on either engine; interactive launches reject them with a clear error: +`--agent` and `--agent-file` select which agent drives the session. Both are available under `kimi -p`; interactive launches reject them with a clear error: ```sh 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 37972f5116..c6f028b303 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -89,9 +89,9 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill | `AskUserQuestion` | Auto-allow | Ask the user a question to gather structured input | | `Skill` | Auto-allow | Invoke a registered inline Skill | -**`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), `run_in_background` (defaults to false), and `model` (`"secondary"` for the secondary model configured via `[secondary_model] model`, or `"primary"` for the main model; ignored when resuming; available when the secondary-model experiment is enabled under `kimi web` or experimental `kimi -p`, not in the TUI). An explicit `model` overrides the selected [agent profile's `model_preference`](../customization/agents.md#agent-file-format); without either, the configured secondary model is the default, or the subagent inherits the caller's model when no secondary model is configured. Agent tasks time out after 2 hours by default; the limit is configurable via `[subagent] timeout_ms` in `config.toml` (`0` = no timeout, or the `KIMI_SUBAGENT_TIMEOUT_MS` env var), and defaults to no timeout in print mode (`kimi -p`). In foreground mode the parent Agent waits for the sub-Agent to complete before continuing; in background mode a task ID is returned immediately and the result is automatically delivered back to the main Agent via a synthetic User message when done. When several foreground `Agent` calls run in the same step, the TUI groups them and shows each subagent's running, waiting, completed, or failed status with elapsed time. See [Agent & Sub-Agents](../customization/agents.md) for details. +**`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), `run_in_background` (defaults to false), and `model` (`"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). 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`. Pass `model` (available when the secondary-model experiment is enabled under `kimi web` or experimental `kimi -p`, not in the TUI) to run item-spawned subagents on the secondary model configured via `[secondary_model] model` (`"secondary"`) or the main model (`"primary"`). This explicit choice overrides the selected [agent profile's `model_preference`](../customization/agents.md#agent-file-format); without either, the configured secondary model is the default, or the subagent inherits the caller's model when no secondary model is configured. Resumed subagents keep their own model. Without `resume_agent_ids`, the tool requires at least 2 items; with `resume_agent_ids`, it can resume one or more existing subagents. The tool supports up to 128 total subagents, waits for all subagents to finish, and returns an aggregated report. In the TUI, foreground swarms show a live `Agent swarm` progress panel above the input box. If a model response calls `AgentSwarm`, that call must be the only tool call in the response; to run multiple swarms, call one `AgentSwarm`, wait for its result, then call the next, or combine the work into one swarm when a single template can cover it. In `manual` permission mode, `AgentSwarm` calls outside active swarm mode request approval unless a permission rule allows them; while swarm mode is active, `AgentSwarm` itself is auto-approved. Permission rules match `AgentSwarm` by tool name only — argument patterns such as `AgentSwarm(swarm)` are not supported. By default the tool ramps up concurrency without an upper limit (5 subagents start immediately, then 1 more every 700 ms); set `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` to a positive integer to cap how many subagents run at the same time during that ramp, or leave it unset for no cap. If it is set to a value that is not a positive integer, the AgentSwarm call fails fast. +**`AgentSwarm`** launches subagents from a shared `prompt_template` and an `items` array, resumes existing subagents through `resume_agent_ids`, or combines both in one call. The template must contain the `{{item}}` placeholder; each item replaces that placeholder and launches one new subagent. Pass `subagent_type` to choose the profile used by every spawned subagent in the swarm, or omit it to use `coder`. Pass `model` (available when the secondary-model experiment is enabled) 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 045904130f..a134bafa07 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -192,7 +192,7 @@ display_name = "Kimi for Coding (custom)" 次主力模型是主模型 `default_model` 之外的第二个模型指针——通常是一个更便宜的模型,供不需要主模型的功能绑定使用。目前的消费者是子 Agent 派生:设置后,新派生的子 Agent(`Agent` / `AgentSwarm`)默认绑定该模型,而不再继承主 Agent 的模型;主 Agent 会被告知每次派生可在 `"secondary"`(该模型)与 `"primary"`(主模型)之间选择。未设置时,子 Agent 继承主 Agent 的模型。 -该功能目前是实验功能,默认关闭。通过 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1` 启用,或使用 master `KIMI_CODE_EXPERIMENTAL_FLAG=1`(`kimi -p` 选择 v2 引擎本就需要该 flag)。它在包括交互式 TUI 在内的所有引擎上生效。 +该功能目前是实验功能,默认关闭。通过 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1` 启用,或使用 master `KIMI_CODE_EXPERIMENTAL_FLAG=1`。它在包括交互式 TUI 在内的所有启动方式下生效。 | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index e5a654ebe0..326004b40c 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -128,9 +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_CODE_EXPERIMENTAL_SECONDARY_MODEL` | 在包括交互式 TUI 在内的所有启动方式下启用实验性的次主力模型功能;master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | +| `KIMI_SECONDARY_MODEL` | 次主力模型;优先级高于 `config.toml` 的 `[secondary_model] model`。次主力模型实验功能启用后,新派生的子 Agent 默认绑定该模型,而不再继承主 Agent 的模型 | 已配置 `[models]` 中的模型 id,如 `kimi-code/kimi-k2.5`;空白值被忽略 | +| `KIMI_SECONDARY_EFFORT` | 次主力模型的 thinking effort;优先级高于 `config.toml` 的 `[secondary_model] default_effort`,仅在次主力模型及其实验功能均启用时生效 | 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 f314f069c2..5c7430af38 100644 --- a/docs/zh/customization/agents.md +++ b/docs/zh/customization/agents.md @@ -109,7 +109,7 @@ disallowedTools: 未知字段会被忽略,新版本写的文件在旧版本上仍可读取。其他 Agent 工具的字段(如 Claude Code 的 `model`、OpenCode 的 `mode`)同样会被忽略;加上 `tools` 的逗号分隔写法和 `name` 缺省回退到文件名,Claude Code 与 OpenCode 风格的 Agent 文件一般可直接加载 —— 只含 `description` 和正文的最小文件可跨工具通用。 -`model_preference` 仅在次主力模型实验功能启用时对新启动的子 Agent 生效——设置 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`,或 master `KIMI_CODE_EXPERIMENTAL_FLAG=1`(`kimi -p` 选择 v2 引擎本就需要该 flag)。它在包括交互式 TUI 在内的所有引擎上生效。该字段不用于填写具体模型 alias,已恢复的子 Agent 也会保持原模型。主 Agent 会在 profile 描述中看到这项偏好,因此仍可在某项任务需要不同选择时显式传入 `model`。 +`model_preference` 仅在次主力模型实验功能启用时对新启动的子 Agent 生效——设置 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`,或 master `KIMI_CODE_EXPERIMENTAL_FLAG=1`。它在包括交互式 TUI 在内的所有启动方式下生效。该字段不用于填写具体模型 alias,已恢复的子 Agent 也会保持原模型。主 Agent 会在 profile 描述中看到这项偏好,因此仍可在某项任务需要不同选择时显式传入 `model`。 目录中发现的非法文件会被跳过并告警,不影响其他文件。通过 `--agent-file` 显式传入的文件必须合法 —— 否则 CLI 会报错并退出。 @@ -121,7 +121,7 @@ disallowedTools: ### 选择主 Agent -两个 CLI flag 用于选择驱动会话的 Agent。**二者在 print 模式(`kimi -p`)下可用,两个引擎均支持**;交互式 TUI 会以明确错误拒绝它们: +两个 CLI flag 用于选择驱动会话的 Agent。**二者在 print 模式(`kimi -p`)下可用**;交互式 TUI 会以明确错误拒绝它们: - **`--agent `**:以指定 Agent 作为主 Agent 启动会话。名称可以指向内置 Agent 或任何已发现的文件;名称不存在时会报错,并列出可用的 Agent。 - **`--agent-file `**:以最高优先级加载一个 Agent 文件(仅本次启动)并以其启动。该 flag 只接受一个文件:不可重复传入,也不能与 `--agent` 同时使用。 @@ -138,7 +138,7 @@ kimi -p --agent reviewer "审查这个分支上的改动" ### 用 SYSTEM.md 覆盖主 Agent 的系统提示词 -希望永久覆盖主 Agent 的系统提示词、而不必每次启动都传入 `--agent` 或 `--agent-file` 时,可以写一份 `$KIMI_CODE_HOME/SYSTEM.md`(默认:`~/.kimi-code/SYSTEM.md`,随 `KIMI_CODE_HOME` 移动)。文件存在且非空期间,它整体替换内置默认主 Agent 的系统提示词——但只替换提示词,描述与工具集仍沿用内置默认值。SYSTEM.md 在包括交互式 TUI 会话在内的所有引擎上生效。 +希望永久覆盖主 Agent 的系统提示词、而不必每次启动都传入 `--agent` 或 `--agent-file` 时,可以写一份 `$KIMI_CODE_HOME/SYSTEM.md`(默认:`~/.kimi-code/SYSTEM.md`,随 `KIMI_CODE_HOME` 移动)。文件存在且非空期间,它整体替换内置默认主 Agent 的系统提示词——但只替换提示词,描述、工具集与允许委派的子 Agent 列表仍沿用内置默认值。SYSTEM.md 在包括交互式 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 d1290f270f..e90d48b2fa 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 启动会话(仅 `kimi -p`,两个引擎均支持) | -| `--agent-file ` | | 从 Markdown 文件加载自定义 Agent(仅本次启动、仅 `kimi -p`,两个引擎均支持)并选中它。不可重复传入,也不能与 `--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。二者在两个引擎的 `kimi -p` 下均可用,交互式启动会以明确错误拒绝: +`--agent` 和 `--agent-file` 用于选择驱动会话的 Agent。二者在 `kimi -p` 下均可用,交互式启动会以明确错误拒绝: ```sh kimi -p --agent reviewer "审查这个分支上的改动" diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index f993fa7bb6..04e84a6ad0 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -89,9 +89,9 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只 | `AskUserQuestion` | 自动放行 | 向用户提问以获取结构化输入 | | `Skill` | 自动放行 | 调用已注册的 inline Skill | -**`Agent`** 将子任务委托给子 Agent 执行。必填参数:`prompt`(完整任务描述)和 `description`(3–5 个词的简短说明)。可选参数:`subagent_type`(默认 `coder`)、`resume`(恢复已有 Agent 的 ID,与 `subagent_type` 互斥)、`run_in_background`(默认 false)和 `model`(`"secondary"` 表示 `[secondary_model] model` 配置的次主力模型,`"primary"` 表示主模型;resume 时无效;在 `kimi web` 或实验性 `kimi -p` 下启用次主力模型实验功能后可用,TUI 下被忽略)。显式 `model` 会覆盖所选 [Agent profile 的 `model_preference`](../customization/agents.md#agent-文件格式);两者均未设置时,已配置的次主力模型为默认值,未配置时则继承调用方模型。Agent 任务默认 2 小时超时,可通过 `config.toml` 的 `[subagent] timeout_ms`(`0` = 无超时,或 `KIMI_SUBAGENT_TIMEOUT_MS` 环境变量)配置,且在 print 模式(`kimi -p`)下默认无超时。前台模式下父 Agent 等待子 Agent 完成再继续;后台模式立即返回任务 ID,完成时通过合成 User 消息自动回到主 Agent。多个前台 `Agent` 调用在同一步运行时,TUI 会合并展示,并为每个子 Agent 显示运行、等待、完成或失败状态以及已耗时长。子 Agent 体系细节见 [Agent 与子 Agent](../customization/agents.md)。 +**`Agent`** 将子任务委托给子 Agent 执行。必填参数:`prompt`(完整任务描述)和 `description`(3–5 个词的简短说明)。可选参数:`subagent_type`(默认 `coder`)、`resume`(恢复已有 Agent 的 ID,与 `subagent_type` 互斥)、`run_in_background`(默认 false)和 `model`(`"secondary"` 表示 `[secondary_model] model` 配置的次主力模型,`"primary"` 表示主模型;resume 时无效;次主力模型实验功能启用后可用)。显式 `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`。传入 `model`(在 `kimi web` 或实验性 `kimi -p` 下启用次主力模型实验功能后可用,TUI 下被忽略)可以让新启动的子 Agent 运行在 `[secondary_model] model` 配置的次主力模型(`"secondary"`)或主模型(`"primary"`)上。这项显式选择会覆盖所选 [Agent profile 的 `model_preference`](../customization/agents.md#agent-文件格式);两者均未设置时,已配置的次主力模型为默认值,未配置时则继承调用方模型。恢复的子 Agent 保持其原有模型。不传 `resume_agent_ids` 时,本工具要求至少 2 个 item;传入 `resume_agent_ids` 时,可以恢复 1 个或多个已有子 Agent。本工具最多支持 128 个子 Agent,会等待全部子 Agent 完成,并返回聚合报告。在 TUI 中,前台 swarm 会在输入框上方显示实时 `Agent swarm` 进度面板。若一次模型响应调用 `AgentSwarm`,该调用必须是该响应中的唯一工具调用;如需运行多个 swarm,应先调用一个 `AgentSwarm` 并等待结果,再调用下一个,若单个模板可以覆盖这些工作,也可以合并为一个 swarm。在 `manual` 权限模式下,未处于 swarm mode 时调用 `AgentSwarm` 会触发审批,除非已有权限规则允许;swarm mode 已开启时,`AgentSwarm` 本身会自动放行。权限规则只能按工具名 `AgentSwarm` 匹配,不支持 `AgentSwarm(swarm)` 这类参数模式。默认情况下,本工具会逐步提升并发且不设上限(立即启动 5 个子 Agent,之后每 700 毫秒再启动 1 个);将 `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` 设为正整数可限制该阶段同时运行的子 Agent 数量,不设置则表示不限制。若设置为非正整数的值,本次 AgentSwarm 调用会立即失败。 +**`AgentSwarm`** 可以从共享的 `prompt_template` 和 `items` 数组启动子 Agent,也可以通过 `resume_agent_ids` 恢复已有子 Agent,或在一次调用中同时使用两者。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个新的子 Agent。传入 `subagent_type` 可以指定整个 swarm 中所有新启动的子 Agent 使用的 profile;省略时默认使用 `coder`。传入 `model`(次主力模型实验功能启用后可用)可以让新启动的子 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 应改为在文本回复中直接提问。 From eddf5ee19c2266630b510f665181c242eba6814b Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 27 Jul 2026 18:00:38 +0800 Subject: [PATCH 12/30] fix(cli): reject --agent-file combined with session resume The resume path only forwards the agent file's name for the bound-profile assertion; the file's content is never re-applied (the session keeps its creation-time catalog snapshot). Previously the combination was silently accepted, so an edited file (or a same-named one) appeared to apply but did not. Reject it at option validation and document the constraint. --- apps/kimi-code/src/cli/commands.ts | 2 +- apps/kimi-code/src/cli/options.ts | 5 +++++ apps/kimi-code/test/cli/options.test.ts | 21 +++++++++++++++++++++ docs/en/customization/agents.md | 2 +- docs/en/reference/kimi-command.md | 4 ++-- docs/zh/customization/agents.md | 2 +- docs/zh/reference/kimi-command.md | 4 ++-- 7 files changed, 33 insertions(+), 7 deletions(-) diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index f3634297b6..df1f6ae4c0 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -90,7 +90,7 @@ export function createProgram( .addOption( new Option( '--agent-file ', - 'Load an agent definition from a Markdown file and select it for this print-mode invocation on either engine.', + 'Load an agent definition from a Markdown file and select it for this print-mode invocation on either engine. Only valid when starting a new session — cannot be combined with --session/--continue.', ) .argParser((value: string, previous: string[] | undefined) => { if ((previous?.length ?? 0) > 0) { diff --git a/apps/kimi-code/src/cli/options.ts b/apps/kimi-code/src/cli/options.ts index 71afb4518f..d4c21664da 100644 --- a/apps/kimi-code/src/cli/options.ts +++ b/apps/kimi-code/src/cli/options.ts @@ -97,6 +97,11 @@ export function validateOptions( if (opts.agent !== undefined && opts.agentFiles.length > 0) { throw new OptionConflictError('Cannot combine --agent with --agent-file.'); } + if (opts.agentFiles.length > 0 && (opts.session !== undefined || opts.continue)) { + throw new OptionConflictError( + 'Cannot combine --agent-file with --session/--continue: the agent file is bound at session creation and is not re-applied on resume.', + ); + } if ( (opts.agent !== undefined || opts.agentFiles.length > 0) && !promptMode diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index e7facf1ad8..d8972bcfb3 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -454,6 +454,27 @@ describe('CLI options parsing', () => { ); }); + it('rejects --agent-file with --session', () => { + const opts = parse(['-p', 'hi', '--agent-file', 'a.md', '--session', 'ses_123']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow( + 'Cannot combine --agent-file with --session/--continue', + ); + }); + + it('rejects --agent-file with --continue', () => { + const opts = parse(['-p', 'hi', '--agent-file', 'a.md', '--continue']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow( + 'Cannot combine --agent-file with --session/--continue', + ); + }); + + it('allows --agent with --session (re-selecting the bound profile)', () => { + const opts = parse(['-p', 'hi', '--agent', 'reviewer', '--session', 'ses_123']); + expect(validateOptions(opts, {}).uiMode).toBe('print'); + }); + it('rejects empty agent values', () => { const opts = parse(['-p', 'hi', '--agent', ' ']); expect(() => validateOptions(opts)).toThrow(OptionConflictError); diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md index dab777eb44..95b22a4cff 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -124,7 +124,7 @@ Custom agents delegated as sub-agents run without the built-in sub-agent framing Two CLI flags select which agent drives the session. **Both are available in print mode (`kimi -p`)**; 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`. +- **`--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`. It is only valid when starting a new session — it cannot be combined with `--session`/`--continue`, because the file's content is bound at session creation and is not re-applied on resume. For example, in print mode: diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index 1c8ef32f60..ec3b950555 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -25,7 +25,7 @@ All flags are optional — run `kimi` directly to enter an interactive session: | `--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 (`kimi -p` only) | -| `--agent-file ` | | Load a custom agent from a Markdown file for this launch and select it (`kimi -p` only). Cannot be repeated or combined with `--agent` | +| `--agent-file ` | | Load a custom agent from a Markdown file for this launch and select it (`kimi -p` only). Cannot be repeated or combined with `--agent`, `--session`, or `--continue` | | `--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. @@ -104,7 +104,7 @@ There are two ways to specify Skills directories, with different semantics: kimi -p --agent reviewer "Review the changes on this branch" ``` -`--agent-file` registers a single agent file at the highest priority for this launch only and selects it; the flag cannot be repeated, and `--agent` and `--agent-file` are mutually exclusive. The selection is fixed at the session's first bind: resuming with the same `--agent` is a no-op, and switching to a different one fails with an "already bound" error. See [Agents and Sub-Agents](../customization/agents.md#custom-agents) for the agent file format and discovery directories. +`--agent-file` registers a single agent file at the highest priority for this launch only and selects it; the flag cannot be repeated, `--agent` and `--agent-file` are mutually exclusive, and it cannot be combined with `--session`/`--continue` since the file's content is bound at session creation rather than re-applied on resume. The selection is fixed at the session's first bind: resuming with the same `--agent` is a no-op, and switching to a different one fails with an "already bound" error. See [Agents and Sub-Agents](../customization/agents.md#custom-agents) for the agent file format and discovery directories. ## Non-Interactive Execution diff --git a/docs/zh/customization/agents.md b/docs/zh/customization/agents.md index 5c7430af38..c66dcc2dcf 100644 --- a/docs/zh/customization/agents.md +++ b/docs/zh/customization/agents.md @@ -124,7 +124,7 @@ disallowedTools: 两个 CLI flag 用于选择驱动会话的 Agent。**二者在 print 模式(`kimi -p`)下可用**;交互式 TUI 会以明确错误拒绝它们: - **`--agent `**:以指定 Agent 作为主 Agent 启动会话。名称可以指向内置 Agent 或任何已发现的文件;名称不存在时会报错,并列出可用的 Agent。 -- **`--agent-file `**:以最高优先级加载一个 Agent 文件(仅本次启动)并以其启动。该 flag 只接受一个文件:不可重复传入,也不能与 `--agent` 同时使用。 +- **`--agent-file `**:以最高优先级加载一个 Agent 文件(仅本次启动)并以其启动。该 flag 只接受一个文件:不可重复传入,也不能与 `--agent` 同时使用。仅在新建会话时有效——不能与 `--session`/`--continue` 组合,因为文件内容在会话创建时绑定,恢复会话时不会重新应用。 例如在 print 模式下: diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index e90d48b2fa..e71445d5c3 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -25,7 +25,7 @@ kimi [options] | `--plan` | | 以 Plan 模式启动新会话,AI 会优先使用只读工具进行探索和规划 | | `--skills-dir ` | | 从指定目录加载 Skills,替换自动发现的用户和项目目录。可重复传入 | | `--agent ` | | 以指定 Agent 作为主 Agent 启动会话(仅 `kimi -p`) | -| `--agent-file ` | | 从 Markdown 文件加载自定义 Agent(仅本次启动、仅 `kimi -p`)并选中它。不可重复传入,也不能与 `--agent` 同时使用 | +| `--agent-file ` | | 从 Markdown 文件加载自定义 Agent(仅本次启动、仅 `kimi -p`)并选中它。不可重复传入,也不能与 `--agent`、`--session` 或 `--continue` 同时使用 | | `--add-dir ` | | 为本次会话添加额外的工作目录。相对路径按当前工作目录解析。可重复传入 | `-r` / `--resume` 是 `--session` 的隐藏别名;`--yes` 和 `--auto-approve` 是 `--yolo` 的隐藏别名,在帮助信息中不显示。 @@ -104,7 +104,7 @@ kimi --plan kimi -p --agent reviewer "审查这个分支上的改动" ``` -`--agent-file` 以最高优先级注册单个 Agent 文件(仅本次启动)并选中它;该 flag 不可重复传入,且 `--agent` 与 `--agent-file` 互斥。选择在会话首次绑定后即固定:以相同的 `--agent` 恢复会话是 no-op,换成不同的 Agent 会报 "already bound" 错误。Agent 文件格式与发现目录详见 [Agent 与子 Agent](../customization/agents.md#自定义-agent)。 +`--agent-file` 以最高优先级注册单个 Agent 文件(仅本次启动)并选中它;该 flag 不可重复传入,`--agent` 与 `--agent-file` 互斥,且不能与 `--session`/`--continue` 同时使用——文件内容在会话创建时绑定,恢复会话时不会重新应用。选择在会话首次绑定后即固定:以相同的 `--agent` 恢复会话是 no-op,换成不同的 Agent 会报 "already bound" 错误。Agent 文件格式与发现目录详见 [Agent 与子 Agent](../customization/agents.md#自定义-agent)。 ## 非交互执行 From 3273045026d8805f36763028015e544d4e17f7f8 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 27 Jul 2026 18:00:56 +0800 Subject: [PATCH 13/30] refactor(agent-core): share prompt-section prose and note v2 twins in agentfile headers The Windows notes, additional-dirs and skills prose blocks existed twice: inline in the builtin default template (system.md) and as constants in the agent-file renderer (from-file.ts). Extract them to profile/prompt-sections.ts as the single source: system.md renders them through injected KIMI_* template variables and from-file.ts imports the same constants. Rendered prompts are byte-identical for all four builtin profiles across macOS/Windows and skills/dirs on/off; a new test pins system.md to the shared constants. Also mark each profile/agentfile file with the path of its agent-core-v2 counterpart so format/semantics changes land in both engines. --- .../src/profile/agentfile/catalog.ts | 5 ++++ .../src/profile/agentfile/discovery.ts | 3 +++ .../src/profile/agentfile/from-file.ts | 21 +++++++-------- .../src/profile/agentfile/parser.ts | 3 +++ .../agent-core/src/profile/agentfile/paths.ts | 3 +++ .../agent-core/src/profile/agentfile/roots.ts | 3 +++ .../src/profile/agentfile/system-file.ts | 3 +++ .../agent-core/src/profile/agentfile/types.ts | 3 +++ .../src/profile/agentfile/validate.ts | 5 +++- .../agent-core/src/profile/default/system.md | 12 +++------ .../agent-core/src/profile/prompt-sections.ts | 26 +++++++++++++++++++ packages/agent-core/src/profile/resolve.ts | 10 +++++++ .../profile/default-agent-profiles.test.ts | 19 ++++++++++++++ 13 files changed, 94 insertions(+), 22 deletions(-) create mode 100644 packages/agent-core/src/profile/prompt-sections.ts diff --git a/packages/agent-core/src/profile/agentfile/catalog.ts b/packages/agent-core/src/profile/agentfile/catalog.ts index ed39e8e672..f32833a8a0 100644 --- a/packages/agent-core/src/profile/agentfile/catalog.ts +++ b/packages/agent-core/src/profile/agentfile/catalog.ts @@ -15,6 +15,11 @@ * means "any type"), and the builtin default profile's subagent set extends * with every file-defined profile so the main agent can delegate to custom * agents. + * + * Semantics mirror the v2 engine's agentFileCatalog domain + * (`packages/agent-core-v2/src/app/agentFileCatalog/`, e.g. `agentProfileSource.ts` + * and `userFileAgentSource.ts`) — keep merge/override/delegation behavior in + * sync across both engines. */ import { DEFAULT_AGENT_PROFILES } from '../default'; diff --git a/packages/agent-core/src/profile/agentfile/discovery.ts b/packages/agent-core/src/profile/agentfile/discovery.ts index 4003c0b204..c539c06fbe 100644 --- a/packages/agent-core/src/profile/agentfile/discovery.ts +++ b/packages/agent-core/src/profile/agentfile/discovery.ts @@ -12,6 +12,9 @@ * the returned `skipped` list keeps the full parse-failure detail regardless, * and the capping summary names a few suppressed paths so the rest stay * findable. + * + * Ported from the v2 engine (`packages/agent-core-v2/src/app/agentFileCatalog/agentFileDiscovery.ts`) + * — keep the two in sync: discovery behavior changes must land in both engines. */ import { promises as fs } from 'node:fs'; diff --git a/packages/agent-core/src/profile/agentfile/from-file.ts b/packages/agent-core/src/profile/agentfile/from-file.ts index 23b37c3f02..63682bc616 100644 --- a/packages/agent-core/src/profile/agentfile/from-file.ts +++ b/packages/agent-core/src/profile/agentfile/from-file.ts @@ -18,9 +18,18 @@ * `mcp__github__*` under an `mcp__*` allow. `subagents` stays an allowlist * of names on the definition; the catalog links it into the resolved record * after merging. + * + * Ported from the v2 engine (`packages/agent-core-v2/src/app/agentFileCatalog/agentProfileFromFile.ts`) + * — keep the two in sync: template variables and profile-mapping semantics + * must land in both engines. */ import type { ResolvedAgentProfile, SystemPromptContext } from '../types'; +import { + ADDITIONAL_DIRS_SECTION_PROSE, + SKILLS_SECTION_PROSE, + WINDOWS_NOTES, +} from '../prompt-sections'; import type { AgentFileDefinition } from './types'; @@ -33,18 +42,6 @@ function renderTemplateVars(template: string, vars: Record): str }); } -const WINDOWS_NOTES = - 'IMPORTANT: You are on Windows. The Bash tool runs through Git Bash, so use Unix shell syntax inside Bash commands — `/dev/null` not `NUL`, and forward slashes in paths. For file operations, always prefer the built-in tools (Read, Write, Edit, Glob, Grep) over Bash commands — they work reliably across all platforms.'; - -const ADDITIONAL_DIRS_SECTION_PROSE = - 'The following directories have been added to the workspace. You can read, write, search, and glob files in these directories as part of your workspace scope.'; - -const SKILLS_SECTION_PROSE = - 'Skills are reusable, composable capabilities that enhance your abilities. Each skill is either a self-contained directory with a `SKILL.md` file or a standalone `.md` file that contains instructions, examples, and/or reference material.\n\n' + - 'Identify the skills relevant to your current task and read the skill file for its instructions; only read further skill details when needed, to conserve the context window.\n\n' + - '## Available skills\n\n' + - 'Skills are grouped by scope (`Project`, `User`, `Extra`, `Built-in`) so you can tell where each came from. When the user refers to "the skill in this project" or "the user-scope skill", use the scope heading to disambiguate. When multiple scopes define a skill with the same name, the more specific scope takes precedence: **Project overrides User overrides Extra overrides Built-in**.'; - export function agentFilePromptVars( context: SystemPromptContext, options: { readonly skillActive: boolean }, diff --git a/packages/agent-core/src/profile/agentfile/parser.ts b/packages/agent-core/src/profile/agentfile/parser.ts index 64bc1c05d9..324b8f2e3e 100644 --- a/packages/agent-core/src/profile/agentfile/parser.ts +++ b/packages/agent-core/src/profile/agentfile/parser.ts @@ -10,6 +10,9 @@ * `tools` / `subagents` means unrestricted like an omitted field, and list * fields accept either a bare comma-separated string or the YAML list form * (Claude Code). + * + * Ported from the v2 engine (`packages/agent-core-v2/src/app/agentFileCatalog/agentFile.ts`) + * — keep the two in sync: agent-file format changes must land in both engines. */ import { FrontmatterError, parseFrontmatter } from '../../skill/parser'; diff --git a/packages/agent-core/src/profile/agentfile/paths.ts b/packages/agent-core/src/profile/agentfile/paths.ts index 3e5f6c80f6..99d86e57c5 100644 --- a/packages/agent-core/src/profile/agentfile/paths.ts +++ b/packages/agent-core/src/profile/agentfile/paths.ts @@ -4,6 +4,9 @@ * the directory walker, and the explicit-file source. Callers pick the * resolution base: discovery roots resolve against the project root, * explicit files against the session workDir. + * + * Ported from the v2 engine (`packages/agent-core-v2/src/app/agentFileCatalog/paths.ts`) + * — keep the two in sync. */ import { promises as fs } from 'node:fs'; diff --git a/packages/agent-core/src/profile/agentfile/roots.ts b/packages/agent-core/src/profile/agentfile/roots.ts index e84dcf64de..7dcecd808f 100644 --- a/packages/agent-core/src/profile/agentfile/roots.ts +++ b/packages/agent-core/src/profile/agentfile/roots.ts @@ -2,6 +2,9 @@ * Agent-root resolution primitives: user, project, and configured discovery * roots, mirroring the skill scanner's directory conventions * (`skills` ↔ `agents`, `.kimi-code` ↔ `.agents`). + * + * Ported from the v2 engine (`packages/agent-core-v2/src/app/agentFileCatalog/agentRoots.ts`) + * — keep the two in sync: discovery-root conventions must land in both engines. */ import { promises as fs } from 'node:fs'; diff --git a/packages/agent-core/src/profile/agentfile/system-file.ts b/packages/agent-core/src/profile/agentfile/system-file.ts index 060f6255b1..bf2e7466b5 100644 --- a/packages/agent-core/src/profile/agentfile/system-file.ts +++ b/packages/agent-core/src/profile/agentfile/system-file.ts @@ -13,6 +13,9 @@ * definition; a read failure degrades to `warn` instead of rejecting, * matching the directory-source policy that a transient fs error must never * poison a session. + * + * Ported from the v2 engine (`packages/agent-core-v2/src/app/agentFileCatalog/systemFile.ts`) + * — keep the two in sync: SYSTEM.md semantics must land in both engines. */ import { promises as fs } from 'node:fs'; diff --git a/packages/agent-core/src/profile/agentfile/types.ts b/packages/agent-core/src/profile/agentfile/types.ts index 70c9db03b2..8e50b20dcf 100644 --- a/packages/agent-core/src/profile/agentfile/types.ts +++ b/packages/agent-core/src/profile/agentfile/types.ts @@ -3,6 +3,9 @@ * (`AgentFileDefinition`), scan roots (`AgentFileRoot`) tagged with their * source, and the discovery result carrying per-file skip diagnostics. * Pure data. + * + * Ported from the v2 engine (`packages/agent-core-v2/src/app/agentFileCatalog/types.ts`) + * — keep the two in sync. */ import type { AgentModelPreference } from '../types'; diff --git a/packages/agent-core/src/profile/agentfile/validate.ts b/packages/agent-core/src/profile/agentfile/validate.ts index 942873f3f6..a10b3fcb85 100644 --- a/packages/agent-core/src/profile/agentfile/validate.ts +++ b/packages/agent-core/src/profile/agentfile/validate.ts @@ -10,7 +10,10 @@ * `mcp____` name; `mcp__github__*` is the working form for a * whole server), and `unknown-tool` (a literal naming no registered or * built-in tool, almost always a typo such as `read` instead of `Read`). - * Mirrors the v2 engine's `findInactiveToolPatterns`. + * + * Ported from the v2 engine (`findInactiveToolPatterns` in + * `packages/agent-core-v2/src/agent/toolPolicy/evaluate.ts`) — keep the two + * in sync: warning kinds and matching rules must land in both engines. */ import { isMcpToolName } from '../../mcp/tool-naming'; diff --git a/packages/agent-core/src/profile/default/system.md b/packages/agent-core/src/profile/default/system.md index 0671cd1084..997c11d0e6 100644 --- a/packages/agent-core/src/profile/default/system.md +++ b/packages/agent-core/src/profile/default/system.md @@ -79,7 +79,7 @@ If the summary is genuinely missing something you need to proceed, ask the user You are running on **{{ KIMI_OS }}**. The Bash tool executes commands using **{{ KIMI_SHELL }}**. {% if KIMI_OS == "Windows" %} -IMPORTANT: You are on Windows. The Bash tool runs through Git Bash, so use Unix shell syntax inside Bash commands — `/dev/null` not `NUL`, and forward slashes in paths. For file operations, always prefer the built-in tools (Read, Write, Edit, Glob, Grep) over Bash commands — they work reliably across all platforms. +{{ KIMI_WINDOWS_NOTES }} {% endif %} The operating environment is not in a sandbox. Any actions you do will immediately affect the user's system. So you MUST be extremely cautious. Unless being explicitly instructed to do so, you should never access (read/write/execute) files outside of the working directory. @@ -105,7 +105,7 @@ The directory listing of current working directory is: ## Additional Directories -The following directories have been added to the workspace. You can read, write, search, and glob files in these directories as part of your workspace scope. +{{ KIMI_ADDITIONAL_DIRS_SECTION_PROSE }} {{ KIMI_ADDITIONAL_DIRS_INFO }} {% endif %} @@ -125,13 +125,7 @@ The applicable `AGENTS.md` instructions are: {% if KIMI_SKILLS %} # Skills -Skills are reusable, composable capabilities that enhance your abilities. Each skill is either a self-contained directory with a `SKILL.md` file or a standalone `.md` file that contains instructions, examples, and/or reference material. - -Identify the skills relevant to your current task and read the skill file for its instructions; only read further skill details when needed, to conserve the context window. - -## Available skills - -Skills are grouped by scope (`Project`, `User`, `Extra`, `Built-in`) so you can tell where each came from. When the user refers to "the skill in this project" or "the user-scope skill", use the scope heading to disambiguate. When multiple scopes define a skill with the same name, the more specific scope takes precedence: **Project overrides User overrides Extra overrides Built-in**. +{{ KIMI_SKILLS_SECTION_PROSE }} {{ KIMI_SKILLS }} {% endif %} diff --git a/packages/agent-core/src/profile/prompt-sections.ts b/packages/agent-core/src/profile/prompt-sections.ts new file mode 100644 index 0000000000..16cf0da902 --- /dev/null +++ b/packages/agent-core/src/profile/prompt-sections.ts @@ -0,0 +1,26 @@ +/** + * Shared prompt-section prose — the single source for the three + * environment-dependent prose blocks that appear in BOTH the builtin default + * prompt and agent-file prompts: + * + * - `profile/default/system.md` renders them through the `KIMI_WINDOWS_NOTES` + * / `KIMI_ADDITIONAL_DIRS_SECTION_PROSE` / `KIMI_SKILLS_SECTION_PROSE` + * template variables injected by `resolve.ts#buildTemplateVars`; + * - `profile/agentfile/from-file.ts` composes them into the agent-file + * `${windows_notes}` / `${additional_dirs_section}` / `${skills_section}` + * variables. + * + * Edit the text HERE only — the two render paths must never drift apart. + */ + +export const WINDOWS_NOTES = + 'IMPORTANT: You are on Windows. The Bash tool runs through Git Bash, so use Unix shell syntax inside Bash commands — `/dev/null` not `NUL`, and forward slashes in paths. For file operations, always prefer the built-in tools (Read, Write, Edit, Glob, Grep) over Bash commands — they work reliably across all platforms.'; + +export const ADDITIONAL_DIRS_SECTION_PROSE = + 'The following directories have been added to the workspace. You can read, write, search, and glob files in these directories as part of your workspace scope.'; + +export const SKILLS_SECTION_PROSE = + 'Skills are reusable, composable capabilities that enhance your abilities. Each skill is either a self-contained directory with a `SKILL.md` file or a standalone `.md` file that contains instructions, examples, and/or reference material.\n\n' + + 'Identify the skills relevant to your current task and read the skill file for its instructions; only read further skill details when needed, to conserve the context window.\n\n' + + '## Available skills\n\n' + + 'Skills are grouped by scope (`Project`, `User`, `Extra`, `Built-in`) so you can tell where each came from. When the user refers to "the skill in this project" or "the user-scope skill", use the scope heading to disambiguate. When multiple scopes define a skill with the same name, the more specific scope takes precedence: **Project overrides User overrides Extra overrides Built-in**.'; diff --git a/packages/agent-core/src/profile/resolve.ts b/packages/agent-core/src/profile/resolve.ts index 3dc6b91085..1e2356cda6 100644 --- a/packages/agent-core/src/profile/resolve.ts +++ b/packages/agent-core/src/profile/resolve.ts @@ -1,4 +1,9 @@ import { renderPrompt } from '../utils/render-prompt'; +import { + ADDITIONAL_DIRS_SECTION_PROSE, + SKILLS_SECTION_PROSE, + WINDOWS_NOTES, +} from './prompt-sections'; import type { AgentModelPreference, RawAgentProfile, @@ -165,6 +170,11 @@ function buildTemplateVars( KIMI_AGENTS_MD: context.agentsMd ?? '', KIMI_SKILLS: tools.includes('Skill') ? skills : '', KIMI_ADDITIONAL_DIRS_INFO: context.additionalDirsInfo ?? '', + // Shared prose sections (single source: profile/prompt-sections.ts) so the + // builtin template and the agent-file renderer can never drift apart. + KIMI_WINDOWS_NOTES: WINDOWS_NOTES, + KIMI_ADDITIONAL_DIRS_SECTION_PROSE: ADDITIONAL_DIRS_SECTION_PROSE, + KIMI_SKILLS_SECTION_PROSE: SKILLS_SECTION_PROSE, ROLE_ADDITIONAL: context.roleAdditional ?? promptVars['ROLE_ADDITIONAL'] ?? promptVars['roleAdditional'] ?? '', }; diff --git a/packages/agent-core/test/profile/default-agent-profiles.test.ts b/packages/agent-core/test/profile/default-agent-profiles.test.ts index 09b4d1cea9..2e798228bc 100644 --- a/packages/agent-core/test/profile/default-agent-profiles.test.ts +++ b/packages/agent-core/test/profile/default-agent-profiles.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; import { DEFAULT_AGENT_PROFILES, loadAgentProfilesFromSources } from '../../src/profile'; +import { + ADDITIONAL_DIRS_SECTION_PROSE, + SKILLS_SECTION_PROSE, + WINDOWS_NOTES, +} from '../../src/profile/prompt-sections'; const promptContext = { osEnv: { @@ -40,6 +45,20 @@ describe('default agent profiles', () => { ); }); + it('renders the environment prose sections from the shared prompt-sections source', () => { + // system.md must render the shared constants (never re-inlined copies), so + // the builtin default prompt and the agent-file renderer cannot drift. + const prompt = + DEFAULT_AGENT_PROFILES['agent']?.systemPrompt({ + ...promptContext, + osEnv: { ...promptContext.osEnv, osKind: 'Windows' }, + additionalDirsInfo: 'EXTRA_DIR_1', + }) ?? ''; + expect(prompt).toContain(WINDOWS_NOTES); + expect(prompt).toContain(ADDITIONAL_DIRS_SECTION_PROSE); + expect(prompt).toContain(SKILLS_SECTION_PROSE); + }); + it('lists the goal tools on the agent profile but not on subagent profiles', () => { const agentTools = DEFAULT_AGENT_PROFILES['agent']?.tools ?? []; expect(agentTools).toEqual( From 725b4f495a8365c70ad99accc9e6e993e55c4f1a Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 27 Jul 2026 18:53:34 +0800 Subject: [PATCH 14/30] feat(cli): add /secondary_model command for the subagent model Mirror /model: a picker with a thinking-effort step that persists [secondary_model] and live-applies to the current session via a new Session.setSecondaryModel RPC (node-sdk wrapper included), so newly spawned subagents bind the new model right away. The /model picker now hides the synthesized __secondary__ derived entry; docs and the update-config builtin skill mention the section. --- .changeset/v1-secondary-model-command.md | 5 + apps/kimi-code/src/tui/commands/config.ts | 106 +++++++++++- apps/kimi-code/src/tui/commands/dispatch.ts | 5 + apps/kimi-code/src/tui/commands/registry.ts | 8 + .../tui/components/dialogs/model-selector.ts | 4 +- .../dialogs/tabbed-model-selector.ts | 4 + .../test/tui/commands/registry.test.ts | 8 + .../test/tui/commands/secondary-model.test.ts | 157 ++++++++++++++++++ .../dialogs/tabbed-model-selector.test.ts | 16 ++ docs/en/configuration/config-files.md | 2 + docs/en/reference/slash-commands.md | 1 + docs/zh/configuration/config-files.md | 2 + docs/zh/reference/slash-commands.md | 1 + .../app/skillCatalog/builtin/update-config.md | 2 +- packages/agent-core/src/agent/index.ts | 13 +- packages/agent-core/src/rpc/core-api.ts | 5 + packages/agent-core/src/rpc/core-impl.ts | 9 + packages/agent-core/src/session/index.ts | 60 ++++++- packages/agent-core/src/session/rpc.ts | 5 + .../agent-core/src/session/subagent-host.ts | 2 +- .../src/skill/builtin/update-config.md | 2 +- packages/agent-core/test/session/init.test.ts | 106 ++++++++++++ .../test/session/subagent-host.test.ts | 3 + packages/node-sdk/src/index.ts | 3 + packages/node-sdk/src/rpc.ts | 14 ++ packages/node-sdk/src/session.ts | 20 +++ 26 files changed, 550 insertions(+), 13 deletions(-) create mode 100644 .changeset/v1-secondary-model-command.md create mode 100644 apps/kimi-code/test/tui/commands/secondary-model.test.ts diff --git a/.changeset/v1-secondary-model-command.md b/.changeset/v1-secondary-model-command.md new file mode 100644 index 0000000000..ba6c4cda83 --- /dev/null +++ b/.changeset/v1-secondary-model-command.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the /secondary_model slash command to configure the secondary model used by subagents, applied to the current session immediately. Requires the secondary-model experiment (KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1); run /secondary_model to pick a model and thinking effort. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 58f1c27acb..57fead52e6 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -1,5 +1,6 @@ import { effectiveModelAlias, + SECONDARY_DERIVED_MODEL_ALIAS, type ExperimentalFeatureState, type ModelAlias, type PermissionMode, @@ -250,6 +251,25 @@ export async function handleModelCommand(host: SlashCommandHost, args: string): showModelPicker(host, alias); } +export async function handleSecondaryModelCommand(host: SlashCommandHost, args: string): Promise { + const alias = args.trim(); + await refreshModelsForPicker(host); + const models = pickerModelsForHost(host); + if (Object.keys(models).length === 0) { + host.showNotice( + 'No models configured', + 'Run /login to sign in to Kimi, or /provider to add another provider from a model catalog.', + ); + return; + } + if (alias.length > 0 && models[alias] === undefined) { + host.showError(`Unknown model alias: ${alias}`); + return; + } + const secondary = (await host.harness.getConfig()).secondaryModel; + showSecondaryModelPicker(host, models, secondary?.model ?? '', secondary?.defaultEffort, alias); +} + export async function handleEffortCommand(host: SlashCommandHost, args: string): Promise { const alias = host.state.appState.model; const model = host.state.appState.availableModels[alias]; @@ -390,13 +410,22 @@ async function applyEditorChoice(host: SlashCommandHost, value: string): Promise ); } -export function showModelPicker(host: SlashCommandHost, selectedValue: string = host.state.appState.model): void { - const models = Object.fromEntries( - Object.entries(host.state.appState.availableModels).map(([alias, model]) => [ - alias, - effectiveModelForHost(host, model), - ]), +/** + * The models a picker may offer: the user's configured aliases with + * host-effective provider resolution applied, minus the synthesized + * `__secondary__` derived entry — a runtime artifact of the `[secondary_model]` + * recipe that must never be selectable as a primary or secondary model. + */ +function pickerModelsForHost(host: SlashCommandHost): Record { + return Object.fromEntries( + Object.entries(host.state.appState.availableModels) + .filter(([alias]) => alias !== SECONDARY_DERIVED_MODEL_ALIAS) + .map(([alias, model]) => [alias, effectiveModelForHost(host, model)]), ); +} + +export function showModelPicker(host: SlashCommandHost, selectedValue: string = host.state.appState.model): void { + const models = pickerModelsForHost(host); const entries = Object.entries(models); if (entries.length === 0) { host.showNotice( @@ -553,6 +582,71 @@ async function persistModelSelection( return true; } +// --------------------------------------------------------------------------- +// Secondary model (`/secondary_model`) +// --------------------------------------------------------------------------- + +function showSecondaryModelPicker( + host: SlashCommandHost, + models: Record, + currentValue: string, + currentEffort: string | undefined, + selectedValue?: string, +): void { + host.mountEditorReplacement( + new TabbedModelSelectorComponent({ + models, + currentValue, + selectedValue, + currentThinkingEffort: currentEffort ?? 'off', + title: ' Select a secondary model (subagents)', + onSelect: ({ alias, thinking }) => { + host.restoreEditor(); + void performSecondaryModelSwitch(host, alias, thinking); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +/** + * Persist-first, then live-apply: the synthesized derived entry only exists in + * the core config after a reload, and `setSecondaryModel` validates the + * pointed alias against it. No session-only variant — a session-local recipe + * with patch fields would bind a derived alias the core config cannot resolve. + */ +async function performSecondaryModelSwitch( + host: SlashCommandHost, + alias: string, + effort: ThinkingEffort, +): Promise { + const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]); + try { + await host.harness.setConfig({ secondaryModel: { model: alias, defaultEffort: effort } }); + } catch (error) { + host.showError(`Failed to save secondary model: ${formatErrorMessage(error)}`); + return; + } + if (host.session !== undefined) { + try { + await host.session.setSecondaryModel(alias, effort); + } catch (error) { + host.showError( + `Saved ${displayName} as the secondary model, but failed to apply it to this session: ${formatErrorMessage(error)}`, + ); + return; + } + } + host.showStatus( + host.session === undefined + ? `Secondary model set to ${displayName} with thinking ${effort}; applies to new sessions.` + : `Secondary model set to ${displayName} with thinking ${effort}.`, + 'success', + ); +} + function showThemePicker(host: SlashCommandHost): void { host.mountEditorReplacement( new ThemeSelectorComponent({ diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index dcfb904733..b2feffaebe 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -29,6 +29,7 @@ import { handleEffortCommand, handleModelCommand, handlePlanCommand, + handleSecondaryModelCommand, handleThemeCommand, handleYoloCommand, showExperimentsPanel, @@ -71,6 +72,7 @@ export { handleEffortCommand, handleModelCommand, handlePlanCommand, + handleSecondaryModelCommand, handleThemeCommand, handleYoloCommand, showModelPicker, @@ -303,6 +305,9 @@ async function handleBuiltInSlashCommand( case 'model': await handleModelCommand(host, args); return; + case 'secondary_model': + await handleSecondaryModelCommand(host, args); + return; case 'effort': await handleEffortCommand(host, args); return; diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 063bcd7bfe..2cea404918 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -184,6 +184,14 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 100, availability: 'always', }, + { + name: 'secondary_model', + aliases: [], + description: 'Configure the secondary model for subagents', + priority: 90, + availability: 'always', + experimentalFlag: 'secondary-model', + }, { name: 'effort', aliases: ['thinking'], diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index 64646e0229..64537df22e 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -67,6 +67,8 @@ export interface ModelSelectorOptions { /** Live thinking effort of the currently active model (e.g. 'off', 'on', * 'high'). Used to highlight the active segment for the current model. */ readonly currentThinkingEffort: ThinkingEffort; + /** Overrides the default ' Select a model' title line. */ + readonly title?: string; /** When true, typed characters filter the list (fuzzy) and a search line is shown. */ readonly searchable?: boolean; /** Items per page. Lists longer than this paginate (PgUp/PgDn). */ @@ -289,7 +291,7 @@ export class ModelSelectorComponent extends Container implements Focusable { const lines: string[] = [ currentTheme.fg('primary', '─'.repeat(width)), - currentTheme.boldFg('primary', ' Select a model') + titleSuffix, + currentTheme.boldFg('primary', this.opts.title ?? ' Select a model') + titleSuffix, currentTheme.fg('textMuted', ' ' + hintParts.join(' · ')), ]; if (this.opts.warning !== undefined) { diff --git a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts index 8adc5efa14..d94de3b06d 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts @@ -40,6 +40,9 @@ export interface TabbedModelSelectorOptions { readonly currentValue: string; readonly selectedValue?: string; readonly currentThinkingEffort: string; + /** Forwarded to each inner selector; overrides the default ' Select a model' + * title line (e.g. the secondary-model picker). */ + readonly title?: string; /** When set, the tab for this provider id is initially active instead of the * tab derived from `currentValue`. */ readonly initialTabId?: string; @@ -180,6 +183,7 @@ function makeSelector( currentValue: opts.currentValue, ...(selectedValue !== undefined ? { selectedValue } : {}), currentThinkingEffort: opts.currentThinkingEffort, + title: opts.title, searchable: true, providerSwitchHint: true, warning: opts.warning, diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index bc4c5894fc..bf875b52b4 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -166,6 +166,7 @@ describe('built-in slash command registry', () => { 'plan', 'reload', 'reload-tui', + 'secondary_model', 'sessions', 'settings', 'status', @@ -188,4 +189,11 @@ describe('built-in slash command registry', () => { expect(resolveSlashCommandAvailability(reload!, '')).toBe('idle-only'); expect(resolveSlashCommandAvailability(reloadTui!, '')).toBe('always'); }); + + it('gates secondary_model behind the secondary-model experiment, always available', () => { + const command = findBuiltInSlashCommand('secondary_model'); + expect(command).toBeDefined(); + expect((command as KimiSlashCommand).experimentalFlag).toBe('secondary-model'); + expect(resolveSlashCommandAvailability(command!, '')).toBe('always'); + }); }); diff --git a/apps/kimi-code/test/tui/commands/secondary-model.test.ts b/apps/kimi-code/test/tui/commands/secondary-model.test.ts new file mode 100644 index 0000000000..4b50b0b0c0 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/secondary-model.test.ts @@ -0,0 +1,157 @@ +import type { ModelAlias, ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import type { SlashCommandHost } from '#/tui/commands'; +import { handleSecondaryModelCommand } from '#/tui/commands/config'; +import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector'; + +interface PickerOptions { + readonly models: Record; + readonly currentValue: string; + readonly currentThinkingEffort: string; + readonly title?: string; + readonly onSelect: (selection: { alias: string; thinking: ThinkingEffort }) => void; +} + +function model(name: string): ModelAlias { + return { + provider: 'test', + model: name, + maxContextSize: 200_000, + displayName: name, + } as unknown as ModelAlias; +} + +function makeHost(options?: { + readonly withSession?: boolean; + readonly secondaryModel?: { model: string; defaultEffort?: string }; +}) { + const session = options?.withSession === false + ? undefined + : { setSecondaryModel: vi.fn(async () => {}) }; + const host = { + state: { + appState: { + availableModels: { + k2: model('k2'), + cheap: model('cheap'), + // The synthesized derived entry must never be selectable. + '__secondary__': model('cheap'), + }, + availableProviders: {}, + transcriptEntries: [], + }, + transcriptEntries: [], + }, + authFlow: { + refreshOAuthProviderModels: vi.fn(async () => undefined), + }, + harness: { + getConfig: vi.fn(async () => ({ + providers: {}, + secondaryModel: options?.secondaryModel, + })), + setConfig: vi.fn(async () => ({ providers: {} })), + }, + session, + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + showStatus: vi.fn(), + showError: vi.fn(), + showNotice: vi.fn(), + track: vi.fn(), + } as unknown as SlashCommandHost & { + harness: { + getConfig: ReturnType; + setConfig: ReturnType; + }; + mountEditorReplacement: ReturnType; + showStatus: ReturnType; + showError: ReturnType; + showNotice: ReturnType; + }; + return { host, session }; +} + +function mountedPicker(host: { mountEditorReplacement: ReturnType }): PickerOptions { + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + const component = host.mountEditorReplacement.mock.calls[0]![0]; + expect(component).toBeInstanceOf(TabbedModelSelectorComponent); + return (component as unknown as { opts: PickerOptions }).opts; +} + +describe('handleSecondaryModelCommand', () => { + it('opens the picker filtered to user models, with the configured recipe as current', async () => { + const { host } = makeHost({ secondaryModel: { model: 'cheap', defaultEffort: 'high' } }); + + await handleSecondaryModelCommand(host, ''); + + const opts = mountedPicker(host); + expect(Object.keys(opts.models)).toEqual(['k2', 'cheap']); + expect(opts.currentValue).toBe('cheap'); + expect(opts.currentThinkingEffort).toBe('high'); + expect(opts.title).toContain('secondary model'); + }); + + it('persists first, then live-applies the selection to the session', async () => { + const { host, session } = makeHost(); + + await handleSecondaryModelCommand(host, ''); + mountedPicker(host).onSelect({ alias: 'k2', thinking: 'high' }); + + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalled(); + }); + expect(host.harness.setConfig).toHaveBeenCalledWith({ + secondaryModel: { model: 'k2', defaultEffort: 'high' }, + }); + expect(session!.setSecondaryModel).toHaveBeenCalledWith('k2', 'high'); + expect(host.harness.setConfig.mock.invocationCallOrder[0]).toBeLessThan( + session!.setSecondaryModel.mock.invocationCallOrder[0]!, + ); + expect(host.showError).not.toHaveBeenCalled(); + }); + + it('persists only when there is no session', async () => { + const { host } = makeHost({ withSession: false }); + + await handleSecondaryModelCommand(host, ''); + mountedPicker(host).onSelect({ alias: 'k2', thinking: 'off' }); + + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalled(); + }); + expect(host.harness.setConfig).toHaveBeenCalledWith({ + secondaryModel: { model: 'k2', defaultEffort: 'off' }, + }); + expect(host.showStatus.mock.calls[0]![0]).toContain('new sessions'); + }); + + it('rejects an unknown alias argument without opening the picker', async () => { + const { host } = makeHost(); + + await handleSecondaryModelCommand(host, 'nope'); + + expect(host.showError).toHaveBeenCalledWith('Unknown model alias: nope'); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('rejects the synthesized derived alias as an argument', async () => { + const { host } = makeHost(); + + await handleSecondaryModelCommand(host, '__secondary__'); + + expect(host.showError).toHaveBeenCalledWith('Unknown model alias: __secondary__'); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('shows a notice when no models are configured', async () => { + const { host } = makeHost(); + host.state.appState.availableModels = {}; + + await handleSecondaryModelCommand(host, ''); + + expect(host.showNotice).toHaveBeenCalled(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts index 3f42874861..f6ffc64961 100644 --- a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts @@ -132,6 +132,22 @@ describe('TabbedModelSelectorComponent', () => { expect(hint!.indexOf('Tab toggle provider')).toBeLessThan(hint!.indexOf('↑↓ navigate')); }); + it('renders the default title, and a custom title when provided', () => { + expect(strip(make().component.render(120).join('\n'))).toContain('Select a model'); + + const titled = new TabbedModelSelectorComponent({ + models: { k2: model('Kimi K2', 'managed:kimi-code') }, + currentValue: 'k2', + currentThinkingEffort: 'off', + title: ' Select a secondary model (subagents)', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const out = strip(titled.render(120).join('\n')); + expect(out).toContain('Select a secondary model (subagents)'); + expect(out).not.toContain('Select a model '); + }); + it('keeps the tab strip between hint and list when a warning line is present', () => { const component = new TabbedModelSelectorComponent({ models: { diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 8871d98c39..0bf22bb499 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -194,6 +194,8 @@ The secondary model is a second model pointer next to the primary `default_model This feature is experimental and disabled by default. Enable it with `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`, or the master `KIMI_CODE_EXPERIMENTAL_FLAG=1`. It takes effect in every launch mode, including the interactive TUI. +In the interactive TUI, the [`/secondary_model`](../reference/slash-commands.md) command opens a model picker that writes this section and live-applies it to the current session, so newly spawned subagents bind the new secondary model right away. + | Field | Type | Default | Description | | --- | --- | --- | --- | | `model` | `string` | — | A model id from your configured `[models]` (any provider, not limited to Kimi models) | diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index d3ab8bf4cb..2b2d404baa 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -16,6 +16,7 @@ Some commands are only available in the idle state. Executing these commands whi | `/logout` | — | Clear credentials for the currently selected account | No | | `/provider` | — | Open the interactive provider manager to view, add, and remove configured providers. See [Platforms & Models — `/provider` and provider management](../configuration/providers.md#provider-与供应商管理) | Yes | | `/model` | — | Switch the LLM model used in the current session | Yes | +| `/secondary_model` | — | Configure the secondary model used by subagents (writes the [`[secondary_model]`](../configuration/config-files.md#secondary_model) section and applies to the current session immediately). Requires the `secondary-model` experiment | Yes | | `/settings` | `/config` | Open the settings panel inside the TUI | Yes | | `/experiments` | `/experimental` | Open the experimental feature panel | Yes | | `/permission` | — | Select a permission mode | Yes | diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index a134bafa07..595b816f93 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -194,6 +194,8 @@ display_name = "Kimi for Coding (custom)" 该功能目前是实验功能,默认关闭。通过 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1` 启用,或使用 master `KIMI_CODE_EXPERIMENTAL_FLAG=1`。它在包括交互式 TUI 在内的所有启动方式下生效。 +在交互式 TUI 中,可以使用 [`/secondary_model`](../reference/slash-commands.md) 命令打开模型选择器来设置该配置:选择后会写入本小节配置,并在当前会话立即生效——之后派生的子 Agent 会直接绑定新的第二模型。 + | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `model` | `string` | — | 已配置 `[models]` 中的模型 id(不限 kimi 模型,可用任意供应商) | diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index a1882cf87e..e49caf2ca3 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -16,6 +16,7 @@ | `/logout` | — | 清除当前所选账号的凭据 | 否 | | `/provider` | — | 打开交互式供应商管理器,查看、添加和删除已配置的供应商。详见[平台与模型 — `/provider` 与供应商管理](../configuration/providers.md#provider-与供应商管理) | 是 | | `/model` | — | 切换当前会话使用的 LLM 模型 | 是 | +| `/secondary_model` | — | 配置子 Agent 使用的第二模型(写入 [`[secondary_model]`](../configuration/config-files.md#secondary_model) 配置并在当前会话立即生效)。需开启 `secondary-model` 实验功能 | 是 | | `/settings` | `/config` | 打开 TUI 内的设置面板 | 是 | | `/experiments` | `/experimental` | 打开实验功能面板 | 是 | | `/permission` | — | 选择权限模式 | 是 | diff --git a/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.md b/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.md index f9b6946724..8590114244 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.md +++ b/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.md @@ -20,7 +20,7 @@ echo "$HOME/.kimi-code" Use the first line when it is non-empty; otherwise use the second line. In the rest of this skill, `` means that resolved root — **never assume `~/.kimi-code`**. -- **`config.toml`** — agent / runtime settings: `default_model`, `providers`, `models`, `thinking`, `permission`, `hooks`, `loop_control`, etc. +- **`config.toml`** — agent / runtime settings: `default_model`, `secondary_model` (subagent model), `providers`, `models`, `thinking`, `permission`, `hooks`, `loop_control`, etc. - **`tui.toml`** — terminal-UI / client preferences: `theme`, `[editor].command`, `[notifications]`, `[upgrade].auto_install` (auto-update). These can usually also be changed with the interactive commands `/config`, `/theme`, `/editor`, which is easier — prefer pointing the user at those. The "read → copy → Edit → validate → back up → overwrite" flow below applies to both files; only **which reload command applies** differs (see Capability 4). diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index c347d5728c..74a76345b2 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -118,7 +118,13 @@ export class Agent { return this._kaos; } - readonly kimiConfig?: KimiConfig; + /** + * The session config snapshot this agent reads (loop control, subagent + * binding descriptions, ...). Mutable via {@link updateKimiConfig} so the + * session can push live config updates (e.g. a `/secondary_model` switch) + * to already-instantiated agents. + */ + kimiConfig?: KimiConfig; readonly homedir?: string; readonly mediaOriginalsDir?: string; readonly rpc?: Partial; @@ -442,6 +448,11 @@ export class Agent { this.tools.setActiveTools(profile.tools, profile.disallowedTools); } + /** Push a refreshed session config snapshot into this agent (see {@link Agent.kimiConfig}). */ + updateKimiConfig(config: KimiConfig | undefined): void { + this.kimiConfig = config; + } + setActiveProfile(profile: ResolvedAgentProfile, brandHome?: string): void { this.activeProfile = profile; this.brandHome = brandHome; diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index b77a038f46..0ccbcd2a19 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -247,6 +247,10 @@ export interface SetModelResult { readonly model: string; readonly providerName?: string | undefined; } +export interface SetSecondaryModelPayload { + readonly model: string; + readonly defaultEffort?: string; +} export interface CancelPlanPayload { readonly id?: string; } @@ -519,6 +523,7 @@ export interface SessionAPI extends AgentAPIWithId { reconnectMcpServer: (payload: ReconnectMcpServerPayload) => void; generateAgentsMd: (payload: EmptyPayload) => void; getSessionWarnings: (payload: EmptyPayload) => readonly SessionWarning[]; + setSecondaryModel: (payload: SetSecondaryModelPayload) => void; waitForBackgroundTasksOnPrint: (payload: EmptyPayload) => void; handlePrintMainTurnCompleted: (payload: EmptyPayload) => 'finish' | 'continue'; addAdditionalDir: (payload: AddAdditionalDirPayload) => AddAdditionalDirResult; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 7a2830756a..da202bb706 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -136,6 +136,7 @@ import type { SetPermissionPayload, SetPluginEnabledPayload, SetPluginMcpServerEnabledPayload, + SetSecondaryModelPayload, SetThinkingPayload, SkillSummary, PluginCommandDef, @@ -1049,6 +1050,14 @@ export class KimiCore implements PromisableMethods { return this.sessionApi(sessionId).getSessionWarnings(payload); } + setSecondaryModel({ sessionId, ...payload }: SessionScopedPayload): void { + // Pick up the just-persisted `[secondary_model]` recipe (and its + // synthesized derived entry) so the session's provider manager can + // resolve the pointed alias — same ordering as `setModel`. + this.reloadProviderManager(); + this.sessionApi(sessionId).setSecondaryModel(payload); + } + waitForBackgroundTasksOnPrint({ sessionId, ...payload }: SessionScopedPayload): Promise { return this.sessionApi(sessionId).waitForBackgroundTasksOnPrint(payload); } diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 64076e81f0..d8c801422c 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -23,6 +23,7 @@ import { resolveWorkspaceAdditionalDirs, resolveConfigValue, type BackgroundConfig, + type SecondaryModelConfig, type WorkspaceAdditionalDirsLoadResult, } from '../config'; import { makeErrorPayload } from '../errors'; @@ -51,6 +52,7 @@ import { wrapSubagentModelError, } from './subagent-binding'; import { + applySecondaryModelConfig, SECONDARY_DERIVED_MODEL_ALIAS, secondaryModelPatch, } from '../config/secondary-model'; @@ -233,8 +235,21 @@ export class Session { private agentsMdWarning: string | undefined; private printSteerDeadline: number | undefined; private printSteerTurns = 0; + /** + * The session's live config snapshot. Initialized from `options.config`; + * updated in place by {@link setSecondaryModel} so mid-session secondary-model + * switches reach the spawn-binding and tool-description readers without + * recreating the session. + */ + private runtimeConfig: KimiConfig | undefined; + + /** The session's current config snapshot (see {@link Session.runtimeConfig}). */ + get kimiConfig(): KimiConfig | undefined { + return this.runtimeConfig; + } constructor(public readonly options: SessionOptions) { + this.runtimeConfig = options.config; // Attach the per-session log sink up front so the constructor's // fire-and-forget `loadSkills` / `loadMcpServers` failures (and // anything else that races) land in the session log, not just global. @@ -789,6 +804,47 @@ export class Session { return warnings; } + /** + * Live-apply a new `[secondary_model]` recipe to this session: the spawn + * binding (`subagent-host`), the startup-warning computation, and every live + * agent's `kimiConfig` (tool descriptions, loop control) all read the + * session snapshot, so a mid-session `/secondary_model` switch takes effect + * for the next subagent spawn without recreating the session. The recipe is + * expected to be persisted (and the core config reloaded) BEFORE this call — + * the provider manager resolves the pointed alias (and the synthesized + * derived entry, when patch fields exist) against the core config. + */ + setSecondaryModel(secondary: SecondaryModelConfig): void { + const base = this.runtimeConfig; + if (base === undefined) { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + 'Cannot set the secondary model: the session has no config.', + ); + } + if (secondary.model !== undefined) { + try { + this.options.providerManager?.resolveProviderConfig(secondary.model); + } catch (error) { + throw wrapSubagentModelError(error, secondary.model, undefined); + } + } + const models = { ...base.models }; + delete models[SECONDARY_DERIVED_MODEL_ALIAS]; + const next = applySecondaryModelConfig({ ...base, models, secondaryModel: secondary }); + this.runtimeConfig = next; + this.secondaryModelWarnings = undefined; + for (const [, entry] of this.agents) { + if (entry instanceof Agent) { + entry.updateKimiConfig(next); + } else { + // Resume in flight: push the update once the agent materializes (the + // rejection is owned by the resume caller, not by this tap). + void entry.then(({ agent }) => agent.updateKimiConfig(next)).catch(() => {}); + } + } + } + private secondaryModelWarnings: SessionWarning[] | undefined; /** @@ -801,7 +857,7 @@ export class Session { private computeSecondaryModelWarnings(): SessionWarning[] { if (this.secondaryModelWarnings !== undefined) return [...this.secondaryModelWarnings]; const warnings: SessionWarning[] = []; - const secondary = resolveSecondaryModel(this.options.config, this.experimentalFlags); + const secondary = resolveSecondaryModel(this.kimiConfig, this.experimentalFlags); if (secondary?.model !== undefined) { const boundAlias = secondaryModelPatch(secondary) === undefined @@ -1090,7 +1146,7 @@ export class Session { type, kaos: this.toolKaos.withCwd(cwd), toolServices: this.options.toolServices, - config: this.options.config, + config: this.kimiConfig, homedir, // Session-level, shared across agents: originals persisted for // compression captions live with the session, not the agent. diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts index d12b44b6ec..ce44699f92 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -28,6 +28,7 @@ import type { SetActiveToolsPayload, SetModelPayload, SetPermissionPayload, + SetSecondaryModelPayload, SetThinkingPayload, SkillSummary, PluginCommandDef, @@ -108,6 +109,10 @@ export class SessionAPIImpl implements PromisableMethods { return this.session.getSessionWarnings(); } + setSecondaryModel(payload: SetSecondaryModelPayload): void { + this.session.setSecondaryModel({ model: payload.model, defaultEffort: payload.defaultEffort }); + } + waitForBackgroundTasksOnPrint(_payload: EmptyPayload): Promise { return this.session.waitForBackgroundTasksOnPrint(); } diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 92e6d5d25e..790e1b4d08 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -474,7 +474,7 @@ export class SessionSubagentHost { modelChoice?: SubagentModelChoice, ): SubagentModelBinding { const binding = resolveSubagentBinding( - this.session.options.config, + this.session.kimiConfig, this.session.experimentalFlags, { modelAlias: parent.config.modelAlias, thinkingEffort: parent.config.thinkingEffort }, modelChoice ?? profile.modelPreference, diff --git a/packages/agent-core/src/skill/builtin/update-config.md b/packages/agent-core/src/skill/builtin/update-config.md index f9b6946724..8590114244 100644 --- a/packages/agent-core/src/skill/builtin/update-config.md +++ b/packages/agent-core/src/skill/builtin/update-config.md @@ -20,7 +20,7 @@ echo "$HOME/.kimi-code" Use the first line when it is non-empty; otherwise use the second line. In the rest of this skill, `` means that resolved root — **never assume `~/.kimi-code`**. -- **`config.toml`** — agent / runtime settings: `default_model`, `providers`, `models`, `thinking`, `permission`, `hooks`, `loop_control`, etc. +- **`config.toml`** — agent / runtime settings: `default_model`, `secondary_model` (subagent model), `providers`, `models`, `thinking`, `permission`, `hooks`, `loop_control`, etc. - **`tui.toml`** — terminal-UI / client preferences: `theme`, `[editor].command`, `[notifications]`, `[upgrade].auto_install` (auto-update). These can usually also be changed with the interactive commands `/config`, `/theme`, `/editor`, which is easier — prefer pointing the user at those. The "read → copy → Edit → validate → back up → overwrite" flow below applies to both files; only **which reload command applies** differs (see Capability 4). diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index 1a5ab4b4d4..c38f98242b 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -9,6 +9,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Agent, AgentOptions } from '../../src/agent'; import { trimTrailingOpenToolExchange } from '../../src/agent/context/projector'; +import type { KimiConfig } from '../../src/config'; import { ProviderManager } from '../../src/session/provider-manager'; import type { ResolvedAgentProfile } from '../../src/profile'; import type { SDKSessionRPC } from '../../src/rpc'; @@ -697,6 +698,111 @@ describe('AgentAPI.startBtw', () => { }); }); +describe('Session.setSecondaryModel', () => { + const SECONDARY_BASE_CONFIG: KimiConfig = { + providers: { + test: { type: MOCK_PROVIDER.type, apiKey: MOCK_PROVIDER.apiKey }, + }, + models: { + [MOCK_PROVIDER.model]: { + provider: 'test', + model: MOCK_PROVIDER.model, + maxContextSize: 1_000_000, + }, + }, + }; + + async function makeSession(config?: KimiConfig): Promise { + const workDir = await makeTempDir(); + const sessionDir = await makeTempDir(); + return new Session({ + id: 'test-secondary-model', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + rpc: createSessionRpc([]), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + providerManager: testProviderManager(), + config, + }); + } + + it('live-applies the recipe to the session snapshot and live agents', async () => { + const session = await makeSession(SECONDARY_BASE_CONFIG); + try { + const { agent } = await session.createAgent( + { type: 'main', generate: createScriptedGenerate().generate }, + { profile: testProfile() }, + ); + + session.setSecondaryModel({ model: MOCK_PROVIDER.model }); + + expect(session.kimiConfig?.secondaryModel).toEqual({ model: MOCK_PROVIDER.model }); + expect(agent.kimiConfig?.secondaryModel).toEqual({ model: MOCK_PROVIDER.model }); + // A pointer-only recipe synthesizes no derived entry. + expect(session.kimiConfig?.models?.['__secondary__']).toBeUndefined(); + + // Agents created after the switch read the updated snapshot too. + const { agent: second } = await session.createAgent( + { type: 'sub', generate: createScriptedGenerate().generate }, + { profile: testProfile(), parentAgentId: 'main' }, + ); + expect(second.kimiConfig?.secondaryModel).toEqual({ model: MOCK_PROVIDER.model }); + } finally { + await session.close(); + } + }); + + it('synthesizes the derived entry for patch recipes and drops it for pointer-only ones', async () => { + const session = await makeSession(SECONDARY_BASE_CONFIG); + try { + session.setSecondaryModel({ model: MOCK_PROVIDER.model, defaultEffort: 'low' }); + const derived = session.kimiConfig?.models?.['__secondary__']; + expect(derived).toBeDefined(); + expect(derived?.overrides?.defaultEffort).toBe('low'); + + session.setSecondaryModel({ model: MOCK_PROVIDER.model }); + expect(session.kimiConfig?.models?.['__secondary__']).toBeUndefined(); + } finally { + await session.close(); + } + }); + + it('rejects a dangling pointer with the wrapped secondary-model error', async () => { + const session = await makeSession(SECONDARY_BASE_CONFIG); + try { + expect(() => session.setSecondaryModel({ model: 'missing-model' })).toThrow( + /\[secondary_model\]\.model/, + ); + expect(session.kimiConfig?.secondaryModel).toBeUndefined(); + } finally { + await session.close(); + } + }); + + it('rejects when the session has no config', async () => { + const session = await makeSession(); + try { + expect(() => session.setSecondaryModel({ model: MOCK_PROVIDER.model })).toThrow(/no config/); + } finally { + await session.close(); + } + }); + + it('routes the RPC payload through SessionAPIImpl', async () => { + const session = await makeSession(SECONDARY_BASE_CONFIG); + try { + const api = new SessionAPIImpl(session); + await api.setSecondaryModel({ model: MOCK_PROVIDER.model, defaultEffort: 'low' }); + expect(session.kimiConfig?.secondaryModel).toEqual({ + model: MOCK_PROVIDER.model, + defaultEffort: 'low', + }); + } finally { + await session.close(); + } + }); +}); + async function makeTempDir(): Promise { const dir = await mkdtemp(join(tmpdir(), 'kimi-core-init-')); tempDirs.push(dir); diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 6c47bdc5b3..52edef997f 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -1842,6 +1842,9 @@ function fakeSession( config: sessionOptions?.config, providerManager: sessionOptions?.providerManager, }, + get kimiConfig() { + return sessionOptions?.config; + }, experimentalFlags: sessionOptions?.experimentalFlags ?? new FlagResolver({}), agentCatalog: testAgentCatalog(), metadata: { diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 3250b45758..db7674e7df 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -69,6 +69,9 @@ export type { LogContext, LogLevel, LogPayload, Logger } from '@moonshot-ai/agen export { effectiveModelAlias, loadRuntimeConfigSafe, resolveConfigPath } from '@moonshot-ai/agent-core'; export { limitAgentReplayByTurns } from '@moonshot-ai/agent-core'; export { parseAgentFileText, resolveAgentPath } from '@moonshot-ai/agent-core'; +// The synthesized `[models]` alias a `[secondary_model]` recipe with patch +// fields materializes at runtime — hosts filter it out of model pickers. +export { SECONDARY_DERIVED_MODEL_ALIAS } from '@moonshot-ai/agent-core'; // Process-wide HTTP proxy bootstrap — installed once at CLI startup so all // outbound fetch honors HTTP_PROXY / HTTPS_PROXY / NO_PROXY. diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 1315812c0b..17fa77f36b 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -100,6 +100,11 @@ export interface SetSessionThinkingRpcInput extends SessionIdRpcInput { readonly effort: string; } +export interface SetSessionSecondaryModelRpcInput extends SessionIdRpcInput { + readonly model: string; + readonly defaultEffort?: string; +} + export interface SetSessionPermissionRpcInput extends SessionIdRpcInput { readonly mode: PermissionMode; } @@ -431,6 +436,15 @@ export abstract class SDKRpcClientBase { }); } + async setSecondaryModel(input: SetSessionSecondaryModelRpcInput): Promise { + const rpc = await this.getRpc(); + return rpc.setSecondaryModel({ + sessionId: input.sessionId, + model: input.model, + defaultEffort: input.defaultEffort, + }); + } + async setPermission(input: SetSessionPermissionRpcInput): Promise { const rpc = await this.getRpc(); return rpc.setPermission({ diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 571c2355ff..6c734b72fb 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -207,6 +207,26 @@ export class Session { await this.rpc.setThinking({ sessionId: this.id, effort: normalized }); } + /** + * Live-apply the `[secondary_model]` recipe to this session (subagent model + * binding). The recipe should be persisted via `KimiHarness.setConfig` first + * so the core config (and its synthesized derived entry) is reloaded before + * the session snapshot is updated — mirroring the `/secondary_model` flow. + */ + async setSecondaryModel(model: string, effort?: ThinkingEffort): Promise { + this.ensureOpen(); + const normalized = normalizeRequiredString( + model, + 'Session secondary model cannot be empty', + ErrorCodes.SESSION_MODEL_EMPTY, + ); + await this.rpc.setSecondaryModel({ + sessionId: this.id, + model: normalized, + defaultEffort: effort, + }); + } + async setPermission(mode: PermissionMode): Promise { this.ensureOpen(); if (!isPermissionMode(mode)) { From fe5ebbe123ccd55a6b9f1ce7886a75231b2d8efe Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 27 Jul 2026 18:53:39 +0800 Subject: [PATCH 15/30] feat(tui): show the bound model in subagent run stats Subagents report their model alias via agent.status.updated after spawn; resolve it to a display name and surface it in tool-call subagent stats and agent-group rows. --- .changeset/subagent-model-display.md | 5 ++++ .../tui/components/messages/agent-group.ts | 4 ++- .../src/tui/components/messages/tool-call.ts | 15 ++++++++-- .../tui/controllers/subagent-event-handler.ts | 9 ++++++ .../components/messages/agent-group.test.ts | 20 +++++++++++++ .../tui/components/messages/tool-call.test.ts | 28 +++++++++++++++++++ 6 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 .changeset/subagent-model-display.md diff --git a/.changeset/subagent-model-display.md b/.changeset/subagent-model-display.md new file mode 100644 index 0000000000..1ef9cafb22 --- /dev/null +++ b/.changeset/subagent-model-display.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show the model each subagent is running on in the subagent card header and the agent-group rows. diff --git a/apps/kimi-code/src/tui/components/messages/agent-group.ts b/apps/kimi-code/src/tui/components/messages/agent-group.ts index 7e47529456..1fe4961e9f 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-group.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-group.ts @@ -301,7 +301,9 @@ function formatBreakdownParts(counts: PhaseCounts): string[] { } function formatStats(snap: ToolCallSubagentSnapshot): string { - const parts = [`${String(snap.toolCount)} tool${snap.toolCount === 1 ? '' : 's'}`]; + const parts: string[] = []; + if (snap.model !== undefined) parts.push(snap.model); + parts.push(`${String(snap.toolCount)} tool${snap.toolCount === 1 ? '' : 's'}`); if (snap.elapsedSeconds !== undefined) parts.push(formatElapsed(snap.elapsedSeconds)); if (snap.tokens > 0) parts.push(formatTokens(snap.tokens)); return currentTheme.dim(` · ${parts.join(' · ')}`); diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 01f76053a0..a669f6299e 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -93,6 +93,8 @@ export interface ToolCallSubagentSnapshot { readonly toolName: string; readonly toolCallDescription: string; readonly agentName: string | undefined; + /** Display name of the model the subagent is bound to, when known (live only). */ + readonly model?: string; readonly phase: SubagentPhase | undefined; readonly toolCount: number; readonly elapsedSeconds: number | undefined; @@ -595,6 +597,8 @@ export class ToolCallComponent extends Container { private backgroundTaskTerminalPhase: 'done' | 'failed' | undefined; private subagentContextTokens: number | undefined; private subagentUsage: TokenUsage | undefined; + /** Display name of the model the subagent is bound to (from its `agent.status.updated`). */ + private subagentModel: string | undefined; private subagentResultSummary: string | undefined; private subagentError: string | undefined; private streamingProgressTimer: ReturnType | undefined; @@ -898,6 +902,7 @@ export class ToolCallComponent extends Container { toolName: this.toolCall.name, toolCallDescription: str(this.toolCall.args['description']) || str(this.toolCall.description), agentName: this.subagentAgentName, + model: this.subagentModel, phase: derivedPhase, toolCount: finished, elapsedSeconds: this.getSubagentElapsedSeconds(), @@ -1162,6 +1167,7 @@ export class ToolCallComponent extends Container { updateSubagentMetrics(payload: { contextTokens?: number | undefined; usage?: TokenUsage | undefined; + modelDisplay?: string | undefined; }): void { if (payload.contextTokens !== undefined && payload.contextTokens > 0) { this.subagentContextTokens = payload.contextTokens; @@ -1169,6 +1175,9 @@ export class ToolCallComponent extends Container { if (payload.usage !== undefined) { this.subagentUsage = payload.usage; } + if (payload.modelDisplay !== undefined) { + this.subagentModel = payload.modelDisplay; + } this.headerText.setText(this.buildHeader()); this.invalidate(); this.notifySnapshotChange(); @@ -1784,9 +1793,9 @@ export class ToolCallComponent extends Container { } private formatSingleSubagentStatsText(): string { - const parts = [ - `${String(this.subToolActivities.size)} tool${this.subToolActivities.size === 1 ? '' : 's'}`, - ]; + const parts: string[] = []; + if (this.subagentModel !== undefined) parts.push(this.subagentModel); + parts.push(`${String(this.subToolActivities.size)} tool${this.subToolActivities.size === 1 ? '' : 's'}`); const elapsed = this.getSubagentElapsedSeconds(); if (elapsed !== undefined) parts.push(formatElapsed(elapsed)); const tokens = diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index f2281ea54c..6227c6dc06 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -9,6 +9,7 @@ import { agentSwarmDescriptionFromArgs, agentSwarmGridHeightForTerminalRows, } from '../components/messages/agent-swarm-progress'; +import { modelDisplayName } from '../components/dialogs/model-selector'; import { MAIN_AGENT_ID } from '../constant/kimi-tui'; import type { BackgroundAgentMetadata, @@ -125,6 +126,14 @@ export class SubAgentEventHandler { toolCall.updateSubagentMetrics({ contextTokens: event.contextTokens, usage: totalUsage, + // The bound model alias rides every child status update (emitted right + // after spawn); surface it on the subagent card. `modelDisplayName` + // falls back to the alias itself when the entry is unknown (e.g. the + // synthesized `__secondary__` derived entry is missing). + modelDisplay: + event.model === undefined + ? undefined + : modelDisplayName(event.model, this.host.state.appState.availableModels[event.model]), }); } return true; diff --git a/apps/kimi-code/test/tui/components/messages/agent-group.test.ts b/apps/kimi-code/test/tui/components/messages/agent-group.test.ts index adf4d3b0b9..e567e96ad6 100644 --- a/apps/kimi-code/test/tui/components/messages/agent-group.test.ts +++ b/apps/kimi-code/test/tui/components/messages/agent-group.test.ts @@ -91,6 +91,26 @@ describe('AgentGroupComponent', () => { waiting.dispose(); }); + it('shows the bound model in the row stats once reported', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const running = createAgent('call_agent_1', 'inspect project', 'explore', ui); + startAgent(running, 'call_agent_1', 'explore'); + + group.attach('call_agent_1', running); + expect(renderText(group)).toContain('explore · inspect project · 0 tools'); + + running.updateSubagentMetrics({ modelDisplay: 'Kimi K2.5' }); + // Non-phase updates are throttled; flush the pending refresh. + vi.runOnlyPendingTimers(); + expect(renderText(group)).toContain('explore · inspect project · Kimi K2.5 · 0 tools'); + + group.dispose(); + running.dispose(); + }); + it('shows the Ctrl+B hint while agents are running and hides it once all are backgrounded', () => { vi.useFakeTimers(); vi.setSystemTime(0); diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index f5ff1fc1e4..4426e0e5a4 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -1033,6 +1033,34 @@ describe('ToolCallComponent', () => { expect(out).not.toContain('summary fallback'); }); + it('shows the bound model in the subagent header and group snapshot once reported', () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + const component = new ToolCallComponent( + { + id: 'call_agent_model', + name: 'Agent', + args: { description: 'explore project' }, + }, + undefined, + ); + component.onSubagentSpawned({ + agentId: 'sub_model_1', + agentName: 'explore', + runInBackground: false, + }); + + let out = strip(component.render(120).join('\n')); + expect(out).toContain('Explore Agent Queued (explore project) · 0 tools'); + expect(out).not.toContain('Kimi K2.5'); + + component.updateSubagentMetrics({ modelDisplay: 'Kimi K2.5' }); + + out = strip(component.render(120).join('\n')); + expect(out).toContain('Explore Agent Queued (explore project) · Kimi K2.5 · 0 tools'); + expect(component.getSubagentSnapshot().model).toBe('Kimi K2.5'); + }); + it('shows Backgrounded after a foreground subagent is detached, even after setResult', () => { vi.useFakeTimers(); vi.setSystemTime(0); From 8c83203339620b400b8448740fb712e8f6609c59 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 27 Jul 2026 19:38:06 +0800 Subject: [PATCH 16/30] fix(agent-core): validate agent profile before session persistence --- packages/agent-core/src/rpc/core-impl.ts | 33 +++++++- packages/agent-core/src/session/index.ts | 44 +++++------ .../src/session/main-agent-profile.ts | 19 +++++ .../test/create-session-transport.test.ts | 76 +++++++++++++++++++ 4 files changed, 144 insertions(+), 28 deletions(-) create mode 100644 packages/agent-core/src/session/main-agent-profile.ts diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index da202bb706..7c94ca8809 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -49,8 +49,10 @@ import { type BeginAuthorizationResult, type SessionMcpConfig, } from '../mcp'; +import { SessionAgentProfileCatalog } from '../profile'; import { Session, type SessionMeta, type SessionSkillConfig } from '../session'; import { exportSessionDirectory } from '../session/export'; +import { resolveMainAgentProfile } from '../session/main-agent-profile'; import { registerBuiltinSkills, SessionSkillRegistry, @@ -336,6 +338,32 @@ export class KimiCore implements PromisableMethods { ...localWorkspaceDirs.additionalDirs, ...callerAdditionalDirs, ]); + const agentCatalogWarnings: Array<{ readonly message: string; readonly error?: unknown }> = []; + const reportAgentCatalogWarnings = (logger: Logger): void => { + for (const warning of agentCatalogWarnings) { + logger.warn( + warning.message, + warning.error === undefined ? undefined : { error: warning.error }, + ); + } + }; + const agentCatalog = new SessionAgentProfileCatalog({ + workDir, + brandHomeDir: this.homeDir, + osHomeDir: this.userHomeDir, + extraDirs: config.extraAgentDirs, + explicitFiles: options.agentFiles, + warn: (message, error) => { + agentCatalogWarnings.push({ message, error }); + }, + }); + try { + await agentCatalog.ready; + resolveMainAgentProfile(agentCatalog, options.agentProfile); + } catch (error) { + reportAgentCatalogWarnings(log.createChild({ sessionId: id })); + throw error; + } const summary = await this.sessionStore.create({ id, workDir, @@ -380,9 +408,7 @@ export class KimiCore implements PromisableMethods { permissionRules: config.permission?.rules, skills: this.resolveSessionSkillConfig(config), agents: { - userHomeDir: this.userHomeDir, - explicitFiles: options.agentFiles, - extraDirs: config.extraAgentDirs, + catalog: agentCatalog, profileName: options.agentProfile, }, mcpConfig, @@ -396,6 +422,7 @@ export class KimiCore implements PromisableMethods { drainAgentTasksOnStop: options.drainAgentTasksOnStop, }); try { + reportAgentCatalogWarnings(session.log); session.metadata = { ...session.metadata, createdAt: new Date(summary.createdAt).toISOString(), diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index d8c801422c..ad68f77b17 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -71,6 +71,7 @@ import type { ToolServices } from '../tools/support/services'; import { FlagResolver, type ExperimentalFlagResolver } from '../flags'; import { ImageLimits } from '../tools/support/image-limits'; import { abortError } from '../utils/abort'; +import { resolveMainAgentProfile } from './main-agent-profile'; export interface SessionOptions { readonly kaos: Kaos; @@ -128,6 +129,8 @@ export interface SessionAgentCatalogConfig { readonly explicitFiles?: readonly string[]; readonly extraDirs?: readonly string[]; readonly profileName?: string; + /** Already-loaded catalog prepared before a persistent session is created. */ + readonly catalog?: SessionAgentProfileCatalog; } export interface AgentMeta { @@ -288,16 +291,18 @@ export class Session { this.mcp.onStatusChange((entry) => { this.onMcpServerStatusChange(entry); }); - this.agentCatalog = new SessionAgentProfileCatalog({ - workDir: options.kaos.getcwd(), - brandHomeDir: options.kimiHomeDir ?? join(homedir(), '.kimi-code'), - osHomeDir: options.agents?.userHomeDir ?? homedir(), - extraDirs: options.agents?.extraDirs ?? options.config?.extraAgentDirs, - explicitFiles: options.agents?.explicitFiles, - warn: (message, error) => { - this.log.warn(message, error === undefined ? undefined : { error }); - }, - }); + this.agentCatalog = + options.agents?.catalog ?? + new SessionAgentProfileCatalog({ + workDir: options.kaos.getcwd(), + brandHomeDir: options.kimiHomeDir ?? join(homedir(), '.kimi-code'), + osHomeDir: options.agents?.userHomeDir ?? homedir(), + extraDirs: options.agents?.extraDirs ?? options.config?.extraAgentDirs, + explicitFiles: options.agents?.explicitFiles, + warn: (message, error) => { + this.log.warn(message, error === undefined ? undefined : { error }); + }, + }); this.skillsReady = this.loadSkills() .catch((error: unknown) => { this.log.error('skills load failed', error); @@ -406,21 +411,10 @@ export class Session { // sees file-defined profiles. await this.skillsReady; this.agentProfileSnapshot = this.agentCatalog.snapshot(); - const profileName = this.options.agents?.profileName; - const profile = - profileName === undefined - ? this.agentCatalog.getDefault() - : this.agentCatalog.get(profileName); - if (profile === undefined) { - const available = this.agentCatalog - .list() - .map((candidate) => candidate.name) - .join(', '); - throw new KimiError( - ErrorCodes.AGENT_NOT_FOUND, - `Agent profile "${profileName}" was not found. Available profiles: ${available}`, - ); - } + const profile = resolveMainAgentProfile( + this.agentCatalog, + this.options.agents?.profileName, + ); const { agent } = await this.createAgent({ type: 'main' }, { profile, }); diff --git a/packages/agent-core/src/session/main-agent-profile.ts b/packages/agent-core/src/session/main-agent-profile.ts new file mode 100644 index 0000000000..1171493b79 --- /dev/null +++ b/packages/agent-core/src/session/main-agent-profile.ts @@ -0,0 +1,19 @@ +import { ErrorCodes, KimiError } from '#/errors'; +import type { ResolvedAgentProfile, SessionAgentProfileCatalog } from '../profile'; + +export function resolveMainAgentProfile( + catalog: SessionAgentProfileCatalog, + profileName?: string, +): ResolvedAgentProfile { + const profile = profileName === undefined ? catalog.getDefault() : catalog.get(profileName); + if (profile !== undefined) return profile; + + const available = catalog + .list() + .map((candidate) => candidate.name) + .join(', '); + throw new KimiError( + ErrorCodes.AGENT_NOT_FOUND, + `Agent profile "${profileName}" was not found. Available profiles: ${available}`, + ); +} diff --git a/packages/node-sdk/test/create-session-transport.test.ts b/packages/node-sdk/test/create-session-transport.test.ts index bdedb1ffea..ff09abf3be 100644 --- a/packages/node-sdk/test/create-session-transport.test.ts +++ b/packages/node-sdk/test/create-session-transport.test.ts @@ -570,6 +570,82 @@ effort = "medium" } }); + it('does not persist a session record when the requested agent profile is missing', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const harness = createKimiHarness({ + identity: TEST_IDENTITY, + homeDir, + }); + + try { + await expect( + harness.createSession({ + id: 'ses_missing_agent_profile', + workDir, + agentProfile: 'missing-agent', + }), + ).rejects.toMatchObject({ + name: 'KimiError', + code: 'agent.not_found', + }); + expect(await harness.listSessions({ workDir })).toEqual([]); + expect(existsSync(join(homeDir, 'session_index.jsonl'))).toBe(false); + } finally { + await harness.close(); + } + }); + + it('allows the session ID to be reused after agent profile selection fails', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const harness = createKimiHarness({ + identity: TEST_IDENTITY, + homeDir, + }); + + try { + await expect( + harness.createSession({ + id: 'ses_reusable_after_missing_profile', + workDir, + agentProfile: 'missing-agent', + }), + ).rejects.toMatchObject({ code: 'agent.not_found' }); + + await expect( + harness.createSession({ + id: 'ses_reusable_after_missing_profile', + workDir, + }), + ).resolves.toMatchObject({ id: 'ses_reusable_after_missing_profile' }); + } finally { + await harness.close(); + } + }); + + it('does not persist a session record when an explicit agent file cannot be loaded', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const harness = createKimiHarness({ + identity: TEST_IDENTITY, + homeDir, + }); + + try { + await expect( + harness.createSession({ + id: 'ses_missing_explicit_agent_file', + workDir, + agentFiles: [join(workDir, 'missing-agent.md')], + }), + ).rejects.toThrow(/missing-agent\.md/); + expect(await harness.listSessions({ workDir })).toEqual([]); + } finally { + await harness.close(); + } + }); + it('closes active runtime handles through closeSession, session.close, and close', async () => { const homeDir = await makeTempDir(); const workDir = await makeTempDir(); From 97cb8f3af2269a034aa11ffcc1d8b1bae7a0445b Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 27 Jul 2026 19:39:39 +0800 Subject: [PATCH 17/30] fix(agent-core): refresh subagent tools after model switch --- packages/agent-core/src/agent/index.ts | 5 ++- packages/agent-core/test/session/init.test.ts | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 74a76345b2..db769d8958 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -448,9 +448,12 @@ export class Agent { this.tools.setActiveTools(profile.tools, profile.disallowedTools); } - /** Push a refreshed session config snapshot into this agent (see {@link Agent.kimiConfig}). */ + /** Push a refreshed session config snapshot and rebuild config-dependent builtin tools. */ updateKimiConfig(config: KimiConfig | undefined): void { this.kimiConfig = config; + if (this.config.hasProvider) { + this.tools.refreshBuiltinTools(); + } } setActiveProfile(profile: ResolvedAgentProfile, brandHome?: string): void { diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index c38f98242b..57419f5918 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -10,6 +10,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Agent, AgentOptions } from '../../src/agent'; import { trimTrailingOpenToolExchange } from '../../src/agent/context/projector'; import type { KimiConfig } from '../../src/config'; +import { FlagResolver } from '../../src/flags'; import { ProviderManager } from '../../src/session/provider-manager'; import type { ResolvedAgentProfile } from '../../src/profile'; import type { SDKSessionRPC } from '../../src/rpc'; @@ -722,6 +723,9 @@ describe('Session.setSecondaryModel', () => { rpc: createSessionRpc([]), skills: { explicitDirs: [join(workDir, 'missing-skills')] }, providerManager: testProviderManager(), + experimentalFlags: new FlagResolver({ + KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL: '1', + }), config, }); } @@ -752,6 +756,33 @@ describe('Session.setSecondaryModel', () => { } }); + it('refreshes collaboration tool descriptions when live-applying a recipe', async () => { + const session = await makeSession(SECONDARY_BASE_CONFIG); + try { + const { agent } = await session.createAgent( + { type: 'main', generate: createScriptedGenerate().generate }, + { profile: { ...testProfile(), tools: ['Agent', 'AgentSwarm'] } }, + ); + agent.config.update({ modelAlias: MOCK_PROVIDER.model, thinkingEffort: 'off' }); + + const descriptionsBefore = Object.fromEntries( + agent.tools.loopTools.map((tool) => [tool.name, tool.description]), + ); + expect(descriptionsBefore['Agent']).not.toContain('Available models'); + expect(descriptionsBefore['AgentSwarm']).not.toContain('Available models'); + + session.setSecondaryModel({ model: MOCK_PROVIDER.model }); + + const descriptionsAfter = Object.fromEntries( + agent.tools.loopTools.map((tool) => [tool.name, tool.description]), + ); + expect(descriptionsAfter['Agent']).toContain('- secondary: mock-model'); + expect(descriptionsAfter['AgentSwarm']).toContain('- secondary: mock-model'); + } finally { + await session.close(); + } + }); + it('synthesizes the derived entry for patch recipes and drops it for pointer-only ones', async () => { const session = await makeSession(SECONDARY_BASE_CONFIG); try { From 4332d7234ed0c5de40886b93aae1d63a005852c6 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 27 Jul 2026 19:49:40 +0800 Subject: [PATCH 18/30] fix(agent-core): show subagent model preferences --- packages/agent-core/src/agent/tool/index.ts | 1 + .../src/tools/builtin/collaboration/agent.ts | 14 ++++- packages/agent-core/test/agent/tool.test.ts | 52 +++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index fb9ccd19fb..993837073a 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -844,6 +844,7 @@ export class ToolManager { allowBackground, log: this.agent.log, subagentTimeoutMs: resolveSubagentTimeoutMs(this.agent.kimiConfig?.subagent?.timeoutMs), + showModelPreferences: this.agent.experimentalFlags.enabled('secondary-model'), subagentModelDescription: buildSubagentModelDescriptions( this.agent.kimiConfig, this.agent.experimentalFlags, diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.ts b/packages/agent-core/src/tools/builtin/collaboration/agent.ts index e8f796ac4e..60e7ac5719 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.ts @@ -123,6 +123,7 @@ export class AgentTool implements BuiltinTool { allowBackground?: boolean | undefined; subagentTimeoutMs?: number | undefined; subagentModelDescription?: string; + showModelPreferences?: boolean; }, ) { const log = options?.log; @@ -130,7 +131,10 @@ export class AgentTool implements BuiltinTool { // `0` is preserved (not normalized): `0 ?? DEFAULT_SUBAGENT_TIMEOUT_MS` // stays `0`, and the BackgroundManager arms no timer for it. this.subagentTimeoutMs = options?.subagentTimeoutMs; - const typeLines = buildSubagentDescriptions(subagents); + const typeLines = buildSubagentDescriptions( + subagents, + options?.showModelPreferences ?? false, + ); const baseDescription = `${AGENT_DESCRIPTION_BASE}\n\n${ this.allowBackground ? AGENT_BACKGROUND_DESCRIPTION : AGENT_BACKGROUND_DISABLED_DESCRIPTION }`; @@ -386,7 +390,10 @@ function launchErrorMessage(error: unknown, signal: AbortSignal): string { return error instanceof Error ? error.message : String(error); } -function buildSubagentDescriptions(subagents: ResolvedAgentProfile['subagents']): string { +function buildSubagentDescriptions( + subagents: ResolvedAgentProfile['subagents'], + showModelPreferences: boolean, +): string { if (subagents === undefined) return ''; return Object.entries(subagents) .map(([name, subagent]) => { @@ -399,6 +406,9 @@ function buildSubagentDescriptions(subagents: ResolvedAgentProfile['subagents']) ); const shownTools = subagent.tools.filter((tool) => !deniedExact.has(tool)); const lines = [header]; + if (showModelPreferences && subagent.modelPreference !== undefined) { + lines.push(` Model preference: ${subagent.modelPreference}`); + } if (shownTools.length > 0) lines.push(` Tools: ${shownTools.join(', ')}`); if (subagent.disallowedTools !== undefined && subagent.disallowedTools.length > 0) { lines.push(` Disabled: ${subagent.disallowedTools.join(', ')}`); diff --git a/packages/agent-core/test/agent/tool.test.ts b/packages/agent-core/test/agent/tool.test.ts index b3aa2193ed..95e2c00ae7 100644 --- a/packages/agent-core/test/agent/tool.test.ts +++ b/packages/agent-core/test/agent/tool.test.ts @@ -1,3 +1,9 @@ +/** + * Scenario: Agent builtin-tool behavior and the tool contract exposed to the LLM. + * Responsibilities: active-tool policy, tool execution, and observable descriptions/schemas. + * Wiring: real Agent and ToolManager with only process/model/subagent boundaries stubbed. + * Run: cd packages/agent-core && ../../node_modules/.bin/vitest run test/agent/tool.test.ts + */ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -363,6 +369,52 @@ describe('Agent tools', () => { expect(ctx.agent.tools.loopTools.some((tool) => tool.name === 'AgentSwarm')).toBe(true); }); + it('shows the model preference for a subagent type when the experiment is enabled', () => { + const subagentHost = { + delegatableSubagents: vi.fn(() => ({ + coder: { + name: 'coder', + description: 'General coding.', + systemPrompt: () => 'coder prompt', + tools: ['Read'], + modelPreference: 'primary' as const, + }, + })), + } as unknown as SessionSubagentHost; + const ctx = testAgent({ + subagentHost, + experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS, { 'secondary-model': true }), + }); + ctx.configure({ tools: ['Agent'] }); + + const description = ctx.agent.tools.loopTools.find((tool) => tool.name === 'Agent')?.description; + + expect(description).toContain('- coder: General coding.\n Model preference: primary'); + }); + + it('hides model preferences when the experiment is disabled', () => { + const subagentHost = { + delegatableSubagents: vi.fn(() => ({ + coder: { + name: 'coder', + description: 'General coding.', + systemPrompt: () => 'coder prompt', + tools: ['Read'], + modelPreference: 'primary' as const, + }, + })), + } as unknown as SessionSubagentHost; + const ctx = testAgent({ + subagentHost, + experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS), + }); + ctx.configure({ tools: ['Agent'] }); + + const description = ctx.agent.tools.loopTools.find((tool) => tool.name === 'Agent')?.description; + + expect(description).not.toContain('Model preference:'); + }); + it('self-heals the builtin tool table when the provider becomes resolvable after construction', () => { // The ProviderManager reads this live config; it starts with no model or // provider, so hasProvider is false at Agent construction and From 249f0505b4e688f5f85668b71076091f880d67ec Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 27 Jul 2026 20:03:10 +0800 Subject: [PATCH 19/30] fix(agent-core): preserve secondary model recipe on live apply --- packages/agent-core/src/rpc/core-api.ts | 4 +- packages/agent-core/src/rpc/core-impl.ts | 13 ++-- packages/agent-core/src/session/index.ts | 40 ++++++----- packages/agent-core/src/session/rpc.ts | 5 -- .../agent-core/test/harness/runtime.test.ts | 39 +++++++++++ packages/agent-core/test/session/init.test.ts | 70 +++++++++++++------ 6 files changed, 123 insertions(+), 48 deletions(-) diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 0ccbcd2a19..92cb872a5c 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -523,7 +523,6 @@ export interface SessionAPI extends AgentAPIWithId { reconnectMcpServer: (payload: ReconnectMcpServerPayload) => void; generateAgentsMd: (payload: EmptyPayload) => void; getSessionWarnings: (payload: EmptyPayload) => readonly SessionWarning[]; - setSecondaryModel: (payload: SetSecondaryModelPayload) => void; waitForBackgroundTasksOnPrint: (payload: EmptyPayload) => void; handlePrintMainTurnCompleted: (payload: EmptyPayload) => 'finish' | 'continue'; addAdditionalDir: (payload: AddAdditionalDirPayload) => AddAdditionalDirResult; @@ -532,6 +531,9 @@ export interface SessionAPI extends AgentAPIWithId { type SessionAPIWithId = WithSessionId; export interface CoreAPI extends SessionAPIWithId { + setSecondaryModel: ( + payload: SetSecondaryModelPayload & { readonly sessionId: string }, + ) => void; getCoreInfo: (payload: EmptyPayload) => CoreInfo; getExperimentalFeatures: (payload: EmptyPayload) => readonly ExperimentalFeatureState[]; getKimiConfig: (payload: GetKimiConfigPayload) => KimiConfig; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 7c94ca8809..7ab642390a 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -1077,12 +1077,13 @@ export class KimiCore implements PromisableMethods { return this.sessionApi(sessionId).getSessionWarnings(payload); } - setSecondaryModel({ sessionId, ...payload }: SessionScopedPayload): void { - // Pick up the just-persisted `[secondary_model]` recipe (and its - // synthesized derived entry) so the session's provider manager can - // resolve the pointed alias — same ordering as `setModel`. - this.reloadProviderManager(); - this.sessionApi(sessionId).setSecondaryModel(payload); + setSecondaryModel({ sessionId }: SessionScopedPayload): void { + // Apply the same fully resolved snapshot the provider manager reads. In + // particular, keep every persisted recipe patch and the synthesized + // `__secondary__` entry instead of reconstructing the recipe from the + // model/default-effort RPC projection. + const config = this.withPrintModeDefaults(this.reloadProviderManager()); + this.requireSession(sessionId).setSecondaryModelConfig(config); } waitForBackgroundTasksOnPrint({ sessionId, ...payload }: SessionScopedPayload): Promise { diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index ad68f77b17..f848fbb8a6 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -23,7 +23,6 @@ import { resolveWorkspaceAdditionalDirs, resolveConfigValue, type BackgroundConfig, - type SecondaryModelConfig, type WorkspaceAdditionalDirsLoadResult, } from '../config'; import { makeErrorPayload } from '../errors'; @@ -52,7 +51,6 @@ import { wrapSubagentModelError, } from './subagent-binding'; import { - applySecondaryModelConfig, SECONDARY_DERIVED_MODEL_ALIAS, secondaryModelPatch, } from '../config/secondary-model'; @@ -240,7 +238,7 @@ export class Session { private printSteerTurns = 0; /** * The session's live config snapshot. Initialized from `options.config`; - * updated in place by {@link setSecondaryModel} so mid-session secondary-model + * updated in place by {@link setSecondaryModelConfig} so mid-session secondary-model * switches reach the spawn-binding and tool-description readers without * recreating the session. */ @@ -799,16 +797,17 @@ export class Session { } /** - * Live-apply a new `[secondary_model]` recipe to this session: the spawn + * Live-apply the core's fully resolved secondary-model config after a + * `[secondary_model]` change: the spawn * binding (`subagent-host`), the startup-warning computation, and every live * agent's `kimiConfig` (tool descriptions, loop control) all read the * session snapshot, so a mid-session `/secondary_model` switch takes effect - * for the next subagent spawn without recreating the session. The recipe is - * expected to be persisted (and the core config reloaded) BEFORE this call — - * the provider manager resolves the pointed alias (and the synthesized - * derived entry, when patch fields exist) against the core config. + * for the next subagent spawn without recreating the session. The core owns + * config reload, environment overlays, and derived-model synthesis. Copying + * that complete recipe and its model entries keeps spawn binding and provider + * resolution aligned without live-applying unrelated session settings. */ - setSecondaryModel(secondary: SecondaryModelConfig): void { + setSecondaryModelConfig(config: KimiConfig): void { const base = this.runtimeConfig; if (base === undefined) { throw new KimiError( @@ -816,16 +815,25 @@ export class Session { 'Cannot set the secondary model: the session has no config.', ); } - if (secondary.model !== undefined) { - try { - this.options.providerManager?.resolveProviderConfig(secondary.model); - } catch (error) { - throw wrapSubagentModelError(error, secondary.model, undefined); - } + const secondary = config.secondaryModel; + if (secondary?.model === undefined) { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + 'Cannot set the secondary model: persist its recipe before applying it to a session.', + ); + } + try { + this.options.providerManager?.resolveProviderConfig(secondary.model); + } catch (error) { + throw wrapSubagentModelError(error, secondary.model, undefined); } const models = { ...base.models }; delete models[SECONDARY_DERIVED_MODEL_ALIAS]; - const next = applySecondaryModelConfig({ ...base, models, secondaryModel: secondary }); + const pointedModel = config.models?.[secondary.model]; + if (pointedModel !== undefined) models[secondary.model] = pointedModel; + const derivedModel = config.models?.[SECONDARY_DERIVED_MODEL_ALIAS]; + if (derivedModel !== undefined) models[SECONDARY_DERIVED_MODEL_ALIAS] = derivedModel; + const next = { ...base, models, secondaryModel: secondary }; this.runtimeConfig = next; this.secondaryModelWarnings = undefined; for (const [, entry] of this.agents) { diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts index ce44699f92..d12b44b6ec 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -28,7 +28,6 @@ import type { SetActiveToolsPayload, SetModelPayload, SetPermissionPayload, - SetSecondaryModelPayload, SetThinkingPayload, SkillSummary, PluginCommandDef, @@ -109,10 +108,6 @@ export class SessionAPIImpl implements PromisableMethods { return this.session.getSessionWarnings(); } - setSecondaryModel(payload: SetSecondaryModelPayload): void { - this.session.setSecondaryModel({ model: payload.model, defaultEffort: payload.defaultEffort }); - } - waitForBackgroundTasksOnPrint(_payload: EmptyPayload): Promise { return this.session.waitForBackgroundTasksOnPrint(); } diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts index 0d7dc1f85b..babbfd6e7c 100644 --- a/packages/agent-core/test/harness/runtime.test.ts +++ b/packages/agent-core/test/harness/runtime.test.ts @@ -228,6 +228,45 @@ micro_compaction = false expect(reloadedMainAgent?.tools.data().some((tool) => tool.name === 'CreateGoal')).toBe(true); }); + it('live-applies the complete persisted secondary recipe when the RPC payload omits effort', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', '1'); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + const created = await rpc.createSession({ + id: 'ses_runtime_secondary_refresh', + workDir, + model: 'default-mock', + }); + + await rpc.setKimiConfig({ + secondaryModel: { + model: 'default-mock', + maxContextSize: 65_536, + }, + }); + await rpc.setSecondaryModel({ sessionId: created.id, model: 'default-mock' }); + + const config = core.sessions.get(created.id)?.getReadyAgent('main')?.kimiConfig; + expect(config?.secondaryModel).toEqual({ + model: 'default-mock', + maxContextSize: 65_536, + }); + expect(config?.models?.['__secondary__']?.overrides?.maxContextSize).toBe(65_536); + }); + // Regression for https://github.com/MoonshotAI/kimi-code/issues/988: during // ACP `session/new` the tool kaos is the reverse-RPC bridge and the client // does not know the session yet, so reading `.kimi-code/local.toml` through diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index 57419f5918..b0a66e6e10 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -699,7 +699,7 @@ describe('AgentAPI.startBtw', () => { }); }); -describe('Session.setSecondaryModel', () => { +describe('Session secondary-model live config', () => { const SECONDARY_BASE_CONFIG: KimiConfig = { providers: { test: { type: MOCK_PROVIDER.type, apiKey: MOCK_PROVIDER.apiKey }, @@ -712,6 +712,21 @@ describe('Session.setSecondaryModel', () => { }, }, }; + const SECONDARY_POINTER_CONFIG: KimiConfig = { + ...SECONDARY_BASE_CONFIG, + secondaryModel: { model: MOCK_PROVIDER.model }, + }; + const SECONDARY_PATCHED_CONFIG: KimiConfig = { + ...SECONDARY_BASE_CONFIG, + models: { + ...SECONDARY_BASE_CONFIG.models, + __secondary__: { + ...SECONDARY_BASE_CONFIG.models![MOCK_PROVIDER.model]!, + overrides: { defaultEffort: 'low' }, + }, + }, + secondaryModel: { model: MOCK_PROVIDER.model, defaultEffort: 'low' }, + }; async function makeSession(config?: KimiConfig): Promise { const workDir = await makeTempDir(); @@ -738,7 +753,7 @@ describe('Session.setSecondaryModel', () => { { profile: testProfile() }, ); - session.setSecondaryModel({ model: MOCK_PROVIDER.model }); + session.setSecondaryModelConfig(SECONDARY_POINTER_CONFIG); expect(session.kimiConfig?.secondaryModel).toEqual({ model: MOCK_PROVIDER.model }); expect(agent.kimiConfig?.secondaryModel).toEqual({ model: MOCK_PROVIDER.model }); @@ -771,7 +786,7 @@ describe('Session.setSecondaryModel', () => { expect(descriptionsBefore['Agent']).not.toContain('Available models'); expect(descriptionsBefore['AgentSwarm']).not.toContain('Available models'); - session.setSecondaryModel({ model: MOCK_PROVIDER.model }); + session.setSecondaryModelConfig(SECONDARY_POINTER_CONFIG); const descriptionsAfter = Object.fromEntries( agent.tools.loopTools.map((tool) => [tool.name, tool.description]), @@ -783,51 +798,66 @@ describe('Session.setSecondaryModel', () => { } }); - it('synthesizes the derived entry for patch recipes and drops it for pointer-only ones', async () => { + it('replaces a patched runtime snapshot with a pointer-only runtime snapshot', async () => { const session = await makeSession(SECONDARY_BASE_CONFIG); try { - session.setSecondaryModel({ model: MOCK_PROVIDER.model, defaultEffort: 'low' }); + session.setSecondaryModelConfig(SECONDARY_PATCHED_CONFIG); const derived = session.kimiConfig?.models?.['__secondary__']; expect(derived).toBeDefined(); expect(derived?.overrides?.defaultEffort).toBe('low'); - session.setSecondaryModel({ model: MOCK_PROVIDER.model }); + session.setSecondaryModelConfig(SECONDARY_POINTER_CONFIG); expect(session.kimiConfig?.models?.['__secondary__']).toBeUndefined(); } finally { await session.close(); } }); + it('keeps unrelated session settings when applying a secondary-model config', async () => { + const session = await makeSession({ + ...SECONDARY_BASE_CONFIG, + loopControl: { maxStepsPerTurn: 7 }, + }); + try { + session.setSecondaryModelConfig({ + ...SECONDARY_POINTER_CONFIG, + loopControl: { maxStepsPerTurn: 99 }, + }); + + expect(session.kimiConfig?.loopControl?.maxStepsPerTurn).toBe(7); + } finally { + await session.close(); + } + }); + it('rejects a dangling pointer with the wrapped secondary-model error', async () => { const session = await makeSession(SECONDARY_BASE_CONFIG); try { - expect(() => session.setSecondaryModel({ model: 'missing-model' })).toThrow( - /\[secondary_model\]\.model/, - ); + expect(() => + session.setSecondaryModelConfig({ + ...SECONDARY_BASE_CONFIG, + secondaryModel: { model: 'missing-model' }, + }), + ).toThrow(/\[secondary_model\]\.model/); expect(session.kimiConfig?.secondaryModel).toBeUndefined(); } finally { await session.close(); } }); - it('rejects when the session has no config', async () => { - const session = await makeSession(); + it('rejects when the complete config has no persisted secondary recipe', async () => { + const session = await makeSession(SECONDARY_BASE_CONFIG); try { - expect(() => session.setSecondaryModel({ model: MOCK_PROVIDER.model })).toThrow(/no config/); + expect(() => session.setSecondaryModelConfig(SECONDARY_BASE_CONFIG)).toThrow(/persist/); } finally { await session.close(); } }); - it('routes the RPC payload through SessionAPIImpl', async () => { - const session = await makeSession(SECONDARY_BASE_CONFIG); + it('rejects when the session has no config', async () => { + const session = await makeSession(); try { - const api = new SessionAPIImpl(session); - await api.setSecondaryModel({ model: MOCK_PROVIDER.model, defaultEffort: 'low' }); - expect(session.kimiConfig?.secondaryModel).toEqual({ - model: MOCK_PROVIDER.model, - defaultEffort: 'low', - }); + expect(() => session.setSecondaryModelConfig(SECONDARY_POINTER_CONFIG)).toThrow(/no config/); } finally { await session.close(); } From 22a0a6995d08e0b2d28dcb9ed495777bd25bdbb8 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 27 Jul 2026 20:24:35 +0800 Subject: [PATCH 20/30] fix(agent-core): make secondary model apply explicit --- apps/kimi-code/src/tui/commands/config.ts | 8 +++---- .../test/tui/commands/secondary-model.test.ts | 6 ++--- packages/agent-core/src/rpc/core-api.ts | 8 +------ packages/agent-core/src/rpc/core-impl.ts | 6 ++--- .../agent-core/test/harness/runtime.test.ts | 4 ++-- packages/node-sdk/src/rpc.ts | 13 ++--------- packages/node-sdk/src/session.ts | 22 ++++++------------- 7 files changed, 21 insertions(+), 46 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 57fead52e6..93c5af9c67 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -613,9 +613,9 @@ function showSecondaryModelPicker( /** * Persist-first, then live-apply: the synthesized derived entry only exists in - * the core config after a reload, and `setSecondaryModel` validates the - * pointed alias against it. No session-only variant — a session-local recipe - * with patch fields would bind a derived alias the core config cannot resolve. + * the core config after a reload. No session-only variant — a session-local + * recipe with patch fields would bind a derived alias the core config cannot + * resolve. */ async function performSecondaryModelSwitch( host: SlashCommandHost, @@ -631,7 +631,7 @@ async function performSecondaryModelSwitch( } if (host.session !== undefined) { try { - await host.session.setSecondaryModel(alias, effort); + await host.session.applyPersistedSecondaryModel(); } catch (error) { host.showError( `Saved ${displayName} as the secondary model, but failed to apply it to this session: ${formatErrorMessage(error)}`, diff --git a/apps/kimi-code/test/tui/commands/secondary-model.test.ts b/apps/kimi-code/test/tui/commands/secondary-model.test.ts index 4b50b0b0c0..ab03d9a404 100644 --- a/apps/kimi-code/test/tui/commands/secondary-model.test.ts +++ b/apps/kimi-code/test/tui/commands/secondary-model.test.ts @@ -28,7 +28,7 @@ function makeHost(options?: { }) { const session = options?.withSession === false ? undefined - : { setSecondaryModel: vi.fn(async () => {}) }; + : { applyPersistedSecondaryModel: vi.fn(async () => {}) }; const host = { state: { appState: { @@ -105,9 +105,9 @@ describe('handleSecondaryModelCommand', () => { expect(host.harness.setConfig).toHaveBeenCalledWith({ secondaryModel: { model: 'k2', defaultEffort: 'high' }, }); - expect(session!.setSecondaryModel).toHaveBeenCalledWith('k2', 'high'); + expect(session!.applyPersistedSecondaryModel).toHaveBeenCalledWith(); expect(host.harness.setConfig.mock.invocationCallOrder[0]).toBeLessThan( - session!.setSecondaryModel.mock.invocationCallOrder[0]!, + session!.applyPersistedSecondaryModel.mock.invocationCallOrder[0]!, ); expect(host.showError).not.toHaveBeenCalled(); }); diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 92cb872a5c..ddc257f7d5 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -247,10 +247,6 @@ export interface SetModelResult { readonly model: string; readonly providerName?: string | undefined; } -export interface SetSecondaryModelPayload { - readonly model: string; - readonly defaultEffort?: string; -} export interface CancelPlanPayload { readonly id?: string; } @@ -531,9 +527,7 @@ export interface SessionAPI extends AgentAPIWithId { type SessionAPIWithId = WithSessionId; export interface CoreAPI extends SessionAPIWithId { - setSecondaryModel: ( - payload: SetSecondaryModelPayload & { readonly sessionId: string }, - ) => void; + applyPersistedSecondaryModel: (payload: EmptyPayload & { readonly sessionId: string }) => void; getCoreInfo: (payload: EmptyPayload) => CoreInfo; getExperimentalFeatures: (payload: EmptyPayload) => readonly ExperimentalFeatureState[]; getKimiConfig: (payload: GetKimiConfigPayload) => KimiConfig; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 7ab642390a..c83eaad3a9 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -138,7 +138,6 @@ import type { SetPermissionPayload, SetPluginEnabledPayload, SetPluginMcpServerEnabledPayload, - SetSecondaryModelPayload, SetThinkingPayload, SkillSummary, PluginCommandDef, @@ -1077,11 +1076,10 @@ export class KimiCore implements PromisableMethods { return this.sessionApi(sessionId).getSessionWarnings(payload); } - setSecondaryModel({ sessionId }: SessionScopedPayload): void { + applyPersistedSecondaryModel({ sessionId }: SessionScopedPayload): void { // Apply the same fully resolved snapshot the provider manager reads. In // particular, keep every persisted recipe patch and the synthesized - // `__secondary__` entry instead of reconstructing the recipe from the - // model/default-effort RPC projection. + // `__secondary__` entry instead of exposing an incomplete setter payload. const config = this.withPrintModeDefaults(this.reloadProviderManager()); this.requireSession(sessionId).setSecondaryModelConfig(config); } diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts index babbfd6e7c..a2847a12b8 100644 --- a/packages/agent-core/test/harness/runtime.test.ts +++ b/packages/agent-core/test/harness/runtime.test.ts @@ -228,7 +228,7 @@ micro_compaction = false expect(reloadedMainAgent?.tools.data().some((tool) => tool.name === 'CreateGoal')).toBe(true); }); - it('live-applies the complete persisted secondary recipe when the RPC payload omits effort', async () => { + it('live-applies the complete persisted secondary recipe', async () => { tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); const homeDir = join(tmp, 'home'); const workDir = join(tmp, 'work'); @@ -257,7 +257,7 @@ micro_compaction = false maxContextSize: 65_536, }, }); - await rpc.setSecondaryModel({ sessionId: created.id, model: 'default-mock' }); + await rpc.applyPersistedSecondaryModel({ sessionId: created.id }); const config = core.sessions.get(created.id)?.getReadyAgent('main')?.kimiConfig; expect(config?.secondaryModel).toEqual({ diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 17fa77f36b..42fc747abd 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -100,11 +100,6 @@ export interface SetSessionThinkingRpcInput extends SessionIdRpcInput { readonly effort: string; } -export interface SetSessionSecondaryModelRpcInput extends SessionIdRpcInput { - readonly model: string; - readonly defaultEffort?: string; -} - export interface SetSessionPermissionRpcInput extends SessionIdRpcInput { readonly mode: PermissionMode; } @@ -436,13 +431,9 @@ export abstract class SDKRpcClientBase { }); } - async setSecondaryModel(input: SetSessionSecondaryModelRpcInput): Promise { + async applyPersistedSecondaryModel(input: SessionIdRpcInput): Promise { const rpc = await this.getRpc(); - return rpc.setSecondaryModel({ - sessionId: input.sessionId, - model: input.model, - defaultEffort: input.defaultEffort, - }); + return rpc.applyPersistedSecondaryModel({ sessionId: input.sessionId }); } async setPermission(input: SetSessionPermissionRpcInput): Promise { diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 6c734b72fb..9222112832 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -208,23 +208,15 @@ export class Session { } /** - * Live-apply the `[secondary_model]` recipe to this session (subagent model - * binding). The recipe should be persisted via `KimiHarness.setConfig` first - * so the core config (and its synthesized derived entry) is reloaded before - * the session snapshot is updated — mirroring the `/secondary_model` flow. + * Live-apply the persisted `[secondary_model]` recipe to this session + * (subagent model binding). Persist the recipe via `KimiHarness.setConfig` + * first; this reloads the complete recipe and its synthesized derived entry + * before updating the session snapshot — mirroring the `/secondary_model` + * flow. */ - async setSecondaryModel(model: string, effort?: ThinkingEffort): Promise { + async applyPersistedSecondaryModel(): Promise { this.ensureOpen(); - const normalized = normalizeRequiredString( - model, - 'Session secondary model cannot be empty', - ErrorCodes.SESSION_MODEL_EMPTY, - ); - await this.rpc.setSecondaryModel({ - sessionId: this.id, - model: normalized, - defaultEffort: effort, - }); + await this.rpc.applyPersistedSecondaryModel({ sessionId: this.id }); } async setPermission(mode: PermissionMode): Promise { From 26f87e294e08958a3e80d7652ace4a41c85e20c7 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 27 Jul 2026 21:06:45 +0800 Subject: [PATCH 21/30] fix(tui): refresh secondary model display state --- apps/kimi-code/src/tui/commands/config.ts | 7 +- .../test/tui/commands/secondary-model.test.ts | 68 ++++++++++++++++--- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 93c5af9c67..3b4a450eb0 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -2,6 +2,7 @@ import { effectiveModelAlias, SECONDARY_DERIVED_MODEL_ALIAS, type ExperimentalFeatureState, + type KimiConfig, type ModelAlias, type PermissionMode, type Session, @@ -623,8 +624,11 @@ async function performSecondaryModelSwitch( effort: ThinkingEffort, ): Promise { const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]); + let updatedConfig: KimiConfig; try { - await host.harness.setConfig({ secondaryModel: { model: alias, defaultEffort: effort } }); + updatedConfig = await host.harness.setConfig({ + secondaryModel: { model: alias, defaultEffort: effort }, + }); } catch (error) { host.showError(`Failed to save secondary model: ${formatErrorMessage(error)}`); return; @@ -639,6 +643,7 @@ async function performSecondaryModelSwitch( return; } } + host.setAppState({ availableModels: updatedConfig.models ?? {} }); host.showStatus( host.session === undefined ? `Secondary model set to ${displayName} with thinking ${effort}; applies to new sessions.` diff --git a/apps/kimi-code/test/tui/commands/secondary-model.test.ts b/apps/kimi-code/test/tui/commands/secondary-model.test.ts index ab03d9a404..b4cdee5ea0 100644 --- a/apps/kimi-code/test/tui/commands/secondary-model.test.ts +++ b/apps/kimi-code/test/tui/commands/secondary-model.test.ts @@ -1,3 +1,9 @@ +/** + * Scenario: /secondary_model command behavior in the interactive TUI. + * Responsibilities: picker filtering, persistence, live apply, and effective-model state refresh. + * Wiring: real command and selector with the SDK/session boundaries stubbed by a small host rig. + * Run: pnpm -C apps/kimi-code exec vitest run test/tui/commands/secondary-model.test.ts + */ import type { ModelAlias, ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; import { describe, expect, it, vi } from 'vitest'; @@ -25,22 +31,24 @@ function model(name: string): ModelAlias { function makeHost(options?: { readonly withSession?: boolean; readonly secondaryModel?: { model: string; defaultEffort?: string }; + readonly persistedModels?: Record; }) { const session = options?.withSession === false ? undefined : { applyPersistedSecondaryModel: vi.fn(async () => {}) }; + const appState = { + availableModels: { + k2: model('k2'), + cheap: model('cheap'), + // The synthesized derived entry must never be selectable. + '__secondary__': model('cheap'), + } as Record, + availableProviders: {}, + transcriptEntries: [], + }; const host = { state: { - appState: { - availableModels: { - k2: model('k2'), - cheap: model('cheap'), - // The synthesized derived entry must never be selectable. - '__secondary__': model('cheap'), - }, - availableProviders: {}, - transcriptEntries: [], - }, + appState, transcriptEntries: [], }, authFlow: { @@ -51,9 +59,10 @@ function makeHost(options?: { providers: {}, secondaryModel: options?.secondaryModel, })), - setConfig: vi.fn(async () => ({ providers: {} })), + setConfig: vi.fn(async () => ({ providers: {}, models: options?.persistedModels })), }, session, + setAppState: vi.fn((patch) => Object.assign(appState, patch)), mountEditorReplacement: vi.fn(), restoreEditor: vi.fn(), showStatus: vi.fn(), @@ -112,6 +121,43 @@ describe('handleSecondaryModelCommand', () => { expect(host.showError).not.toHaveBeenCalled(); }); + it('refreshes the effective model map after a live secondary-model switch', async () => { + const { host } = makeHost({ + persistedModels: { + k2: model('k2'), + cheap: model('cheap'), + '__secondary__': model('k2'), + }, + }); + + await handleSecondaryModelCommand(host, ''); + mountedPicker(host).onSelect({ alias: 'k2', thinking: 'high' }); + + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalled(); + }); + expect(host.state.appState.availableModels['__secondary__']?.displayName).toBe('k2'); + }); + + it('keeps the current effective model map when live apply fails', async () => { + const { host, session } = makeHost({ + persistedModels: { + k2: model('k2'), + cheap: model('cheap'), + '__secondary__': model('k2'), + }, + }); + session!.applyPersistedSecondaryModel.mockRejectedValueOnce(new Error('apply failed')); + + await handleSecondaryModelCommand(host, ''); + mountedPicker(host).onSelect({ alias: 'k2', thinking: 'high' }); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalled(); + }); + expect(host.state.appState.availableModels['__secondary__']?.displayName).toBe('cheap'); + }); + it('persists only when there is no session', async () => { const { host } = makeHost({ withSession: false }); From 4d31304249bfcc644f321531da8880bbdd7944a6 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 27 Jul 2026 21:15:27 +0800 Subject: [PATCH 22/30] chore: merge secondary model changesets into one --- .changeset/subagent-model-display.md | 5 ----- .changeset/v1-secondary-model-command.md | 5 ----- .changeset/v1-secondary-model.md | 4 ++-- 3 files changed, 2 insertions(+), 12 deletions(-) delete mode 100644 .changeset/subagent-model-display.md delete mode 100644 .changeset/v1-secondary-model-command.md diff --git a/.changeset/subagent-model-display.md b/.changeset/subagent-model-display.md deleted file mode 100644 index 1ef9cafb22..0000000000 --- a/.changeset/subagent-model-display.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Show the model each subagent is running on in the subagent card header and the agent-group rows. diff --git a/.changeset/v1-secondary-model-command.md b/.changeset/v1-secondary-model-command.md deleted file mode 100644 index ba6c4cda83..0000000000 --- a/.changeset/v1-secondary-model-command.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": minor ---- - -Add the /secondary_model slash command to configure the secondary model used by subagents, applied to the current session immediately. Requires the secondary-model experiment (KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1); run /secondary_model to pick a model and thinking effort. diff --git a/.changeset/v1-secondary-model.md b/.changeset/v1-secondary-model.md index 6dbb6cae24..6887bd9054 100644 --- a/.changeset/v1-secondary-model.md +++ b/.changeset/v1-secondary-model.md @@ -1,5 +1,5 @@ --- -"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kimi-code": minor --- -Support a secondary model for subagents on agent-core. +Add the /secondary_model slash command to configure the secondary model used by subagents, and show each subagent's model in the subagent card header and agent-group rows. Requires the secondary-model experiment (KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1); run /secondary_model to pick a model and thinking effort, applied to the current session immediately. From 2709ba3b599ca643ee2657b69cab00676c51d79e Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Mon, 27 Jul 2026 21:16:04 +0800 Subject: [PATCH 23/30] Add /secondary_model command for subagent configuration Show each subagent's model in the subagent card header and agent-group rows. Requires the secondary-model experiment (KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1); run /secondary_model to pick a model and thinking effort, applied to the current session immediately. Signed-off-by: 7Sageer --- .changeset/v1-secondary-model.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/v1-secondary-model.md b/.changeset/v1-secondary-model.md index 6887bd9054..181ba6978a 100644 --- a/.changeset/v1-secondary-model.md +++ b/.changeset/v1-secondary-model.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -Add the /secondary_model slash command to configure the secondary model used by subagents, and show each subagent's model in the subagent card header and agent-group rows. Requires the secondary-model experiment (KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1); run /secondary_model to pick a model and thinking effort, applied to the current session immediately. +Add the /secondary_model slash command to configure the secondary model used by subagents. From bb0fd016e95115497d5c2523cbe0b0c8b79c2e54 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 27 Jul 2026 21:46:36 +0800 Subject: [PATCH 24/30] fix(agent-core): align explicit agent file precedence --- .../src/profile/agentfile/catalog.ts | 6 ++++- .../agent-core/test/profile/agentfile.test.ts | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/agent-core/src/profile/agentfile/catalog.ts b/packages/agent-core/src/profile/agentfile/catalog.ts index f32833a8a0..9b78c214e3 100644 --- a/packages/agent-core/src/profile/agentfile/catalog.ts +++ b/packages/agent-core/src/profile/agentfile/catalog.ts @@ -218,13 +218,17 @@ export class SessionAgentProfileCatalog { } // ── Explicit source (fatal) ────────────────────────────────────── + // Match v2's per-source merge semantics: when several explicit files + // declare the same profile name, the last file replaces the earlier one. + const explicitEntries = new Map(); for (const file of this.options.explicitFiles ?? []) { const path = resolveAgentPath(file, this.options.workDir, this.options.osHomeDir); const text = await fs.readFile(path, 'utf-8'); const definition = parseAgentFileText({ path, source: 'explicit', text }); this.warnInactivePatterns(definition); - entries.push(this.entryFromDefinition(definition, effectiveDefault)); + explicitEntries.set(definition.name, this.entryFromDefinition(definition, effectiveDefault)); } + entries.push(...explicitEntries.values()); const winners = this.applyFileEntries(entries); this.snapshotValue = this.snapshotFromEntries(winners, systemMd); diff --git a/packages/agent-core/test/profile/agentfile.test.ts b/packages/agent-core/test/profile/agentfile.test.ts index 50cf2f4cf4..ff1440f334 100644 --- a/packages/agent-core/test/profile/agentfile.test.ts +++ b/packages/agent-core/test/profile/agentfile.test.ts @@ -427,6 +427,30 @@ describe('SessionAgentProfileCatalog', () => { expect(c.get('coder')?.description).toBe('Explicit coder.'); }); + it('uses the last explicit file when files declare the same profile name', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeFile( + join(workDir, 'first-reviewer.md'), + agentFileText({ name: 'reviewer', description: 'First reviewer.' }), + 'utf-8', + ); + await writeFile( + join(workDir, 'last-reviewer.md'), + agentFileText({ name: 'reviewer', description: 'Last reviewer.' }), + 'utf-8', + ); + + const c = catalog({ + workDir, + brandHomeDir: brandHome, + osHomeDir: osHome, + explicitFiles: ['first-reviewer.md', 'last-reviewer.md'], + }); + await c.ready; + + expect(c.get('reviewer')?.description).toBe('Last reviewer.'); + }); + it('extends the default delegation set with file-defined agents', async () => { const { workDir, brandHome, osHome } = await makeLayout(); await writeAgent( From 08cd432de9ed14667699756bb38e9a8620ba1d05 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 28 Jul 2026 11:28:38 +0800 Subject: [PATCH 25/30] fix(agent-core): let disallowedTools deny select_tools --- packages/agent-core/src/agent/tool/index.ts | 16 +++++++++--- .../test/agent/tool-select.e2e.test.ts | 26 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index 993837073a..768b48766d 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -736,10 +736,13 @@ export class ToolManager { name: tool.name, description: tool.description, // select_tools is always registered but only offered while the - // disclosure gate is open (see loopTools); report that live state. + // disclosure gate is open and the denylist does not name it (see + // loopTools); report that live state. active: this.isExactToolEnabled(tool.name) || - (tool.name === b.SELECT_TOOLS_TOOL_NAME && this.agent.toolSelectEnabled), + (tool.name === b.SELECT_TOOLS_TOOL_NAME && + this.agent.toolSelectEnabled && + !this.disabledTools.has(tool.name)), source: 'builtin', }; } @@ -990,7 +993,14 @@ export class ToolManager { loadedSet === undefined ? enabledMcpNames : enabledMcpNames.filter((name) => loadedSet.has(name)); - const selectToolsName = disclosure ? [b.SELECT_TOOLS_TOOL_NAME] : []; + // The disclosure gate decides exposure, but the denylist still wins: a + // profile disallowedTools entry naming select_tools keeps it out of the + // table (mirrors agent-core-v2 isToolActiveForDisclosure, which applies + // the deny layers but not the allowlist to select_tools). + const selectToolsName = + disclosure && !this.disabledTools.has(b.SELECT_TOOLS_TOOL_NAME) + ? [b.SELECT_TOOLS_TOOL_NAME] + : []; return uniq([...enabledNames, ...selectToolsName, ...mcpNames]) .toSorted((a, b) => a.localeCompare(b)) // select_tools is exposed exclusively through the disclosure gate — a diff --git a/packages/agent-core/test/agent/tool-select.e2e.test.ts b/packages/agent-core/test/agent/tool-select.e2e.test.ts index f5e584f1e3..2531149ef6 100644 --- a/packages/agent-core/test/agent/tool-select.e2e.test.ts +++ b/packages/agent-core/test/agent/tool-select.e2e.test.ts @@ -277,6 +277,32 @@ describe('disclosure mode — top-level convergence and announcements', () => { expect(ctx.agent.context.history.filter(isLoadableToolsAnnouncement)).toHaveLength(1); }); + it('honors a disallowedTools deny of select_tools itself', async () => { + const ctx = await disclosureAgent(); + ctx.agent.tools.setActiveTools(['Read', 'mcp__*'], ['select_tools']); + + // The denylist wins over the disclosure gate: select_tools leaves the + // executable table and reports inactive (mirrors agent-core-v2 + // isToolActiveForDisclosure). + const loopNames = ctx.agent.tools.loopTools.map((t) => t.name); + expect(loopNames).not.toContain('select_tools'); + expect(loopNames).toContain('Read'); + expect(ctx.agent.tools.data().find((info) => info.name === 'select_tools')?.active).toBe( + false, + ); + + // The wire view drops it too, and a call lands on the missing-tool path. + ctx.mockNextResponse({ type: 'text', text: 'try' }, selectCall('call-1', [GRAFANA_TOOL])); + ctx.mockNextResponse({ type: 'text', text: 'ok' }); + await runTurn(ctx, 'load it'); + + expect(ctx.llmCalls[0]!.tools.map((t) => t.name)).not.toContain('select_tools'); + expect( + ctx.agent.context.history.some((m) => m.role === 'tool' && m.isError === true), + ).toBe(true); + expect(schemaMessages(ctx)).toHaveLength(0); + }); + it('announces tools_removed at the next boundary after a server disconnects', async () => { const ctx = await disclosureAgent(); ctx.mockNextResponse({ type: 'text', text: 'one' }); From 38c7df5905d847726204e50b070256f3c1634b5b Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 28 Jul 2026 11:31:07 +0800 Subject: [PATCH 26/30] chore(cli): drop engine mention from --agent/--agent-file help text --- apps/kimi-code/src/cli/commands.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index df1f6ae4c0..0cf4e8a2ae 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -77,7 +77,7 @@ export function createProgram( .addOption( new Option( '--agent ', - 'Agent profile to use for this print-mode invocation on either engine. Custom profiles are discovered from agent directories or loaded via --agent-file.', + 'Agent profile to use for this print-mode invocation. Custom profiles are discovered from agent directories or loaded via --agent-file.', ) .argParser((value: string, previous: string | undefined) => { if (previous !== undefined) { @@ -90,7 +90,7 @@ export function createProgram( .addOption( new Option( '--agent-file ', - 'Load an agent definition from a Markdown file and select it for this print-mode invocation on either engine. Only valid when starting a new session — cannot be combined with --session/--continue.', + 'Load an agent definition from a Markdown file and select it for this print-mode invocation. Only valid when starting a new session — cannot be combined with --session/--continue.', ) .argParser((value: string, previous: string[] | undefined) => { if ((previous?.length ?? 0) > 0) { From 1c8abe539cb24999b398b0fbff30e71615aa32a9 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 28 Jul 2026 12:16:02 +0800 Subject: [PATCH 27/30] feat(cli): support --agent/--agent-file in the interactive TUI Bind the selected agent profile to the startup session when launching the TUI with --agent/--agent-file, including the session created after an OAuth login at startup. Sessions created later in the process (/new) keep the default profile. Make both flags creation-only in every mode: combining them with --session/--continue is now rejected in print mode too, since resume restores the bound agent from the session automatically. --- apps/kimi-code/src/cli/agent-selection.ts | 42 ++++++++++++++ apps/kimi-code/src/cli/commands.ts | 4 +- apps/kimi-code/src/cli/options.ts | 9 +-- apps/kimi-code/src/cli/run-prompt.ts | 42 ++------------ apps/kimi-code/src/cli/run-shell.ts | 5 ++ apps/kimi-code/src/cli/v2/run-v2-print.ts | 27 +++------ .../src/tui/controllers/auth-flow.ts | 6 ++ apps/kimi-code/src/tui/kimi-tui.ts | 8 +++ apps/kimi-code/src/tui/types.ts | 4 ++ apps/kimi-code/test/cli/options.test.ts | 36 +++++++----- apps/kimi-code/test/cli/run-prompt.test.ts | 22 +++----- apps/kimi-code/test/cli/run-shell.test.ts | 29 ++++++++++ .../test/tui/kimi-tui-startup.test.ts | 55 +++++++++++++++++++ docs/en/customization/agents.md | 11 ++-- docs/en/reference/kimi-command.md | 9 +-- docs/zh/customization/agents.md | 11 ++-- docs/zh/reference/kimi-command.md | 9 +-- 17 files changed, 218 insertions(+), 111 deletions(-) create mode 100644 apps/kimi-code/src/cli/agent-selection.ts diff --git a/apps/kimi-code/src/cli/agent-selection.ts b/apps/kimi-code/src/cli/agent-selection.ts new file mode 100644 index 0000000000..0ffb53d599 --- /dev/null +++ b/apps/kimi-code/src/cli/agent-selection.ts @@ -0,0 +1,42 @@ +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; + +import { parseAgentFileText, resolveAgentPath } from '@moonshot-ai/kimi-code-sdk'; + +import type { CLIOptions } from './options'; + +/** + * Resolve which agent profile the launch flags select. + * + * `--agent` carries the profile name directly; `--agent-file` implicitly + * selects the profile the file defines, so the file is parsed here (fatal on + * error) so a bad file fails before any session work. Returns undefined when + * neither flag is present. + */ +export async function resolveAgentProfileSelection( + opts: Pick, + workDir: string, +): Promise { + if (opts.agent !== undefined) return opts.agent; + const agentFile = opts.agentFiles?.[0]; + if (agentFile === undefined) return undefined; + + const path = resolveAgentPath(agentFile, workDir, homedir()); + let text: string; + try { + text = await readFile(path, 'utf8'); + } catch (error) { + throw new Error( + `Failed to read agent file "${path}": ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + try { + return parseAgentFileText({ path, source: 'explicit', text }).name; + } catch (error) { + throw new Error( + `Invalid agent file "${path}": ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } +} diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 0cf4e8a2ae..a090df4d0f 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -77,7 +77,7 @@ export function createProgram( .addOption( new Option( '--agent ', - 'Agent profile to use for this print-mode invocation. Custom profiles are discovered from agent directories or loaded via --agent-file.', + 'Agent profile to start the new session with. Custom profiles are discovered from agent directories or loaded via --agent-file. Cannot be combined with --session/--continue.', ) .argParser((value: string, previous: string | undefined) => { if (previous !== undefined) { @@ -90,7 +90,7 @@ export function createProgram( .addOption( new Option( '--agent-file ', - 'Load an agent definition from a Markdown file and select it for this print-mode invocation. Only valid when starting a new session — cannot be combined with --session/--continue.', + 'Load an agent definition from a Markdown file and select it for the new session. Cannot be combined with --session/--continue.', ) .argParser((value: string, previous: string[] | undefined) => { if ((previous?.length ?? 0) > 0) { diff --git a/apps/kimi-code/src/cli/options.ts b/apps/kimi-code/src/cli/options.ts index d4c21664da..004fd7cabd 100644 --- a/apps/kimi-code/src/cli/options.ts +++ b/apps/kimi-code/src/cli/options.ts @@ -97,17 +97,12 @@ export function validateOptions( if (opts.agent !== undefined && opts.agentFiles.length > 0) { throw new OptionConflictError('Cannot combine --agent with --agent-file.'); } - if (opts.agentFiles.length > 0 && (opts.session !== undefined || opts.continue)) { - throw new OptionConflictError( - 'Cannot combine --agent-file with --session/--continue: the agent file is bound at session creation and is not re-applied on resume.', - ); - } if ( (opts.agent !== undefined || opts.agentFiles.length > 0) && - !promptMode + (opts.session !== undefined || opts.continue) ) { throw new OptionConflictError( - '--agent/--agent-file are only available in print mode (kimi -p).', + 'Cannot combine --agent/--agent-file with --session/--continue: the agent is bound at session creation and the bound agent is restored automatically on resume.', ); } if (promptMode && opts.session === '') { diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index 22132dabe0..fd795e838d 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -1,6 +1,3 @@ -import { readFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; - import { setCrashPhase, setTelemetryContext, @@ -12,8 +9,6 @@ import chalk from 'chalk'; import { createKimiHarness, log, - parseAgentFileText, - resolveAgentPath, type Event, type GoalSnapshot, type SessionStatus, @@ -23,6 +18,7 @@ import { resolve } from 'pathe'; import { CLI_SHUTDOWN_TIMEOUT_MS, PROMPT_CLEANUP_TIMEOUT_MS } from '#/constant/app'; +import { resolveAgentProfileSelection } from './agent-selection'; import { isKimiV2Enabled } from './experimental-v2'; import { resolveOutputFormat } from './options'; import type { CLIOptions, PromptOutputFormat } from './options'; @@ -301,8 +297,9 @@ async function resolvePromptSession( stderr: PromptOutput, setRestorePermission: (restorePermission: () => Promise) => void, ): Promise { - const agentProfile = await resolveAgentProfileSelection(opts, workDir); - + // `--agent`/`--agent-file` are creation-only: validateOptions rejects them + // together with --session/--continue, so resume paths never forward a + // profile — the bound agent is restored from the session itself. if (opts.session !== undefined) { const sessions = await harness.listSessions({ sessionId: opts.session, workDir }); const target = sessions[0]; @@ -323,7 +320,6 @@ async function resolvePromptSession( const session = await harness.resumeSession({ id: opts.session, additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, - agentProfile, }); const status = await session.getStatus(); const restorePermission = await forcePromptPermission( @@ -351,7 +347,6 @@ async function resolvePromptSession( const session = await harness.resumeSession({ id: previous.id, additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, - agentProfile, }); const status = await session.getStatus(); const restorePermission = await forcePromptPermission( @@ -374,6 +369,7 @@ async function resolvePromptSession( stderr.write(`No sessions to continue under "${workDir}"; starting a fresh session.\n`); } + const agentProfile = await resolveAgentProfileSelection(opts, workDir); const model = requireConfiguredModel(opts.model, defaultModel); const session = await harness.createSession({ workDir, @@ -394,34 +390,6 @@ async function resolvePromptSession( }; } -async function resolveAgentProfileSelection( - opts: CLIOptions, - workDir: string, -): Promise { - if (opts.agent !== undefined) return opts.agent; - const agentFile = opts.agentFiles?.[0]; - if (agentFile === undefined) return undefined; - - const path = resolveAgentPath(agentFile, workDir, homedir()); - let text: string; - try { - text = await readFile(path, 'utf8'); - } catch (error) { - throw new Error( - `Failed to read agent file "${path}": ${error instanceof Error ? error.message : String(error)}`, - { cause: error }, - ); - } - try { - return parseAgentFileText({ path, source: 'explicit', text }).name; - } catch (error) { - throw new Error( - `Invalid agent file "${path}": ${error instanceof Error ? error.message : String(error)}`, - { cause: error }, - ); - } -} - async function forcePromptPermission( session: PromptSession, previousPermission: SessionStatus['permission'], diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 7e0a8ce711..91c9b0d7c3 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -29,6 +29,7 @@ import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; import { restoreTerminalModes } from '#/utils/terminal-restore'; import type { CLIOptions } from './options'; +import { resolveAgentProfileSelection } from './agent-selection'; import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry'; import { createKimiCodeHostIdentity } from './version'; @@ -101,8 +102,12 @@ export async function runShell( configWarning = combineStartupNotice(configWarning, warning); } const configMs = Date.now() - configStartedAt; + // Resolve --agent/--agent-file once for the startup session; validateOptions + // has already rejected them alongside --session/--continue. + const agentProfile = await resolveAgentProfileSelection(opts, workDir); const tui = new KimiTUI(harness, { cliOptions: opts, + agentProfile, additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, tuiConfig, version, diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index ff0c96c590..0bf117b038 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -293,27 +293,14 @@ async function resolveNativeSession( } } - // `--agent` / `--agent-file` bind an explicit profile; without them the - // historical setModel path (default profile on first bind) is kept. A - // same-name re-select on a resumed session keeps the profile and only applies - // an explicitly requested model; a different name is rejected by the - // engine's first-bind guard inside `bind`. - const applyProfileSelection = async ( + // `--agent` / `--agent-file` are creation-only: validateOptions rejects them + // together with --session/--continue, so resume paths only apply an + // explicitly requested model — the bound profile is restored by the engine. + const applyModelOverride = async ( profile: IAgentProfileService, model: string | undefined, ): Promise => { - if (agentProfileName !== undefined) { - if (profile.data().profileName === agentProfileName) { - if (model !== undefined) await profile.setModel(model); - return; - } - await profile.bind({ - profile: agentProfileName, - model: requireConfiguredModel(model ?? profile.getModel(), defaultModel), - }); - } else if (model !== undefined) { - await profile.setModel(model); - } + if (model !== undefined) await profile.setModel(model); }; const resumeById = async (id: string): Promise => { @@ -353,7 +340,7 @@ async function resolveNativeSession( const session = await resumeById(opts.session); const agent = await ensureMainAgent(session); const profile = agent.accessor.get(IAgentProfileService); - await applyProfileSelection(profile, opts.model); + await applyModelOverride(profile, opts.model); const currentModel = profile.getModel(); const { restorePermission } = forceAuto(agent); return { @@ -372,7 +359,7 @@ async function resolveNativeSession( const session = await resumeById(previous.id); const agent = await ensureMainAgent(session); const profile = agent.accessor.get(IAgentProfileService); - await applyProfileSelection(profile, opts.model); + await applyModelOverride(profile, opts.model); const currentModel = profile.getModel(); const { restorePermission } = forceAuto(agent); return { diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index a7acc77ce8..bf8699ff2c 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -85,6 +85,12 @@ export class AuthFlowController { ? 'yolo' : undefined, planMode: host.state.appState.planMode ? true : undefined, + // The post-login session is still the startup session: carry the + // --agent/--agent-file binding resolved at launch. + agentProfile: host.options.startup.agentProfile, + agentFiles: host.options.startup.agentFiles?.length + ? [...host.options.startup.agentFiles] + : undefined, }; if (host.state.appState.additionalDirs.length > 0) { options.additionalDirs = [...host.state.appState.additionalDirs]; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index f0764c82b9..88241ce180 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -174,6 +174,8 @@ export type { export interface KimiTUIStartupInput { readonly cliOptions: CLIOptions; + /** Profile name resolved from cliOptions --agent/--agent-file (see resolveAgentProfileSelection). */ + readonly agentProfile?: string; readonly additionalDirs?: readonly string[]; readonly tuiConfig: TuiConfig; readonly version: string; @@ -384,6 +386,8 @@ export class KimiTUI { auto: startupInput.cliOptions.auto, plan: startupInput.cliOptions.plan, model: startupInput.cliOptions.model, + agentProfile: startupInput.agentProfile, + agentFiles: startupInput.cliOptions.agentFiles, startupNotice: startupInput.startupNotice, }, }; @@ -743,6 +747,10 @@ export class KimiTUI { model: startup.model, permission: startup.auto ? 'auto' : startup.yolo ? 'yolo' : undefined, planMode: startup.plan ? true : undefined, + // --agent/--agent-file bind the startup session only; sessions created + // later in this process fall back to the default profile. + agentProfile: startup.agentProfile, + agentFiles: startup.agentFiles?.length ? [...startup.agentFiles] : undefined, }; if (this.state.appState.additionalDirs.length > 0) { createSessionOptions.additionalDirs = [...this.state.appState.additionalDirs]; diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index d895b11e12..5bddf62c5e 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -242,6 +242,10 @@ export interface TUIStartupOptions { readonly auto: boolean; readonly plan: boolean; readonly model?: string; + /** Resolved profile name from --agent/--agent-file; bound to the startup session only. */ + readonly agentProfile?: string; + /** Raw --agent-file paths, passed to session creation alongside `agentProfile`. */ + readonly agentFiles?: readonly string[]; readonly startupNotice?: string; } diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index d8972bcfb3..2adb0c2171 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -400,14 +400,12 @@ describe('CLI options parsing', () => { }); describe('--agent / --agent-file', () => { - it('describes agent selectors as available in print mode on either engine', () => { + it('describes agent selectors as new-session-only', () => { const help = createProgram('0.1.0-test', () => {}, () => {}).helpInformation(); const normalizedHelp = help.replaceAll(/\s+/g, ' '); - expect(normalizedHelp).toContain( - 'Agent profile to use for this print-mode invocation on either engine.', - ); - expect(help).not.toContain('v2 engine only'); + expect(normalizedHelp).toContain('Agent profile to start the new session with.'); + expect(normalizedHelp).not.toContain('print-mode invocation'); }); it('parses a single --agent', () => { @@ -458,7 +456,7 @@ describe('CLI options parsing', () => { const opts = parse(['-p', 'hi', '--agent-file', 'a.md', '--session', 'ses_123']); expect(() => validateOptions(opts)).toThrow(OptionConflictError); expect(() => validateOptions(opts)).toThrow( - 'Cannot combine --agent-file with --session/--continue', + 'Cannot combine --agent/--agent-file with --session/--continue', ); }); @@ -466,13 +464,24 @@ describe('CLI options parsing', () => { const opts = parse(['-p', 'hi', '--agent-file', 'a.md', '--continue']); expect(() => validateOptions(opts)).toThrow(OptionConflictError); expect(() => validateOptions(opts)).toThrow( - 'Cannot combine --agent-file with --session/--continue', + 'Cannot combine --agent/--agent-file with --session/--continue', ); }); - it('allows --agent with --session (re-selecting the bound profile)', () => { + it('rejects --agent with --session', () => { const opts = parse(['-p', 'hi', '--agent', 'reviewer', '--session', 'ses_123']); - expect(validateOptions(opts, {}).uiMode).toBe('print'); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow( + 'Cannot combine --agent/--agent-file with --session/--continue', + ); + }); + + it('rejects --agent with --continue in shell mode', () => { + const opts = parse(['--agent', 'reviewer', '--continue']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow( + 'Cannot combine --agent/--agent-file with --session/--continue', + ); }); it('rejects empty agent values', () => { @@ -487,12 +496,9 @@ describe('CLI options parsing', () => { expect(() => validateOptions(opts)).toThrow('Agent file path cannot be empty.'); }); - it('rejects the flags in shell mode', () => { - const opts = parse(['--agent', 'reviewer']); - expect(() => validateOptions(opts)).toThrow(OptionConflictError); - expect(() => validateOptions(opts)).toThrow( - '--agent/--agent-file are only available in print mode (kimi -p).', - ); + it('accepts the flags in shell mode', () => { + expect(validateOptions(parse(['--agent', 'reviewer']), {}).uiMode).toBe('shell'); + expect(validateOptions(parse(['--agent-file', 'a.md']), {}).uiMode).toBe('shell'); }); it('accepts the flags in prompt mode without the v2 engine flag', () => { diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index 922574de87..d71e88b23f 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -666,18 +666,15 @@ describe('runPrompt', () => { expect(mocks.harnessCreateSession).not.toHaveBeenCalled(); }); - it('forwards the selected agent profile when resuming a concrete v1 session', async () => { + it('does not forward an agent profile when resuming a concrete v1 session', async () => { + // validateOptions rejects --agent with --session; runPrompt must not + // forward a profile to resume even if a caller hands one over. await runPrompt(opts({ session: 'ses_existing', agent: 'reviewer' }), '1.2.3-test', { stdout: writer(), stderr: writer(), }); - expect(mocks.harnessResumeSession).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'ses_existing', - agentProfile: 'reviewer', - }), - ); + expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ id: 'ses_existing' }); }); it('allows resuming a concrete session when Windows workdir uses backslashes', async () => { @@ -952,18 +949,15 @@ describe('runPrompt', () => { expect(mocks.harnessCreateSession).not.toHaveBeenCalled(); }); - it('forwards the selected agent profile when continuing a previous v1 session', async () => { + it('does not forward an agent profile when continuing a previous v1 session', async () => { + // validateOptions rejects --agent with --continue; runPrompt must not + // forward a profile to resume even if a caller hands one over. await runPrompt(opts({ continue: true, agent: 'reviewer' }), '1.2.3-test', { stdout: writer(), stderr: writer(), }); - expect(mocks.harnessResumeSession).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'ses_previous', - agentProfile: 'reviewer', - }), - ); + expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ id: 'ses_previous' }); }); it('continues a previous session without a configured default model', async () => { diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index 2275d3a267..f7578c9b72 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -245,6 +245,35 @@ describe('runShell', () => { }); }); + it('resolves the --agent profile into the TUI startup input', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + + await runShell( + { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + agent: 'reviewer', + agentFiles: [], + }, + '1.2.3-test', + ); + + const [, , startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!; + expect(startupInput).toMatchObject({ agentProfile: 'reviewer' }); + }); + it('forwards skillsDirs from CLI options to the harness', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 79f8cfb552..e6e7d15871 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -280,6 +280,24 @@ describe('KimiTUI startup', () => { }); }); + it('binds the resolved agent profile and agent files to the startup session', async () => { + const session = makeSession(); + const harness = makeHarness(session); + const driver = makeDriver(harness, { + ...makeStartupInput({ agent: 'reviewer', agentFiles: ['reviewer.md'] }), + agentProfile: 'reviewer', + }); + + await expect(driver.init()).resolves.toBe(false); + + expect(harness.createSession).toHaveBeenCalledWith({ + workDir: '/tmp/proj-a', + agentProfile: 'reviewer', + agentFiles: ['reviewer.md'], + }); + expect(driver.state.startupState).toBe('ready'); + }); + it('resumes the latest session for --continue and marks history for replay', async () => { const session = makeSession({ id: 'ses-latest' }); const harness = makeHarness(session, { @@ -1204,6 +1222,43 @@ describe('KimiTUI startup', () => { }); }); + it('carries the agent binding into the post-login startup session', async () => { + const session = makeSession(); + const createSession = vi + .fn() + .mockRejectedValueOnce(loginRequiredError()) + .mockResolvedValueOnce(session); + const harness = makeHarness(session, { + getConfig: vi.fn(async () => ({ + defaultModel: 'k2', + thinking: { enabled: false }, + models: { + k2: { model: 'moonshot-v1', maxContextSize: 100 }, + }, + })), + createSession, + }); + const driver = makeDriver(harness, { + ...makeStartupInput({ agent: 'reviewer', agentFiles: ['reviewer.md'] }), + agentProfile: 'reviewer', + }); + + await expect(driver.init()).resolves.toBe(false); + + vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); + await handleLoginCommand(driver as any); + + expect(createSession).toHaveBeenNthCalledWith(2, { + workDir: '/tmp/proj-a', + model: 'k2', + thinking: 'off', + permission: undefined, + planMode: undefined, + agentProfile: 'reviewer', + agentFiles: ['reviewer.md'], + }); + }); + it('does not force manual permission after OAuth login without --yolo', async () => { const session = makeSession({ getStatus: vi.fn(async () => ({ diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md index 95b22a4cff..4caedf6f48 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -121,18 +121,21 @@ 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 are available in print mode (`kimi -p`)**; the interactive TUI rejects them with a clear error for now: +Two CLI flags select which agent drives a new session, in both print mode (`kimi -p`) and the interactive TUI: - **`--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`. It is only valid when starting a new session — it cannot be combined with `--session`/`--continue`, because the file's content is bound at session creation and is not re-applied on resume. +- **`--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`. -For example, in print mode: +Both flags only apply when starting a new session — neither can be combined with `--session`/`--continue`. The agent is bound at session creation, and resuming restores the bound agent automatically, so no flag is needed (or allowed) on resume. + +For example: ```sh +kimi --agent reviewer kimi -p --agent reviewer "Review the changes on this branch" ``` -The bound agent is the session's identity: it is fixed at the session's first bind and cannot be switched later. Re-selecting the already-bound agent (for example resuming with the same `--agent`) is a no-op; selecting a different one fails with an "already bound" error. +The bound agent is the session's identity: it is fixed at the session's first bind and cannot be switched later. In the TUI the flags bind only the startup session; a session created later in the same process (for example via `/new`) starts with the default agent. For main-agent customization, reference `${base_prompt}` in the body so the environment, workspace-instruction, and Skill injections from the default prompt stay in effect; a body without `${base_prompt}` owns the entire prompt, which fits self-contained sub-agents. diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index ec3b950555..bc77b4d001 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 (`kimi -p` only) | -| `--agent-file ` | | Load a custom agent from a Markdown file for this launch and select it (`kimi -p` only). Cannot be repeated or combined with `--agent`, `--session`, or `--continue` | +| `--agent ` | | Start a new session with the specified agent as the main Agent. Cannot be combined with `--session`/`--continue` | +| `--agent-file ` | | Load a custom agent from a Markdown file for the new session and select it. Cannot be repeated or combined with `--agent`, `--session`, or `--continue` | | `--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,13 +98,14 @@ There are two ways to specify Skills directories, with different semantics: ### Custom Agents -`--agent` and `--agent-file` select which agent drives the session. Both are available under `kimi -p`; interactive launches reject them with a clear error: +`--agent` and `--agent-file` select which agent drives a new session, in both print mode (`kimi -p`) and the interactive TUI: ```sh +kimi --agent reviewer kimi -p --agent reviewer "Review the changes on this branch" ``` -`--agent-file` registers a single agent file at the highest priority for this launch only and selects it; the flag cannot be repeated, `--agent` and `--agent-file` are mutually exclusive, and it cannot be combined with `--session`/`--continue` since the file's content is bound at session creation rather than re-applied on resume. The selection is fixed at the session's first bind: resuming with the same `--agent` is a no-op, and switching to a different one fails with an "already bound" error. See [Agents and Sub-Agents](../customization/agents.md#custom-agents) for the agent file format and discovery directories. +`--agent-file` registers a single agent file at the highest priority for this launch only and selects it; the flag cannot be repeated, and `--agent` and `--agent-file` are mutually exclusive. Both flags only apply when starting a new session — neither can be combined with `--session`/`--continue`, because the agent is bound at session creation and resuming restores the bound agent automatically. The selection is fixed at the session's first bind and cannot be switched later; in the TUI the flags bind only the startup session, and a session created later in the same process (for example via `/new`) starts with the default agent. See [Agents and Sub-Agents](../customization/agents.md#custom-agents) for the agent file format and discovery directories. ## Non-Interactive Execution diff --git a/docs/zh/customization/agents.md b/docs/zh/customization/agents.md index c66dcc2dcf..0727b260ff 100644 --- a/docs/zh/customization/agents.md +++ b/docs/zh/customization/agents.md @@ -121,18 +121,21 @@ disallowedTools: ### 选择主 Agent -两个 CLI flag 用于选择驱动会话的 Agent。**二者在 print 模式(`kimi -p`)下可用**;交互式 TUI 会以明确错误拒绝它们: +两个 CLI flag 用于选择驱动新会话的 Agent,在 print 模式(`kimi -p`)和交互式 TUI 中均可使用: - **`--agent `**:以指定 Agent 作为主 Agent 启动会话。名称可以指向内置 Agent 或任何已发现的文件;名称不存在时会报错,并列出可用的 Agent。 -- **`--agent-file `**:以最高优先级加载一个 Agent 文件(仅本次启动)并以其启动。该 flag 只接受一个文件:不可重复传入,也不能与 `--agent` 同时使用。仅在新建会话时有效——不能与 `--session`/`--continue` 组合,因为文件内容在会话创建时绑定,恢复会话时不会重新应用。 +- **`--agent-file `**:以最高优先级加载一个 Agent 文件(仅本次启动)并以其启动。该 flag 只接受一个文件:不可重复传入,也不能与 `--agent` 同时使用。 -例如在 print 模式下: +两个 flag 都仅在新建会话时有效——都不能与 `--session`/`--continue` 组合。Agent 在会话创建时绑定,恢复会话时会自动还原已绑定的 Agent,因此恢复时不需要(也不允许)携带这些 flag。 + +例如: ```sh +kimi --agent reviewer kimi -p --agent reviewer "审查这个分支上的改动" ``` -绑定的 Agent 即会话的身份:在会话首次绑定后即固定,之后不可切换。重复选择已绑定的 Agent(例如以相同的 `--agent` 恢复会话)是 no-op;选择不同的 Agent 会报 "already bound" 错误。 +绑定的 Agent 即会话的身份:在会话首次绑定后即固定,之后不可切换。在 TUI 中,这些 flag 只绑定启动时的会话;之后在同一进程内新建的会话(例如通过 `/new`)使用默认 Agent。 定制主 Agent 时,在正文中引用 `${base_prompt}` 可保持默认提示词的环境、工作区指令和 Skill 注入生效;不引用 `${base_prompt}` 的正文则完全拥有自己的提示词,适合自包含的子 Agent。 diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index e71445d5c3..9808114628 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 启动会话(仅 `kimi -p`) | -| `--agent-file ` | | 从 Markdown 文件加载自定义 Agent(仅本次启动、仅 `kimi -p`)并选中它。不可重复传入,也不能与 `--agent`、`--session` 或 `--continue` 同时使用 | +| `--agent ` | | 以指定 Agent 作为主 Agent 启动新会话。不能与 `--session`/`--continue` 同时使用 | +| `--agent-file ` | | 从 Markdown 文件加载自定义 Agent 并为新会话选中它。不可重复传入,也不能与 `--agent`、`--session` 或 `--continue` 同时使用 | | `--add-dir ` | | 为本次会话添加额外的工作目录。相对路径按当前工作目录解析。可重复传入 | `-r` / `--resume` 是 `--session` 的隐藏别名;`--yes` 和 `--auto-approve` 是 `--yolo` 的隐藏别名,在帮助信息中不显示。 @@ -98,13 +98,14 @@ kimi --plan ### 自定义 Agent -`--agent` 和 `--agent-file` 用于选择驱动会话的 Agent。二者在 `kimi -p` 下均可用,交互式启动会以明确错误拒绝: +`--agent` 和 `--agent-file` 用于选择驱动新会话的 Agent,在 print 模式(`kimi -p`)和交互式 TUI 中均可使用: ```sh +kimi --agent reviewer kimi -p --agent reviewer "审查这个分支上的改动" ``` -`--agent-file` 以最高优先级注册单个 Agent 文件(仅本次启动)并选中它;该 flag 不可重复传入,`--agent` 与 `--agent-file` 互斥,且不能与 `--session`/`--continue` 同时使用——文件内容在会话创建时绑定,恢复会话时不会重新应用。选择在会话首次绑定后即固定:以相同的 `--agent` 恢复会话是 no-op,换成不同的 Agent 会报 "already bound" 错误。Agent 文件格式与发现目录详见 [Agent 与子 Agent](../customization/agents.md#自定义-agent)。 +`--agent-file` 以最高优先级注册单个 Agent 文件(仅本次启动)并选中它;该 flag 不可重复传入,`--agent` 与 `--agent-file` 互斥。两个 flag 都仅在新建会话时有效——都不能与 `--session`/`--continue` 组合,因为 Agent 在会话创建时绑定,恢复会话时会自动还原已绑定的 Agent。选择在会话首次绑定后即固定,之后不可切换;在 TUI 中,这些 flag 只绑定启动时的会话,之后在同一进程内新建的会话(例如通过 `/new`)使用默认 Agent。Agent 文件格式与发现目录详见 [Agent 与子 Agent](../customization/agents.md#自定义-agent)。 ## 非交互执行 From 1d4275e656094e14a62bdeba95c60c51a8ba1fa2 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 28 Jul 2026 13:09:50 +0800 Subject: [PATCH 28/30] fix(agent-core): persist new secondary-model selections under env overrides stripSecondaryModelConfig restored secondary_model.model/default_effort from raw whenever KIMI_SECONDARY_MODEL/KIMI_SECONDARY_EFFORT was set, so a /secondary_model pick made under the env vars was silently discarded on write. Restore from raw only when the value being written still equals the env value (an overlay round-trip), mirroring the pointer check in stripEnvModelConfig; a genuinely different selection now reaches config.toml. --- .../agent-core/src/config/secondary-model.ts | 21 +++++++----- .../test/config/secondary-model.test.ts | 34 +++++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/packages/agent-core/src/config/secondary-model.ts b/packages/agent-core/src/config/secondary-model.ts index d3dbb7c41d..507e36b71c 100644 --- a/packages/agent-core/src/config/secondary-model.ts +++ b/packages/agent-core/src/config/secondary-model.ts @@ -98,9 +98,11 @@ export function applySecondaryModelConfig(config: KimiConfig, env: Env = process * mirroring the v2 overlay which strips a user-configured entry under the * reserved id all the same), a `default_model` pointer at the derived entry * (restored from raw so it cannot dangle after the recipe is removed), and - * the env-injected recipe fields (restored from raw while the env vars are - * set, so a `getConfig` -> `setConfig` round-trip cannot persist shell - * overrides). + * the env-injected recipe fields (restored from raw when the value being + * written still equals the env value, so a `getConfig` -> `setConfig` + * round-trip cannot persist shell overrides, while a genuinely new selection + * — e.g. a `/secondary_model` pick made under `KIMI_SECONDARY_MODEL` — does + * reach the disk, mirroring the pointer check in `stripEnvModelConfig`). */ export function stripSecondaryModelConfig( config: KimiConfig, @@ -122,16 +124,19 @@ export function stripSecondaryModelConfig( }; } - const envModelSet = trimmed(env[SECONDARY_MODEL_ENV]) !== undefined; - const envEffortSet = trimmed(env[SECONDARY_MODEL_EFFORT_ENV]) !== undefined; - if ((envModelSet || envEffortSet) && next.secondaryModel !== undefined) { + const envModel = trimmed(env[SECONDARY_MODEL_ENV]); + const envEffort = trimmed(env[SECONDARY_MODEL_EFFORT_ENV]); + if ((envModel !== undefined || envEffort !== undefined) && next.secondaryModel !== undefined) { const raw = isPlainObject(config.raw?.['secondary_model']) ? config.raw['secondary_model'] : {}; const restored = { ...next.secondaryModel }; - if (envModelSet) { + // Restore from raw only when the value being written still IS the env + // value (a round-trip of the overlaid runtime view). A different value is + // a deliberate new selection and must persist. + if (envModel !== undefined && restored.model === envModel) { if (typeof raw['model'] === 'string') restored.model = raw['model']; else delete restored.model; } - if (envEffortSet) { + if (envEffort !== undefined && restored.defaultEffort === envEffort) { if (typeof raw['default_effort'] === 'string') restored.defaultEffort = raw['default_effort']; else delete restored.defaultEffort; } diff --git a/packages/agent-core/test/config/secondary-model.test.ts b/packages/agent-core/test/config/secondary-model.test.ts index e8007b6841..723cc1d9dc 100644 --- a/packages/agent-core/test/config/secondary-model.test.ts +++ b/packages/agent-core/test/config/secondary-model.test.ts @@ -154,6 +154,40 @@ describe('stripSecondaryModelConfig', () => { expect(stripped.secondaryModel).toEqual({ model: 'cheap', defaultEffort: 'low' }); }); + it('keeps a genuinely new selection that differs from the env values', () => { + // `/secondary_model` under KIMI_SECONDARY_MODEL: the picked recipe must + // reach the disk; only overlay round-trips are restored from raw. + const onDisk = parseConfigString( + ['[secondary_model]', 'model = "cheap"', 'default_effort = "low"'].join('\n'), + ); + const picked: KimiConfig = { + ...onDisk, + secondaryModel: { model: 'main', defaultEffort: 'high' }, + }; + const stripped = stripSecondaryModelConfig(picked, { + KIMI_SECONDARY_MODEL: 'cheap', + KIMI_SECONDARY_EFFORT: 'low', + }); + expect(stripped.secondaryModel).toEqual({ model: 'main', defaultEffort: 'high' }); + }); + + it('restores only the fields still carrying the env values', () => { + const onDisk = parseConfigString( + ['[secondary_model]', 'model = "cheap"', 'default_effort = "low"'].join('\n'), + ); + // The model still carries the env value (restored from raw); the effort + // is a new pick (persists). + const mixed: KimiConfig = { + ...onDisk, + secondaryModel: { model: 'main', defaultEffort: 'high' }, + }; + const stripped = stripSecondaryModelConfig(mixed, { + KIMI_SECONDARY_MODEL: 'main', + KIMI_SECONDARY_EFFORT: 'low', + }); + expect(stripped.secondaryModel).toEqual({ model: 'cheap', defaultEffort: 'high' }); + }); + it('keeps file-sourced recipe fields when no env override is active', () => { const runtime = configWithSecondary(); const stripped = stripSecondaryModelConfig(runtime, {}); From 7f76b85e89e5eef7f86645c20036174a7c18bfdd Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 28 Jul 2026 13:09:57 +0800 Subject: [PATCH 29/30] fix(cli): report the effective secondary model when env overrides the pick /secondary_model toasted the picked alias even when KIMI_SECONDARY_MODEL/KIMI_SECONDARY_EFFORT made the session bind a different model. Read the effective binding back from the reloaded config (as /model does from session status) and warn with the env-overridden values instead. --- apps/kimi-code/src/tui/commands/config.ts | 24 +++++++++++++++ .../test/tui/commands/secondary-model.test.ts | 29 ++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 3b4a450eb0..2422dc5648 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -644,6 +644,30 @@ async function performSecondaryModelSwitch( } } host.setAppState({ availableModels: updatedConfig.models ?? {} }); + // Report the effective binding from the reloaded config, not the picked + // value: KIMI_SECONDARY_MODEL / KIMI_SECONDARY_EFFORT override the recipe at + // runtime, and the session binds the overlaid snapshot (mirrors how + // /model displays the effective alias read back from the session). + const effective = updatedConfig.secondaryModel; + const envOverrides: string[] = []; + if (effective?.model !== undefined && effective.model !== alias) { + envOverrides.push(`KIMI_SECONDARY_MODEL=${effective.model}`); + } + if (effective?.defaultEffort !== undefined && effective.defaultEffort !== effort) { + envOverrides.push(`KIMI_SECONDARY_EFFORT=${effective.defaultEffort}`); + } + if (envOverrides.length > 0 && effective?.model !== undefined) { + const effectiveName = modelDisplayName( + effective.model, + updatedConfig.models?.[effective.model], + ); + host.showStatus( + `Saved ${displayName} as the secondary model, but ${envOverrides.join(' and ')} ` + + `overrides it at runtime — subagents bind ${effectiveName} until the env var is unset.`, + 'warning', + ); + return; + } host.showStatus( host.session === undefined ? `Secondary model set to ${displayName} with thinking ${effort}; applies to new sessions.` diff --git a/apps/kimi-code/test/tui/commands/secondary-model.test.ts b/apps/kimi-code/test/tui/commands/secondary-model.test.ts index b4cdee5ea0..81b309ef01 100644 --- a/apps/kimi-code/test/tui/commands/secondary-model.test.ts +++ b/apps/kimi-code/test/tui/commands/secondary-model.test.ts @@ -32,6 +32,8 @@ function makeHost(options?: { readonly withSession?: boolean; readonly secondaryModel?: { model: string; defaultEffort?: string }; readonly persistedModels?: Record; + /** The secondary model the reloaded config carries — env overlays win. */ + readonly effectiveSecondary?: { model: string; defaultEffort?: string }; }) { const session = options?.withSession === false ? undefined @@ -59,7 +61,11 @@ function makeHost(options?: { providers: {}, secondaryModel: options?.secondaryModel, })), - setConfig: vi.fn(async () => ({ providers: {}, models: options?.persistedModels })), + setConfig: vi.fn(async () => ({ + providers: {}, + models: options?.persistedModels, + secondaryModel: options?.effectiveSecondary, + })), }, session, setAppState: vi.fn((patch) => Object.assign(appState, patch)), @@ -139,6 +145,27 @@ describe('handleSecondaryModelCommand', () => { expect(host.state.appState.availableModels['__secondary__']?.displayName).toBe('k2'); }); + it('warns with the env-overridden effective binding instead of the picked model', async () => { + // KIMI_SECONDARY_MODEL / KIMI_SECONDARY_EFFORT win over the persisted + // recipe: the reloaded config carries the overlaid values, and the status + // message must name them rather than echo the pick. + const { host } = makeHost({ + effectiveSecondary: { model: 'cheap', defaultEffort: 'low' }, + }); + + await handleSecondaryModelCommand(host, ''); + mountedPicker(host).onSelect({ alias: 'k2', thinking: 'high' }); + + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalled(); + }); + const [message, color] = host.showStatus.mock.calls[0]!; + expect(message).toContain('KIMI_SECONDARY_MODEL=cheap'); + expect(message).toContain('KIMI_SECONDARY_EFFORT=low'); + expect(color).toBe('warning'); + expect(host.showError).not.toHaveBeenCalled(); + }); + it('keeps the current effective model map when live apply fails', async () => { const { host, session } = makeHost({ persistedModels: { From a977778b3b817a62c2b47639a33749be026d4c5e Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 28 Jul 2026 13:14:09 +0800 Subject: [PATCH 30/30] feat(tui): show the bound model name in the AgentSwarm panel header --- .../messages/agent-swarm-progress.ts | 17 ++++++++++++- .../tui/controllers/subagent-event-handler.ts | 9 +++++++ .../messages/agent-swarm-progress.test.ts | 24 +++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts index 75c7266a2f..ec2e499621 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -190,6 +190,7 @@ export class AgentSwarmProgressComponent implements Component { private description: string; private readonly requestRender: (() => void) | undefined; private readonly availableGridHeight: (() => number | undefined) | undefined; + private modelDisplay = ''; private inputComplete = false; private failed = false; private aborted = false; @@ -224,6 +225,16 @@ export class AgentSwarmProgressComponent implements Component { this.activitySpinnerText = provider; } + /** + * Show the bound model once in the header. Every swarm member binds to the + * same model, so the first child status update wins and later ones (e.g. + * from resumed agents that kept a different binding) do not churn it. + */ + setModelDisplay(modelDisplay: string): void { + if (this.modelDisplay.length > 0 || modelDisplay.length === 0) return; + this.modelDisplay = modelDisplay; + } + markToolCallEnded(): void { this.toolCallActive = false; this.activitySpinnerText = undefined; @@ -481,9 +492,13 @@ export class AgentSwarmProgressComponent implements Component { this.description.length > 0 ? chalk.hex(this.colors.primary)(' ─ ') + chalk.hex(this.colors.text)(this.description) : ''; + const model = + this.modelDisplay.length > 0 + ? chalk.hex(this.colors.primary)(' ─ ') + chalk.hex(this.colors.textDim)(this.modelDisplay) + : ''; const prefixText = '─ '; const labelWidth = Math.max(1, width - visibleWidth(prefixText) - 1); - const label = truncateToWidth(title + description, labelWidth); + const label = truncateToWidth(title + description + model, labelWidth); const suffixWidth = Math.max(0, width - visibleWidth(prefixText) - visibleWidth(label)); const suffix = suffixWidth === 0 ? '' : ` ${'─'.repeat(Math.max(0, suffixWidth - 1))}`; return chalk.hex(this.colors.primary)(prefixText) + label + chalk.hex(this.colors.primary)(suffix); diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index 6227c6dc06..4368ea541c 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -511,6 +511,15 @@ export class SubAgentEventHandler { progress.appendModelDelta({ agentId: subagentId, delta: event.delta }); } else if (event.type === 'tool.call.started') { progress.recordToolCall({ agentId: subagentId, toolCallId: event.toolCallId }); + } else if (event.type === 'agent.status.updated' && event.model !== undefined) { + // The bound model alias rides every child status update (emitted right + // after spawn). Swarm members share one binding, so the panel shows it + // once in the header instead of per cell. `modelDisplayName` falls back + // to the alias itself when the entry is unknown (e.g. the synthesized + // `__secondary__` derived entry is missing). + progress.setModelDisplay( + modelDisplayName(event.model, this.host.state.appState.availableModels[event.model]), + ); } } diff --git a/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts b/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts index ad6389418f..bb460ce0e6 100644 --- a/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts +++ b/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts @@ -167,6 +167,30 @@ describe('AgentSwarmProgressComponent', () => { expect(output).not.toContain('01'); }); + it('shows the bound model display name in the header', () => { + const component = createComponent(); + + component.setModelDisplay('kimi-k2-thinking'); + const lines = renderLines(component); + const headerLine = lines.find((line) => line.includes('Agent Swarm')); + + expect(headerLine).toBeDefined(); + expect(headerLine).toContain('Review changed files ─ kimi-k2-thinking'); + }); + + it('keeps the first reported model when later status updates differ', () => { + const component = createComponent(); + + component.setModelDisplay('kimi-k2-thinking'); + component.setModelDisplay('other-model'); + component.setModelDisplay(''); + + const output = renderText(component); + + expect(output).toContain('kimi-k2-thinking'); + expect(output).not.toContain('other-model'); + }); + it('repaints from the active palette when the theme changes', () => { const previousLevel = chalk.level; chalk.level = 3; // force truecolor so palette differences surface as ANSI