From fe751e12c4625d78533eab262cfed634f8a61f02 Mon Sep 17 00:00:00 2001 From: yorha9e Date: Mon, 20 Jul 2026 00:36:02 +0800 Subject: [PATCH 1/8] feat(agent-core): per-workspace subagent model bindings Add an experimental subagent-model-selection feature (off by default, KIMI_CODE_EXPERIMENTAL_SUBAGENT_MODEL_SELECTION=1) that binds configured model aliases and thinking efforts to subagent types per workspace: - Bindings live in /.kimi-code/local.toml [subagent.] and are applied mechanically at spawn (workspace binding > profile binding > parent inheritance); the calling agent never sees or overrides them, removing LLM discretion from model routing - First unbound spawn of a type asks the user once interactively and persists the answer, including an explicit keep-inheriting choice - /subagent-model command (list/set/clear) manages bindings via a new session RPC chain (agent-core -> node-sdk -> TUI) - Resume/retry are sticky: a subagent always keeps the model/effort it was configured with (persisted via config.update wire records); no mid-conversation model switches. Unresolvable stored aliases fail fast with a configuration error instead of silently realigning - Agent profiles can bind modelAlias/thinkingEffort per subagent type - Effective model/effort are exposed in the subagent.spawned event, AgentBackgroundTaskInfo, approval label, and tool result text --- .changeset/subagent-model-binding.md | 5 + apps/kimi-code/src/tui/commands/dispatch.ts | 4 + apps/kimi-code/src/tui/commands/registry.ts | 35 ++++ .../src/tui/commands/subagent-model.ts | 175 ++++++++++++++++++ .../test/tui/commands/subagent-model.test.ts | 145 +++++++++++++++ docs/en/configuration/env-vars.md | 1 + docs/en/customization/agents.md | 25 +++ docs/zh/configuration/env-vars.md | 1 + docs/zh/customization/agents.md | 25 +++ .../src/agent/background/agent-task.ts | 10 + packages/agent-core/src/agent/tool/index.ts | 4 + .../src/agent/tool/subagent-binding.ts | 114 ++++++++++++ .../agent-core/src/config/workspace-local.ts | 90 +++++++++ packages/agent-core/src/flags/registry.ts | 9 + packages/agent-core/src/profile/resolve.ts | 10 +- packages/agent-core/src/profile/types.ts | 8 + packages/agent-core/src/rpc/core-api.ts | 16 +- packages/agent-core/src/rpc/core-impl.ts | 16 ++ packages/agent-core/src/session/index.ts | 26 +++ packages/agent-core/src/session/rpc.ts | 11 ++ .../agent-core/src/session/subagent-host.ts | 82 ++++++-- .../src/tools/builtin/collaboration/agent.ts | 74 +++++++- packages/agent-core/test/agent/tool.test.ts | 107 ++++++++++- .../test/config/workspace-local.test.ts | 81 ++++++++ .../test/profile/agent-profile-loader.test.ts | 24 +++ .../test/session/subagent-host.test.ts | 120 +++++++++++- packages/agent-core/test/tools/agent.test.ts | 158 ++++++++++++++++ packages/node-sdk/src/rpc.ts | 17 ++ packages/node-sdk/src/session.ts | 21 +++ packages/node-sdk/src/types.ts | 20 ++ .../node-sdk/test/session-context.test.ts | 26 +++ packages/protocol/src/events.ts | 6 + 32 files changed, 1437 insertions(+), 29 deletions(-) create mode 100644 .changeset/subagent-model-binding.md create mode 100644 apps/kimi-code/src/tui/commands/subagent-model.ts create mode 100644 apps/kimi-code/test/tui/commands/subagent-model.test.ts create mode 100644 packages/agent-core/src/agent/tool/subagent-binding.ts diff --git a/.changeset/subagent-model-binding.md b/.changeset/subagent-model-binding.md new file mode 100644 index 0000000000..5422e94d9a --- /dev/null +++ b/.changeset/subagent-model-binding.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add experimental per-workspace model and thinking-effort bindings for subagent types. Enable the `subagent-model-selection` experiment to bind configured model aliases to subagent types in `.kimi-code/local.toml`; bindings are applied mechanically at spawn (the calling agent cannot override them), are managed via the `/subagent-model` command, and resumed subagents always keep the model they were configured with. diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index dcfb904733..2e102d87b1 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -39,6 +39,7 @@ import { import { handleGoalCommand } from './goal'; import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; import { handleAddDirCommand } from './add-dir'; +import { handleSubagentModelCommand } from './subagent-model'; import { parseSlashInput } from './parse'; import { handlePluginsCommand } from './plugins'; import { handleProviderCommand } from './provider'; @@ -285,6 +286,9 @@ async function handleBuiltInSlashCommand( case 'add-dir': await handleAddDirCommand(host, args); return; + case 'subagent-model': + await handleSubagentModelCommand(host, args); + return; case 'experiments': await showExperimentsPanel(host); return; diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 063bcd7bfe..f35028a62e 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -49,6 +49,31 @@ export function swarmArgumentCompletions(argumentPrefix: string): AutocompleteIt return completeLeadingArg(SWARM_ARG_COMPLETIONS, argumentPrefix); } +const SUBAGENT_MODEL_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ + { value: 'list', description: 'Show current subagent model bindings' }, + { value: 'set', description: 'Bind a model for a subagent type' }, + { value: 'clear', description: 'Remove a binding' }, +]; + +/** Argument autocompletion for the `/subagent-model` command. */ +export function subagentModelArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { + const subMatch = argumentPrefix.match(/^(set|clear)\s+(\S*)$/i); + if (subMatch !== null) { + const types: readonly ArgCompletionSpec[] = [ + { value: 'coder', description: 'General software engineering subagent' }, + { value: 'explore', description: 'Read-only exploration subagent' }, + { value: 'plan', description: 'Read-only planning subagent' }, + ]; + return ( + completeLeadingArg(types, subMatch[2] ?? '')?.map((item) => ({ + ...item, + value: `${subMatch[1]} ${item.value}`, + })) ?? null + ); + } + return completeLeadingArg(SUBAGENT_MODEL_ARG_COMPLETIONS, argumentPrefix); +} + /** Argument autocompletion for the `/add-dir` command. */ export function addDirArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { if (isPathLikeAddDirArgument(argumentPrefix)) { @@ -254,6 +279,16 @@ export const BUILTIN_SLASH_COMMANDS = [ argumentHint: '[list] | ', completeArgs: addDirArgumentCompletions, }, + { + name: 'subagent-model', + aliases: [], + description: 'Manage per-workspace model bindings for subagent types', + priority: 60, + availability: 'idle-only', + argumentHint: '[list] | set | clear ', + completeArgs: subagentModelArgumentCompletions, + experimentalFlag: 'subagent-model-selection', + }, { name: 'experiments', aliases: ['experimental'], diff --git a/apps/kimi-code/src/tui/commands/subagent-model.ts b/apps/kimi-code/src/tui/commands/subagent-model.ts new file mode 100644 index 0000000000..b7f2e11983 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/subagent-model.ts @@ -0,0 +1,175 @@ +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; +import type { SlashCommandHost } from './dispatch'; + +const INHERIT_VALUE = '__inherit__'; + +/** + * `/subagent-model` — manage per-workspace model bindings for subagent types + * (stored in `.kimi-code/local.toml`, applied mechanically at spawn when the + * subagent-model-selection experiment is enabled). + * + * /subagent-model [list] show current bindings + * /subagent-model set pick a model (and effort) for a subagent type + * /subagent-model clear remove a binding + */ +export async function handleSubagentModelCommand( + host: SlashCommandHost, + args: string, +): Promise { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const [actionRaw, typeRaw] = args.trim().split(/\s+/, 2); + const action = (actionRaw ?? '').toLowerCase() || 'list'; + const agentType = (typeRaw ?? '').trim(); + + if (action === 'list') { + const bindings = await session.getSubagentBindings(); + const entries = Object.entries(bindings); + if (entries.length === 0) { + host.showStatus( + 'No subagent model bindings in this workspace.\n' + + 'Use /subagent-model set to bind a model, or spawn a subagent to be asked once.', + ); + return; + } + host.showStatus( + [ + 'Subagent model bindings (workspace):', + ...entries.map( + ([type, binding]) => + ` ${type}: ${formatBinding(binding)}`, + ), + ].join('\n'), + ); + return; + } + + if (action === 'clear') { + if (agentType.length === 0) { + host.showError('Usage: /subagent-model clear '); + return; + } + try { + const result = await session.setSubagentBinding(agentType, undefined); + host.showStatus(`Cleared model binding for "${agentType}".\nSaved to:\n ${result.configPath}`, 'success'); + } catch (error) { + host.showError(error instanceof Error ? error.message : String(error)); + } + return; + } + + if (action === 'set') { + if (agentType.length === 0) { + host.showError('Usage: /subagent-model set '); + return; + } + const aliases = Object.keys(host.state.appState.availableModels).toSorted(); + if (aliases.length === 0) { + host.showError('No models configured. Run /login or /provider first.'); + return; + } + host.mountEditorReplacement( + new ChoicePickerComponent({ + title: `Bind model for subagent "${agentType}"`, + hint: '↑↓ navigate · Enter confirm · Esc cancel', + options: [ + { + value: INHERIT_VALUE, + label: 'Keep inheriting from the main agent', + }, + ...aliases.map((alias) => ({ value: alias, label: alias })), + ], + onSelect: (value) => { + if (value === INHERIT_VALUE) { + host.restoreEditor(); + void persistBinding(host, agentType, { inherit: true }); + return; + } + void pickThinkingEffort(host, agentType, value); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); + return; + } + + host.showError('Usage: /subagent-model [list] | set | clear '); +} + +function formatBinding(binding: { + model?: string; + thinkingEffort?: string; + inherit?: boolean; +}): string { + if (binding.inherit === true) return 'inherit from main agent'; + const parts = [binding.model ?? 'inherit model']; + if (binding.thinkingEffort !== undefined) parts.push(`thinking ${binding.thinkingEffort}`); + return parts.join(', '); +} + +async function pickThinkingEffort( + host: SlashCommandHost, + agentType: string, + model: string, +): Promise { + const supportEfforts = + host.state.appState.availableModels[model]?.supportEfforts?.filter( + (effort) => effort.length > 0, + ) ?? []; + if (supportEfforts.length === 0) { + host.restoreEditor(); + await persistBinding(host, agentType, { model }); + return; + } + host.restoreEditor(); + host.mountEditorReplacement( + new ChoicePickerComponent({ + title: `Thinking effort for subagent "${agentType}" on ${model}`, + hint: '↑↓ navigate · Enter confirm · Esc skip (inherit effort)', + options: [ + { value: INHERIT_VALUE, label: 'Inherit the main agent thinking effort' }, + ...supportEfforts.map((effort) => ({ value: effort, label: effort })), + ], + onSelect: (value) => { + host.restoreEditor(); + void persistBinding( + host, + agentType, + value === INHERIT_VALUE ? { model } : { model, thinkingEffort: value }, + ); + }, + onCancel: () => { + host.restoreEditor(); + void persistBinding(host, agentType, { model }); + }, + }), + ); +} + +async function persistBinding( + host: SlashCommandHost, + agentType: string, + binding: { model?: string; thinkingEffort?: string; inherit?: boolean }, +): Promise { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + try { + const result = await session.setSubagentBinding(agentType, binding); + host.showStatus( + `Subagent "${agentType}" binding: ${formatBinding(binding)}\nSaved to:\n ${result.configPath}`, + 'success', + ); + } catch (error) { + host.showError(error instanceof Error ? error.message : String(error)); + } +} diff --git a/apps/kimi-code/test/tui/commands/subagent-model.test.ts b/apps/kimi-code/test/tui/commands/subagent-model.test.ts new file mode 100644 index 0000000000..0076efc823 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/subagent-model.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { handleSubagentModelCommand } from '#/tui/commands/subagent-model'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; + +type MountedPanel = { + handleInput: (data: string) => void; + render: (width: number) => string[]; +}; + +function makeHost(options: { + bindings?: Record; + availableModels?: Record; +}) { + const bindings = options.bindings ?? {}; + const availableModels = options.availableModels ?? { 'k3': {}, 'glm': { supportEfforts: ['low', 'high'] } }; + const state = { + appState: { + availableModels, + streamingPhase: 'idle', + isCompacting: false, + }, + }; + let mountedPanel: MountedPanel | null = null; + const session = { + id: 'session-1', + getSubagentBindings: vi.fn(async () => bindings), + setSubagentBinding: vi.fn( + async (_type: string, _binding?: unknown) => ({ configPath: '/repo/.kimi-code/local.toml' }), + ), + }; + const host = { + state, + session, + showError: vi.fn(), + showStatus: vi.fn(), + mountEditorReplacement: vi.fn((panel: MountedPanel) => { + mountedPanel = panel; + }), + restoreEditor: vi.fn(() => { + mountedPanel = null; + }), + } as unknown as SlashCommandHost & { + session: typeof session; + showError: ReturnType; + showStatus: ReturnType; + mountEditorReplacement: ReturnType; + restoreEditor: ReturnType; + }; + return { host, session, getMountedPanel: () => mountedPanel }; +} + +describe('handleSubagentModelCommand', () => { + it('shows guidance when no bindings exist', async () => { + const { host } = makeHost({ bindings: {} }); + + await handleSubagentModelCommand(host, ''); + + expect(host.showStatus).toHaveBeenCalledWith(expect.stringContaining('No subagent model bindings')); + }); + + it('lists current bindings', async () => { + const { host } = makeHost({ + bindings: { + coder: { model: 'k3', thinkingEffort: 'high' }, + explore: { inherit: true }, + }, + }); + + await handleSubagentModelCommand(host, 'list'); + + expect(host.showStatus).toHaveBeenCalledWith( + expect.stringContaining('coder: k3, thinking high'), + ); + expect(host.showStatus).toHaveBeenCalledWith( + expect.stringContaining('explore: inherit from main agent'), + ); + }); + + it('clears a binding', async () => { + const { host, session } = makeHost({}); + + await handleSubagentModelCommand(host, 'clear coder'); + + expect(session.setSubagentBinding).toHaveBeenCalledWith('coder', undefined); + expect(host.showStatus).toHaveBeenCalledWith( + expect.stringContaining('Cleared model binding for "coder"'), + 'success', + ); + }); + + it('binds a model without an effort question when the model declares no efforts', async () => { + const { host, session, getMountedPanel } = makeHost({}); + + await handleSubagentModelCommand(host, 'set coder'); + // Options: [inherit, glm, k3] — pick k3 (no declared efforts). + getMountedPanel()?.handleInput(''); + getMountedPanel()?.handleInput(''); + getMountedPanel()?.handleInput(' '); + + await vi.waitFor(() => { + expect(session.setSubagentBinding).toHaveBeenCalledWith('coder', { model: 'k3' }); + }); + }); + + it('binds a model with a chosen thinking effort', async () => { + const { host, session, getMountedPanel } = makeHost({}); + + await handleSubagentModelCommand(host, 'set explore'); + // Options: [inherit, glm, k3] — pick glm. + getMountedPanel()?.handleInput(''); + getMountedPanel()?.handleInput(' '); + // Effort options: [inherit, low, high] — pick high. + await vi.waitFor(() => expect(getMountedPanel()).not.toBeNull()); + getMountedPanel()?.handleInput(''); + getMountedPanel()?.handleInput(''); + getMountedPanel()?.handleInput(' '); + + await vi.waitFor(() => { + expect(session.setSubagentBinding).toHaveBeenCalledWith('explore', { + model: 'glm', + thinkingEffort: 'high', + }); + }); + }); + + it('records an explicit inherit choice', async () => { + const { host, session, getMountedPanel } = makeHost({}); + + await handleSubagentModelCommand(host, 'set explore'); + getMountedPanel()?.handleInput(' '); + + await vi.waitFor(() => { + expect(session.setSubagentBinding).toHaveBeenCalledWith('explore', { inherit: true }); + }); + }); + + it('rejects a set without a type', async () => { + const { host } = makeHost({}); + + await handleSubagentModelCommand(host, 'set'); + + expect(host.showError).toHaveBeenCalledWith('Usage: /subagent-model set '); + }); +}); diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 1ae9413913..50f02bd9c6 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -136,6 +136,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_WEB_SEARCH_API_KEY` | API key of the web search (`WebSearch`) service; replaces both the configured API key and OAuth credential when set | Non-blank string; blank values are ignored | | `KIMI_WEB_FETCH_BASE_URL` | API URL of the web fetch (`FetchURL`) service; takes higher priority than `[services.moonshot_fetch] base_url`. Persisted credentials and custom headers are not forwarded to an env-selected endpoint. Without an env or config endpoint, signed-in users try the managed Kimi OAuth fetch service before direct local requests | Non-blank string; blank values are ignored | | `KIMI_WEB_FETCH_API_KEY` | API key of the web fetch (`FetchURL`) service; replaces both the configured API key and OAuth credential when set | Non-blank string; blank values are ignored | +| `KIMI_CODE_EXPERIMENTAL_SUBAGENT_MODEL_SELECTION` | Enable per-workspace model/effort bindings for [`Agent`](../customization/agents.md#how-to-invoke) subagent types (`.kimi-code/local.toml`, managed via `/subagent-model`) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_EXPERIMENTAL_FLAG` | Enable all registered experimental features for this process | `1`, `true`, `yes`, `on` | | `KIMI_SHELL_PATH` | Override the Git Bash path on Windows (used when auto-detection fails) | Absolute path | | `KIMI_MODEL_MAX_COMPLETION_TOKENS` | Hard cap on `max_completion_tokens` per LLM step; applies to the `kimi` provider only | Positive integer; `0` or negative disables clamping | diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md index 93d5e1d1d4..bcfcf57007 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -22,6 +22,31 @@ Each dispatch is presented in the terminal as an approval request (unless it mat Sub-agents support running in the background: results are automatically returned to the main Agent upon completion, with no manual polling needed. You can also call back an existing sub-agent instance to continue the same task. +Binding a model or thinking effort to a sub-agent type is experimental and disabled by default. Enable it persistently in `config.toml`: + +```toml +[experimental] +subagent-model-selection = true +``` + +To enable it only for the current process, set the dedicated environment variable instead: + +```sh +export KIMI_CODE_EXPERIMENTAL_SUBAGENT_MODEL_SELECTION=1 +``` + +Bindings are **yours, not the model's**: each workspace keeps its own bindings in `.kimi-code/local.toml` (in the project root) under `[subagent.]`: + +```toml +[subagent.coder] +model = "kimi-code/kimi-for-coding" +thinking_effort = "high" +``` + +The first time an unbound sub-agent type is spawned in a workspace, you are asked once whether to bind a model (answering "keep inheriting" is remembered too). Afterwards the binding is applied mechanically to every new sub-agent of that type — the calling Agent cannot see or override it. Manage bindings anytime with the `/subagent-model` command (`list` / `set ` / `clear `). Precedence: workspace binding > profile binding (for profiles shipped with the app) > inherit the calling Agent's current model and effort. + +Bound values are fixed at spawn: resuming a sub-agent always keeps the model and effort it was configured with, mid-conversation switches are not possible, and both are restored after a session restart. A bound alias is validated against your models configuration — if it no longer resolves (for example after being removed from `config.toml`), spawning or resuming fails with a configuration error until you update or clear the binding. + ## Context Isolation and Resource Cost Each sub-agent has a fully independent context window. It can only see the task description explicitly passed by the main Agent and cannot see the main Agent's conversation history. The sub-agent's own intermediate reasoning and tool call records do not flow back; only the final result appears in the main Agent's context. diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 1e4bba38cc..f3ba7dbde9 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -136,6 +136,7 @@ kimi | `KIMI_WEB_SEARCH_API_KEY` | 网页搜索(`WebSearch`)服务的 API 密钥;设置后同时替换配置中的 API 密钥和 OAuth 凭据 | 非空字符串;空白值被忽略 | | `KIMI_WEB_FETCH_BASE_URL` | 网页抓取(`FetchURL`)服务的 API URL;优先级高于 `[services.moonshot_fetch] base_url`。文件中持久化的凭据和自定义 header 不会发送到环境变量指定的端点。环境变量和配置都没有指定端点时,已登录用户会先尝试 Kimi OAuth 托管抓取服务,再回退到本地直接请求 | 非空字符串;空白值被忽略 | | `KIMI_WEB_FETCH_API_KEY` | 网页抓取(`FetchURL`)服务的 API 密钥;设置后同时替换配置中的 API 密钥和 OAuth 凭据 | 非空字符串;空白值被忽略 | +| `KIMI_CODE_EXPERIMENTAL_SUBAGENT_MODEL_SELECTION` | 为 [`Agent`](../customization/agents.md#调用方式) 子 Agent 类型启用按工作区的模型/思考强度绑定(`.kimi-code/local.toml`,用 `/subagent-model` 管理) | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_EXPERIMENTAL_FLAG` | 在当前进程启用所有已注册的实验功能 | `1`、`true`、`yes`、`on` | | `KIMI_SHELL_PATH` | Windows 上覆盖 Git Bash 路径(自动探测失败时使用) | 绝对路径 | | `KIMI_MODEL_MAX_COMPLETION_TOKENS` | 单步 LLM 请求的 `max_completion_tokens` 硬上限,仅对 `kimi` 供应商生效 | 正整数;`0` 或负数禁用 clamp | diff --git a/docs/zh/customization/agents.md b/docs/zh/customization/agents.md index c095ba1eed..628efacbca 100644 --- a/docs/zh/customization/agents.md +++ b/docs/zh/customization/agents.md @@ -22,6 +22,31 @@ Kimi Code CLI 内置三种子 Agent,开箱即用,分别面向不同任务形 子 Agent 支持在后台运行:完成后结果自动回到主 Agent,无需手动轮询。也可以唤回已有的子 Agent 实例继续推进同一任务。 +为子 Agent 类型绑定模型或思考强度是实验功能,默认关闭。在 `config.toml` 中持久开启: + +```toml +[experimental] +subagent-model-selection = true +``` + +只对当前进程开启的话,设置专用环境变量: + +```sh +export KIMI_CODE_EXPERIMENTAL_SUBAGENT_MODEL_SELECTION=1 +``` + +绑定属于**你而不是模型**:每个工作区在项目根的 `.kimi-code/local.toml` 里保存自己的绑定,形如 `[subagent.<类型>]`: + +```toml +[subagent.coder] +model = "kimi-code/kimi-for-coding" +thinking_effort = "high" +``` + +某个子 Agent 类型在工作区首次被派发且没有绑定时,会询问你一次是否绑定模型(选择"保持继承"也会被记住)。之后该绑定会机械地应用到该类型的每个新子 Agent 上——主 Agent 看不到也无法覆盖它。随时可以用 `/subagent-model` 命令管理绑定(`list` / `set <类型>` / `clear <类型>`)。优先级:工作区绑定 > profile 绑定(随应用分发的 profile)> 继承主 Agent 当前的模型与思考强度。 + +绑定值在派发时固化:唤回(resume)子 Agent 总是保持它已配置的模型与思考强度,无法在对话中途切换,会话重启后也会恢复。绑定的别名会对照你的模型配置校验——如果它不再可解析(比如已从 `config.toml` 删除),派发或唤回会以配置错误失败,直到你更新或清除该绑定。 + ## 上下文隔离与资源开销 每个子 Agent 拥有完全独立的上下文窗口,只能看到主 Agent 显式传入的任务描述,看不到主 Agent 的对话历史。子 Agent 自己的中间思考和工具调用记录不会回流,只有最终结果会出现在主 Agent 的上下文里。 diff --git a/packages/agent-core/src/agent/background/agent-task.ts b/packages/agent-core/src/agent/background/agent-task.ts index c317968033..40d377e96d 100644 --- a/packages/agent-core/src/agent/background/agent-task.ts +++ b/packages/agent-core/src/agent/background/agent-task.ts @@ -12,6 +12,10 @@ export interface AgentBackgroundTaskInfo extends BackgroundTaskInfoBase { readonly agentId?: string; /** Subagent profile name. */ readonly subagentType?: string; + /** Effective model alias of the subagent run, when resolved. */ + readonly modelAlias?: string; + /** Effective thinking effort of the subagent run, when resolved. */ + readonly thinkingEffort?: string; } export class AgentBackgroundTask implements BackgroundTask { @@ -19,6 +23,8 @@ export class AgentBackgroundTask implements BackgroundTask { readonly idPrefix: string = 'agent'; readonly agentId: string; readonly subagentType: string; + readonly modelAlias?: string; + readonly thinkingEffort?: string; constructor( private readonly handle: SubagentHandle, @@ -28,6 +34,8 @@ export class AgentBackgroundTask implements BackgroundTask { ) { this.agentId = handle.agentId; this.subagentType = handle.profileName; + this.modelAlias = handle.modelAlias; + this.thinkingEffort = handle.thinkingEffort; } async start(sink: BackgroundTaskSink): Promise { @@ -65,6 +73,8 @@ export class AgentBackgroundTask implements BackgroundTask { kind: 'agent', agentId: this.agentId, subagentType: this.subagentType, + modelAlias: this.modelAlias, + thinkingEffort: this.thinkingEffort, }; } } diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index 9833707dbc..2d645aa455 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -10,6 +10,7 @@ import { makeErrorPayload } from '../../errors'; import type { ExecutableTool, ToolUpdate } from '../../loop'; import { createMcpAuthTool } from '../../mcp/auth-tool'; import type { McpConnectionManager, McpServerEntry } from '../../mcp'; +import { createSubagentBindingCallbacks } from './subagent-binding'; import { mcpResultToExecutableOutput } from '../../mcp/output'; import { isMcpToolName, qualifyMcpToolName } from '../../mcp/tool-naming'; import type { MCPClient, MCPToolDefinition } from '../../mcp/types'; @@ -756,6 +757,9 @@ export class ToolManager { allowBackground, log: this.agent.log, subagentTimeoutMs: resolveSubagentTimeoutMs(this.agent.kimiConfig?.subagent?.timeoutMs), + modelSelectionEnabled: () => + this.agent.experimentalFlags.enabled('subagent-model-selection'), + ...createSubagentBindingCallbacks(this.agent, kaos, cwd), }, ), this.agent.subagentHost && diff --git a/packages/agent-core/src/agent/tool/subagent-binding.ts b/packages/agent-core/src/agent/tool/subagent-binding.ts new file mode 100644 index 0000000000..4beea9edb5 --- /dev/null +++ b/packages/agent-core/src/agent/tool/subagent-binding.ts @@ -0,0 +1,114 @@ +/** + * Workspace subagent model bindings — callback factory for the Agent tool. + * + * Bindings live in `/.kimi-code/local.toml` under + * `[subagent.]` (see `config/workspace-local.ts`). The Agent tool uses + * these callbacks to (a) apply a binding mechanically on spawn and (b) ask + * the user interactively the first time an unbound subagent type is spawned + * in this workspace, persisting the answer — including an explicit "keep + * inheriting" choice so the question never repeats for that type. + */ + +import type { Kaos } from '@moonshot-ai/kaos'; + +import type { Agent } from '../index'; +import type { QuestionAnswers, QuestionResult } from '../../rpc'; +import { + readSubagentBinding, + writeSubagentBinding, + type SubagentBinding, +} from '../../config/workspace-local'; + +export type ReadSubagentBindingCallback = ( + profileName: string, +) => Promise; + +export type AskSubagentBindingCallback = ( + profileName: string, +) => Promise; + +const INHERIT_LABEL = 'Keep inheriting from the main agent'; + +/** + * Build the binding callbacks for the Agent tool. `askBinding` is returned + * only when the agent can question the user interactively; in + * non-interactive environments (e.g. print mode) spawns silently inherit. + */ +export function createSubagentBindingCallbacks( + agent: Agent, + kaos: Kaos, + workDir: string, +): { + readBinding: ReadSubagentBindingCallback; + askBinding?: AskSubagentBindingCallback; +} { + const readBinding: ReadSubagentBindingCallback = (profileName) => + readSubagentBinding(kaos, workDir, profileName); + + const requestQuestion = agent.rpc?.requestQuestion?.bind(agent.rpc); + if (requestQuestion === undefined) return { readBinding }; + + const askBinding: AskSubagentBindingCallback = async (profileName) => { + const models = agent.kimiConfig?.models ?? {}; + const aliases = Object.keys(models).toSorted(); + const modelQuestion = `Subagent type "${profileName}" has no model binding in this workspace. Bind a model for it?`; + const modelResult = await requestQuestion({ + questions: [ + { + question: modelQuestion, + header: 'Subagent', + options: [ + { + label: INHERIT_LABEL, + description: 'Recorded as the choice for this workspace; you will not be asked again', + }, + ...aliases.map((alias) => ({ label: alias })), + ], + }, + ], + }); + const chosen = answerFor(modelResult, modelQuestion); + if (chosen === undefined) return undefined; // dismissed — ask again next time + if (chosen === INHERIT_LABEL) { + const binding: SubagentBinding = { inherit: true }; + await writeSubagentBinding(kaos, workDir, profileName, binding); + return binding; + } + + const model = chosen; + let thinkingEffort: string | undefined; + const supportEfforts = models[model]?.supportEfforts ?? []; + if (supportEfforts.length > 0) { + const effortQuestion = `Thinking effort for subagent type "${profileName}" on ${model}?`; + const effortResult = await requestQuestion({ + questions: [ + { + question: effortQuestion, + header: 'Subagent', + options: [ + { label: INHERIT_LABEL, description: 'Inherit the main agent thinking effort' }, + ...supportEfforts.map((effort) => ({ label: effort })), + ], + }, + ], + }); + const effort = answerFor(effortResult, effortQuestion); + if (effort !== undefined && effort !== INHERIT_LABEL) thinkingEffort = effort; + } + + const binding: SubagentBinding = { model, thinkingEffort }; + await writeSubagentBinding(kaos, workDir, profileName, binding); + return binding; + }; + + return { readBinding, askBinding }; +} + +function answerFor(result: QuestionResult, question: string): string | undefined { + if (result === null) return undefined; + // `QuestionResult` is either a bare answers record or `{ answers }`; TS + // cannot narrow the union via `in`, so normalize explicitly. + const answers = ('answers' in result ? result.answers : result) as QuestionAnswers; + const value = answers[question]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} diff --git a/packages/agent-core/src/config/workspace-local.ts b/packages/agent-core/src/config/workspace-local.ts index 337ae76a0c..c9f6c14c5c 100644 --- a/packages/agent-core/src/config/workspace-local.ts +++ b/packages/agent-core/src/config/workspace-local.ts @@ -8,12 +8,19 @@ import { ErrorCodes, KimiError } from '#/errors'; const S_IFMT = 0o170000; const S_IFDIR = 0o040000; +const SubagentBindingTomlSchema = z.object({ + model: z.string().optional(), + thinking_effort: z.string().optional(), + inherit: z.boolean().optional(), +}); + const WorkspaceLocalTomlSchema = z.object({ workspace: z .object({ additional_dir: z.array(z.string()), }) .optional(), + subagent: z.record(z.string(), SubagentBindingTomlSchema).optional(), }); type WorkspaceLocalToml = z.infer; @@ -94,6 +101,89 @@ export async function appendWorkspaceAdditionalDir( return { projectRoot, configPath, additionalDirs: [...fileExistingDirs, additionalDir] }; } +/** + * Per-workspace model/effort binding for one subagent type, stored in + * `/.kimi-code/local.toml` under `[subagent.]`. + * + * `inherit: true` is an explicit user choice to keep parent inheritance — + * recorded so the Agent tool does not re-ask on every spawn. + */ +export interface SubagentBinding { + readonly model?: string; + readonly thinkingEffort?: string; + readonly inherit?: boolean; +} + +/** Read the binding for one subagent type; `undefined` means never configured. */ +export async function readSubagentBinding( + kaos: Kaos, + workDir: string, + agentType: string, +): Promise { + const projectRoot = await findProjectRoot(kaos, workDir); + const configPath = getWorkspaceLocalConfigPath(projectRoot); + const file = await readWorkspaceLocalToml(kaos, configPath); + const entry = file?.parsed.subagent?.[agentType]; + if (entry === undefined) return undefined; + return { + model: entry.model, + thinkingEffort: entry.thinking_effort, + inherit: entry.inherit, + }; +} + +/** Read all subagent bindings for the workspace (for display/management). */ +export async function readSubagentBindings( + kaos: Kaos, + workDir: string, +): Promise>> { + const projectRoot = await findProjectRoot(kaos, workDir); + const configPath = getWorkspaceLocalConfigPath(projectRoot); + const file = await readWorkspaceLocalToml(kaos, configPath); + const entries = file?.parsed.subagent ?? {}; + return Object.fromEntries( + Object.entries(entries).map(([type, entry]) => [ + type, + { model: entry.model, thinkingEffort: entry.thinking_effort, inherit: entry.inherit }, + ]), + ); +} + +/** + * Write (or clear, when `binding` is `undefined`) the binding for one + * subagent type, preserving unrelated TOML content. + */ +export async function writeSubagentBinding( + kaos: Kaos, + workDir: string, + agentType: string, + binding: SubagentBinding | undefined, +): Promise<{ readonly configPath: string }> { + const projectRoot = await findProjectRoot(kaos, workDir); + const configPath = getWorkspaceLocalConfigPath(projectRoot); + const file = (await readWorkspaceLocalToml(kaos, configPath)) ?? { raw: {}, parsed: {} }; + + const subagent = cloneRecord(file.raw['subagent']); + if (binding === undefined) { + delete subagent[agentType]; + } else { + const entry: Record = {}; + if (binding.model !== undefined) entry['model'] = binding.model; + if (binding.thinkingEffort !== undefined) entry['thinking_effort'] = binding.thinkingEffort; + if (binding.inherit !== undefined) entry['inherit'] = binding.inherit; + subagent[agentType] = entry; + } + if (Object.keys(subagent).length === 0) { + delete file.raw['subagent']; + } else { + file.raw['subagent'] = subagent; + } + + await kaos.mkdir(dirname(configPath), { parents: true, existOk: true }); + await kaos.writeText(configPath, `${stringifyToml(file.raw)}\n`); + return { configPath }; +} + export function normalizeAdditionalDirs(additionalDirs: readonly string[]): string[] { const seen = new Set(); const normalizedDirs: string[] = []; diff --git a/packages/agent-core/src/flags/registry.ts b/packages/agent-core/src/flags/registry.ts index dbec75b80b..33c95dade1 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: 'subagent-model-selection', + title: 'Subagent model selection', + description: + 'Bind configured model aliases and thinking efforts to subagent types per workspace (.kimi-code/local.toml); bindings are applied mechanically at spawn.', + env: 'KIMI_CODE_EXPERIMENTAL_SUBAGENT_MODEL_SELECTION', + default: false, + surface: 'core', + }, ] as const satisfies readonly FlagDefinitionInput[]; /** Literal union of registered flag ids. */ diff --git a/packages/agent-core/src/profile/resolve.ts b/packages/agent-core/src/profile/resolve.ts index e73b7b4fcf..338691edfd 100644 --- a/packages/agent-core/src/profile/resolve.ts +++ b/packages/agent-core/src/profile/resolve.ts @@ -46,7 +46,7 @@ export function resolveAgentProfiles( resolvedCache.set(profile.name, toResolvedProfile(merged)); } - applySubagentDescriptions(mergedCache, resolvedCache); + applySubagentOverrides(mergedCache, resolvedCache); linkResolvedSubagents(mergedCache, resolvedCache); const result: Record = {}; @@ -166,7 +166,7 @@ function buildTemplateVars( }; } -function applySubagentDescriptions( +function applySubagentOverrides( mergedProfiles: Map, resolvedProfiles: Map, ): void { @@ -180,6 +180,12 @@ function applySubagentDescriptions( if (target.description === undefined && subagent.description !== undefined) { target.description = subagent.description; } + if (target.modelAlias === undefined && subagent.modelAlias !== undefined) { + target.modelAlias = subagent.modelAlias; + } + if (target.thinkingEffort === undefined && subagent.thinkingEffort !== undefined) { + target.thinkingEffort = subagent.thinkingEffort; + } } } } diff --git a/packages/agent-core/src/profile/types.ts b/packages/agent-core/src/profile/types.ts index 27d407c3b3..3c41e38f9c 100644 --- a/packages/agent-core/src/profile/types.ts +++ b/packages/agent-core/src/profile/types.ts @@ -5,6 +5,8 @@ import type { SkillRegistry } from '../agent/skill/types'; export const RawSubagentProfileSchema = z.object({ description: z.string().optional(), + modelAlias: z.string().optional(), + thinkingEffort: z.string().optional(), }); export type RawSubagentProfile = z.infer; @@ -52,5 +54,11 @@ export interface ResolvedAgentProfile { systemPrompt: SystemPromptRenderer; tools: string[]; whenToUse?: string; + /** + * Optional model/thinking overrides bound at the owner's `subagents` entry. + * `undefined` means the subagent inherits the parent agent's settings. + */ + modelAlias?: string; + thinkingEffort?: string; subagents?: Record; } diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index faa44f538f..d014b093ed 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -15,7 +15,7 @@ import type { PermissionData, PermissionMode } from '#/agent/permission'; import type { PlanData } from '#/agent/plan'; import type { SwarmModeTrigger } from '#/agent/swarm'; import type { ToolInfo } from '#/agent/tool'; -import type { KimiConfig, KimiConfigPatch, McpServerConfig } from '#/config'; +import type { KimiConfig, KimiConfigPatch, McpServerConfig, SubagentBinding } from '#/config'; import type { ExperimentalFeatureState } from '#/flags'; import type { ResumeSessionResult } from '#/rpc/resumed'; import type { SessionMeta } from '#/session'; @@ -408,6 +408,18 @@ export interface AddAdditionalDirResult { readonly persisted: boolean; } +export type GetSubagentBindingsResult = Readonly>; + +export interface SetSubagentBindingPayload { + readonly agentType: string; + /** Omit/undefined to clear the binding for this subagent type. */ + readonly binding?: SubagentBinding; +} + +export interface SetSubagentBindingResult { + readonly configPath: string; +} + export interface RenameSessionPayload { readonly title: string; } @@ -515,6 +527,8 @@ export interface SessionAPI extends AgentAPIWithId { waitForBackgroundTasksOnPrint: (payload: EmptyPayload) => void; handlePrintMainTurnCompleted: (payload: EmptyPayload) => 'finish' | 'continue'; addAdditionalDir: (payload: AddAdditionalDirPayload) => AddAdditionalDirResult; + getSubagentBindings: (payload: EmptyPayload) => GetSubagentBindingsResult; + setSubagentBinding: (payload: SetSubagentBindingPayload) => SetSubagentBindingResult; } type SessionAPIWithId = WithSessionId; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 06e97a5e44..27316a747b 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -109,6 +109,7 @@ import type { GetCronTasksResult, GetKimiConfigPayload, GetPluginInfoPayload, + GetSubagentBindingsResult, InstallPluginPayload, ImportContextPayload, ListSessionsPayload, @@ -135,6 +136,8 @@ import type { SetModelResult, SetPermissionPayload, SetPluginEnabledPayload, + SetSubagentBindingPayload, + SetSubagentBindingResult, SetPluginMcpServerEnabledPayload, SetThinkingPayload, SkillSummary, @@ -1052,6 +1055,19 @@ export class KimiCore implements PromisableMethods { return this.requireSession(sessionId).addAdditionalDir(payload.path, payload.persist); } + getSubagentBindings({ + sessionId, + }: SessionScopedPayload): Promise { + return this.requireSession(sessionId).getSubagentBindings(); + } + + setSubagentBinding({ + sessionId, + ...payload + }: SessionScopedPayload): Promise { + return this.requireSession(sessionId).setSubagentBinding(payload.agentType, payload.binding); + } + startBtw({ sessionId, ...payload }: SessionAgentPayload): Promise { return this.sessionApi(sessionId).startBtw(payload); } diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 92f9cd291e..762518b813 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -19,10 +19,13 @@ import { parseBooleanEnv, PRINT_MAX_TURNS_DEFAULT, PRINT_WAIT_CEILING_S_DEFAULT, + readSubagentBindings, readWorkspaceAdditionalDirs, resolveWorkspaceAdditionalDirs, resolveConfigValue, type BackgroundConfig, + type SubagentBinding, + writeSubagentBinding, type WorkspaceAdditionalDirsLoadResult, } from '../config'; import { makeErrorPayload } from '../errors'; @@ -326,6 +329,29 @@ export class Session { this.requireMainAgent().context.appendLocalCommandStdout(message); } + /** Per-workspace subagent model bindings from `.kimi-code/local.toml`. */ + async getSubagentBindings(): Promise>> { + const cwd = this.toolKaos.getcwd(); + return readSubagentBindings(this.systemContextKaos(cwd), cwd); + } + + /** + * Set or clear (binding `undefined`) one subagent type's model binding. + * A bound model is validated against the configured providers first. + */ + async setSubagentBinding( + agentType: string, + binding: SubagentBinding | undefined, + ): Promise<{ readonly configPath: string }> { + const cwd = this.toolKaos.getcwd(); + const systemKaos = this.systemContextKaos(cwd); + if (binding?.model !== undefined) { + const main = await this.ensureAgentResumed('main'); + main.modelProvider?.resolveProviderConfig(binding.model); + } + return writeSubagentBinding(systemKaos, cwd, agentType, binding); + } + /** * Kaos used by session-internal bootstrap (AGENTS.md context, cwd listing) * and metadata persistence. Always backed by the persistence sink (typically diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts index a2e6f0c1e9..901ca53e05 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -16,6 +16,7 @@ import type { EnterSwarmPayload, GetBackgroundOutputPayload, GetBackgroundPayload, + GetSubagentBindingsResult, ImportContextPayload, McpServerInfo, McpStartupMetrics, @@ -28,6 +29,8 @@ import type { SetActiveToolsPayload, SetModelPayload, SetPermissionPayload, + SetSubagentBindingPayload, + SetSubagentBindingResult, SetThinkingPayload, SkillSummary, PluginCommandDef, @@ -120,6 +123,14 @@ export class SessionAPIImpl implements PromisableMethods { return this.session.addAdditionalDir(payload.path, payload.persist); } + getSubagentBindings(): Promise { + return this.session.getSubagentBindings(); + } + + setSubagentBinding(payload: SetSubagentBindingPayload): Promise { + return this.session.setSubagentBinding(payload.agentType, payload.binding); + } + async prompt({ agentId, ...payload }: AgentScopedPayload) { if (agentId === 'main') { await this.updatePromptMetadata(promptMetadataTextFromPayload(payload)); diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index bb80a18209..8369ac076f 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -6,7 +6,7 @@ import { import type { Agent } from '../agent'; import type { PromptOrigin } from '../agent/context'; -import { ErrorCodes } from '../errors'; +import { ErrorCodes, KimiError } from '../errors'; import { DenyAllPermissionPolicy } from '../agent/permission/policies/deny-all'; import { InMemoryAgentRecordPersistence } from '../agent/records'; import { isAbortError } from '../loop/errors'; @@ -32,6 +32,8 @@ import SUMMARY_CONTINUATION_PROMPT from './summary-continuation.md?raw'; export const DEFAULT_SUBAGENT_TIMEOUT_MS = 2 * 60 * 60 * 1000; export const DEFAULT_SUBAGENT_TIMEOUT_DESCRIPTION = '2 hours'; +export const SUBAGENT_MODEL_UNAVAILABLE_MESSAGE = + 'The configured subagent model alias is not resolvable. Check the bindings in .kimi-code/local.toml and your models config.'; const SUBAGENT_TIMEOUT_ENV = 'KIMI_SUBAGENT_TIMEOUT_MS'; @@ -111,6 +113,14 @@ export interface RunSubagentOptions { readonly parentToolCallUuid?: string; readonly prompt: string; readonly description: string; + /** + * Spawn-time model override (from workspace binding or profile binding). + * Spawn precedence: this value > profile binding > inherit the parent. + * Resume/retry ignore this: the child always keeps its configured model. + */ + readonly modelAlias?: string; + /** Spawn-time thinking-effort override; same semantics as `modelAlias`. */ + readonly thinkingEffort?: string; readonly swarmIndex?: number; readonly runInBackground: boolean; readonly signal: AbortSignal; @@ -132,6 +142,10 @@ export type SubagentHandle = { readonly agentId: string; readonly profileName: string; readonly resumed: boolean; + /** Effective model alias for this run (after override resolution). */ + readonly modelAlias?: string; + /** Effective thinking effort for this run (after override resolution). */ + readonly thinkingEffort?: string; readonly completion: Promise; }; @@ -154,14 +168,22 @@ export class SessionSubagentHost { const parent = await this.session.ensureAgentResumed(this.ownerAgentId); const profile = this.resolveProfile(parent, options.profileName); + // Model/effort precedence: per-run override (workspace binding) > profile + // binding > inherit the parent agent. + const modelAlias = this.resolveChildModel(parent, options.modelAlias ?? profile.modelAlias); + const thinkingEffort = + options.thinkingEffort ?? profile.thinkingEffort ?? parent.config.thinkingEffort; const { id, agent } = await this.session.createAgent( { type: 'sub', generate: parent.rawGenerate }, { parentAgentId: this.ownerAgentId, swarmItem: options.swarmItem }, ); const completion = this.runWithActiveChild(id, options, async (runOptions) => { - this.emitSubagentSpawned(parent, id, profile.name, runOptions); + this.emitSubagentSpawned(parent, id, profile.name, runOptions, { + modelAlias, + thinkingEffort, + }); try { - await this.configureChild(parent, agent, profile); + await this.configureChild(parent, agent, profile, { modelAlias, thinkingEffort }); return await this.runPromptTurn(parent, id, agent, profile.name, runOptions); } catch (error) { this.emitSubagentFailed(parent, id, runOptions, error); @@ -172,33 +194,47 @@ export class SessionSubagentHost { agentId: id, profileName: profile.name, resumed: false, + modelAlias, + thinkingEffort, completion, }; } async resume(agentId: string, options: RunSubagentOptions): Promise { options.signal.throwIfAborted(); - const { parent, child, profileName } = await this.ensureIdleSubagent(agentId); + const parent = await this.session.ensureAgentResumed(this.ownerAgentId); + const { child, profileName } = await this.ensureIdleSubagent(agentId, parent); + // Sticky semantics: a resumed child always keeps its configured + // model/effort; mid-conversation model switches are not supported. + const modelAlias = this.resolveChildModel(parent, child.config.modelAlias); + const thinkingEffort = child.config.thinkingEffort; const completion = this.runWithActiveChild(agentId, options, async (runOptions) => { - this.emitSubagentSpawned(parent, agentId, profileName, runOptions); + this.emitSubagentSpawned(parent, agentId, profileName, runOptions, { + modelAlias, + thinkingEffort, + }); try { - child.config.update({ modelAlias: parent.config.modelAlias }); + child.config.update({ modelAlias, thinkingEffort }); return await this.runPromptTurn(parent, agentId, child, profileName, runOptions); } catch (error) { this.emitSubagentFailed(parent, agentId, runOptions, error); throw error; } }); - return { agentId, profileName, resumed: true, completion }; + return { agentId, profileName, resumed: true, modelAlias, thinkingEffort, completion }; } async retry(agentId: string, options: RunSubagentOptions): Promise { options.signal.throwIfAborted(); - const { parent, child, profileName } = await this.ensureIdleSubagent(agentId); + const parent = await this.session.ensureAgentResumed(this.ownerAgentId); + const { child, profileName } = await this.ensureIdleSubagent(agentId, parent); + // Sticky semantics, same as resume(). + const modelAlias = this.resolveChildModel(parent, child.config.modelAlias); + const thinkingEffort = child.config.thinkingEffort; const completion = this.runWithActiveChild(agentId, options, async (runOptions) => { try { runOptions.signal.throwIfAborted(); - child.config.update({ modelAlias: parent.config.modelAlias }); + child.config.update({ modelAlias, thinkingEffort }); this.emitSubagentStarted(parent, agentId); const turnId = child.turn.retry('agent-host'); if (turnId === null) { @@ -211,13 +247,13 @@ export class SessionSubagentHost { throw error; } }); - return { agentId, profileName, resumed: true, completion }; + return { agentId, profileName, resumed: true, modelAlias, thinkingEffort, completion }; } private async ensureIdleSubagent( agentId: string, + parent: Agent, ): Promise<{ readonly parent: Agent; readonly child: Agent; readonly profileName: string }> { - const parent = await this.session.ensureAgentResumed(this.ownerAgentId); const metadata = this.session.metadata.agents[agentId]; if (metadata?.type !== 'sub') { throw new Error(`Agent instance "${agentId}" is not a subagent`); @@ -400,12 +436,14 @@ export class SessionSubagentHost { parent: Agent, child: Agent, profile: ResolvedAgentProfile, + overrides: { readonly modelAlias?: string; readonly thinkingEffort?: string }, ): Promise { - // A subagent always inherits the parent agent's model. + // Model/effort are resolved by the caller (per-run override > profile + // binding > parent inheritance). child.config.update({ cwd: parent.config.cwd, - modelAlias: parent.config.modelAlias, - thinkingEffort: parent.config.thinkingEffort, + modelAlias: overrides.modelAlias, + thinkingEffort: overrides.thinkingEffort, }); const context = await prepareSystemPromptContext( @@ -417,6 +455,19 @@ export class SessionSubagentHost { child.tools.inheritUserTools(parent.tools); } + private resolveChildModel(parent: Agent, requestedModelAlias?: string): string { + const modelAlias = requestedModelAlias ?? parent.config.modelAlias; + if (modelAlias === undefined) throw new Error('Caller agent has no model bound'); + try { + parent.modelProvider?.resolveProviderConfig(modelAlias); + } catch (error) { + throw new KimiError(ErrorCodes.CONFIG_INVALID, SUBAGENT_MODEL_UNAVAILABLE_MESSAGE, { + cause: error, + }); + } + return modelAlias; + } + /** * Hold the run open until the child agent's background tasks (background * Bash, nested background agents) settle — the print-mode (`kimi -p`) @@ -504,6 +555,7 @@ export class SessionSubagentHost { childId: string, profileName: string, options: RunSubagentOptions, + effective: { readonly modelAlias?: string; readonly thinkingEffort?: string }, ): void { parent.emitEvent({ type: 'subagent.spawned', @@ -515,6 +567,8 @@ export class SessionSubagentHost { description: options.description, swarmIndex: options.swarmIndex, runInBackground: options.runInBackground, + modelAlias: effective.modelAlias, + thinkingEffort: effective.thinkingEffort, }); parent.telemetry.track('subagent_created', { agent_id: childId, diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.ts b/packages/agent-core/src/tools/builtin/collaboration/agent.ts index e12a8c16df..c290e21488 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.ts @@ -18,6 +18,10 @@ import { z } from 'zod'; import type { BuiltinTool } from '../../../agent/tool'; +import type { + AskSubagentBindingCallback, + ReadSubagentBindingCallback, +} from '../../../agent/tool/subagent-binding'; import type { Logger } from '../../../logging'; import { ToolAccesses } from '../../../loop/tool-access'; import { isAbortError } from '../../../loop/errors'; @@ -116,6 +120,9 @@ export class AgentTool implements BuiltinTool { log?: Logger; allowBackground?: boolean | undefined; subagentTimeoutMs?: number | undefined; + modelSelectionEnabled?: boolean | (() => boolean); + readBinding?: ReadSubagentBindingCallback; + askBinding?: AskSubagentBindingCallback; }, ) { const log = options?.log; @@ -123,6 +130,13 @@ 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 modelSelectionEnabled = options?.modelSelectionEnabled ?? false; + this.isModelSelectionEnabled = + typeof modelSelectionEnabled === 'function' + ? modelSelectionEnabled + : () => modelSelectionEnabled; + this.readBinding = options?.readBinding; + this.askBinding = options?.askBinding; const typeLines = buildSubagentDescriptions(subagents); const baseDescription = `${AGENT_DESCRIPTION_BASE}\n\n${ this.allowBackground ? AGENT_BACKGROUND_DESCRIPTION : AGENT_BACKGROUND_DISABLED_DESCRIPTION @@ -136,20 +150,49 @@ export class AgentTool implements BuiltinTool { private readonly log?: Logger; private readonly allowBackground: boolean; private readonly subagentTimeoutMs?: number; + private readonly isModelSelectionEnabled: () => boolean; + private readonly readBinding?: ReadSubagentBindingCallback; + private readonly askBinding?: AskSubagentBindingCallback; + + /** + * Effective workspace binding for a spawn: read the stored binding; when + * absent, asking is enabled, and an interactive ask callback exists, ask + * the user once and persist the answer. Returns `undefined` for resume, + * when the experiment is disabled, or when no binding applies (plain + * inheritance). + */ + private async resolveSpawnBinding( + profileName: string, + operation: 'spawn' | 'resume', + allowAsk: boolean, + ): Promise<{ readonly modelAlias?: string; readonly thinkingEffort?: string } | undefined> { + if (operation !== 'spawn' || !this.isModelSelectionEnabled()) return undefined; + let binding = await this.readBinding?.(profileName); + if (binding === undefined && allowAsk && this.askBinding !== undefined) { + binding = await this.askBinding(profileName); + } + if (binding === undefined || binding.inherit === true) return undefined; + if (binding.model === undefined && binding.thinkingEffort === undefined) return undefined; + return { modelAlias: binding.model, thinkingEffort: binding.thinkingEffort }; + } async resolveExecution(args: AgentToolInput): Promise { let profileName = args.subagent_type?.length ? args.subagent_type : 'coder'; const resumeAgentId = args.resume?.trim(); + const operation = resumeAgentId !== undefined && resumeAgentId.length > 0 ? 'resume' : 'spawn'; if (resumeAgentId !== undefined && resumeAgentId.length > 0) { profileName = (await this.subagentHost.getProfileName?.(resumeAgentId)) ?? 'subagent'; } + // Read-only binding lookup for the approval label; the interactive + // first-use ask happens later in execute(). + const binding = await this.resolveSpawnBinding(profileName, operation, false); const prefix = args.run_in_background === true ? 'Launching background' : 'Launching'; return { description: `${prefix} ${profileName} agent: ${args.description}`, accesses: ToolAccesses.none(), display: { kind: 'agent_call', - agent_name: profileName, + agent_name: subagentApprovalAgentName(profileName, binding?.modelAlias), prompt: args.prompt, background: args.run_in_background, }, @@ -198,10 +241,15 @@ export class AgentTool implements BuiltinTool { } const operation = resumeAgentId !== undefined && resumeAgentId.length > 0 ? 'resume' : 'spawn'; + // Workspace model binding (experiment-gated): applied mechanically at + // spawn; the first unbound spawn may ask the user once interactively. + const binding = await this.resolveSpawnBinding(requestedProfileName ?? 'coder', operation, true); const runOptions = { parentToolCallId: toolCallId, prompt: args.prompt, description: args.description, + modelAlias: binding?.modelAlias, + thinkingEffort: binding?.thinkingEffort, runInBackground, signal: controller.signal, }; @@ -314,6 +362,13 @@ export class AgentTool implements BuiltinTool { const USER_INTERRUPTED_SUBAGENT_MESSAGE = 'The user manually interrupted this subagent (and any sibling agents launched alongside it). This was a deliberate user action, not a system error, a timeout, or a capacity/concurrency limit. Do not retry automatically or speculate about why it failed — wait for the user\'s next instruction.'; +function handleModelLines(handle: SubagentHandle): string[] { + const lines: string[] = []; + if (handle.modelAlias !== undefined) lines.push(`model: ${handle.modelAlias}`); + if (handle.thinkingEffort !== undefined) lines.push(`thinking_effort: ${handle.thinkingEffort}`); + return lines; +} + function formatBackgroundAgentResult( taskId: string, handle: SubagentHandle, @@ -325,6 +380,7 @@ function formatBackgroundAgentResult( 'status: running', `agent_id: ${handle.agentId}`, `actual_subagent_type: ${handle.profileName}`, + ...handleModelLines(handle), 'automatic_notification: true', '', `description: ${description}`, @@ -340,6 +396,7 @@ function formatForegroundAgentSuccess(handle: SubagentHandle, result: string): s return [ `agent_id: ${handle.agentId}`, `actual_subagent_type: ${handle.profileName}`, + ...handleModelLines(handle), 'status: completed', '', '[summary]', @@ -355,6 +412,7 @@ function formatForegroundAgentFailure( const lines = [ `agent_id: ${handle.agentId}`, `actual_subagent_type: ${handle.profileName}`, + ...handleModelLines(handle), 'status: failed', '', `subagent error: ${message}`, @@ -386,3 +444,17 @@ function buildSubagentDescriptions(subagents: ResolvedAgentProfile['subagents']) }) .join('\n'); } + +// ── Approval-display helpers ───────────────────────────────────────── + +/** Alias characters considered safe to render in the approval UI. */ +const SAFE_ALIAS = /^[A-Za-z0-9_][A-Za-z0-9._+/@:-]*$/; +const MAX_ALIAS_LENGTH = 160; + +function subagentApprovalAgentName(agentName: string, modelAlias?: string): string { + if (modelAlias === undefined) return agentName; + const isSafe = + modelAlias.length > 0 && modelAlias.length <= MAX_ALIAS_LENGTH && SAFE_ALIAS.test(modelAlias); + const modelLabel = isSafe ? `model ${modelAlias}` : 'model inherited (alias hidden)'; + return `${agentName} · ${modelLabel}`; +} diff --git a/packages/agent-core/test/agent/tool.test.ts b/packages/agent-core/test/agent/tool.test.ts index 13ad93cb7d..1c4bd679f6 100644 --- a/packages/agent-core/test/agent/tool.test.ts +++ b/packages/agent-core/test/agent/tool.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -6,11 +6,15 @@ import type { ToolCall } from '@moonshot-ai/kosong'; import { describe, expect, it, vi } from 'vitest'; import { budgetToolResultForModel } from '../../src/agent/turn/tool-result-budget'; +import type { Agent } from '../../src/agent'; +import { createSubagentBindingCallbacks } from '../../src/agent/tool/subagent-binding'; +import { readSubagentBinding } from '../../src/config/workspace-local'; import type { KimiConfig } from '../../src/config'; import { HookEngine } from '../../src/session/hooks'; import { ProviderManager } from '../../src/session/provider-manager'; import type { SessionSubagentHost } from '../../src/session/subagent-host'; import { FLAG_DEFINITIONS, FlagResolver } from '../../src/flags'; +import { testKaos } from '../fixtures/test-kaos'; import { createFakeKaos } from '../tools/fixtures/fake-kaos'; import { createCommandKaos, testAgent } from './harness/agent'; import { executeTool } from '../tools/fixtures/execute-tool'; @@ -525,6 +529,107 @@ describe('Agent tools', () => { }); }); +describe('createSubagentBindingCallbacks', () => { + const TWO_MODELS = { + 'kimi-code/k3': { provider: 'p', model: 'k3', maxContextSize: 1_000_000 }, + 'sub2/glm-5.2-x': { + provider: 'p', + model: 'glm', + maxContextSize: 1_000_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + }, + }; + + function makeProject(): string { + const root = mkdtempSync(join(tmpdir(), 'subagent-binding-')); + mkdirSync(join(root, '.git'), { recursive: true }); + return root; + } + + function fakeAgent(requestQuestion?: (request: unknown) => Promise): Agent { + return { + rpc: requestQuestion === undefined ? {} : { requestQuestion: vi.fn(requestQuestion) }, + kimiConfig: { providers: {}, models: TWO_MODELS }, + } as unknown as Agent; + } + + it('omits askBinding when the agent cannot question the user', () => { + const { askBinding } = createSubagentBindingCallbacks(fakeAgent(), testKaos, makeProject()); + expect(askBinding).toBeUndefined(); + }); + + it('asks once, persists the chosen model and effort, and returns the binding', async () => { + const root = makeProject(); + const answers: Record = {}; + const requestQuestion = vi.fn(async (request: unknown) => { + const questions = (request as { questions: Array<{ question: string }> }).questions; + const question = questions[0]!.question; + const answer = question.includes('no model binding') + ? 'sub2/glm-5.2-x' + : 'high'; + answers[question] = answer; + return { answers: { [question]: answer } }; + }); + const agent = fakeAgent(requestQuestion); + const { askBinding } = createSubagentBindingCallbacks(agent, testKaos, root); + + const binding = await askBinding!('coder'); + + expect(requestQuestion).toHaveBeenCalledTimes(2); + expect(binding).toEqual({ model: 'sub2/glm-5.2-x', thinkingEffort: 'high' }); + await expect(readSubagentBinding(testKaos, root, 'coder')).resolves.toEqual({ + model: 'sub2/glm-5.2-x', + thinkingEffort: 'high', + inherit: undefined, + }); + }); + + it('records an explicit inherit choice without asking for effort', async () => { + const root = makeProject(); + const requestQuestion = vi.fn(async (request: unknown) => { + const questions = (request as { questions: Array<{ question: string }> }).questions; + return { answers: { [questions[0]!.question]: 'Keep inheriting from the main agent' } }; + }); + const { askBinding } = createSubagentBindingCallbacks(fakeAgent(requestQuestion), testKaos, root); + + const binding = await askBinding!('explore'); + + expect(requestQuestion).toHaveBeenCalledTimes(1); + expect(binding).toEqual({ inherit: true }); + await expect(readSubagentBinding(testKaos, root, 'explore')).resolves.toEqual({ + model: undefined, + thinkingEffort: undefined, + inherit: true, + }); + }); + + it('returns undefined and persists nothing when the user dismisses the question', async () => { + const root = makeProject(); + const requestQuestion = vi.fn(async () => null); + const { askBinding } = createSubagentBindingCallbacks(fakeAgent(requestQuestion), testKaos, root); + + const binding = await askBinding!('coder'); + + expect(binding).toBeUndefined(); + await expect(readSubagentBinding(testKaos, root, 'coder')).resolves.toBeUndefined(); + }); + + it('skips the effort question for models without declared efforts', async () => { + const root = makeProject(); + const requestQuestion = vi.fn(async (request: unknown) => { + const questions = (request as { questions: Array<{ question: string }> }).questions; + return { answers: { [questions[0]!.question]: 'kimi-code/k3' } }; + }); + const { askBinding } = createSubagentBindingCallbacks(fakeAgent(requestQuestion), testKaos, root); + + const binding = await askBinding!('coder'); + + expect(requestQuestion).toHaveBeenCalledTimes(1); + expect(binding).toEqual({ model: 'kimi-code/k3', thinkingEffort: undefined }); + }); +}); + function bashCall(): ToolCall { return { type: 'function', diff --git a/packages/agent-core/test/config/workspace-local.test.ts b/packages/agent-core/test/config/workspace-local.test.ts index 202b591364..74f0558372 100644 --- a/packages/agent-core/test/config/workspace-local.test.ts +++ b/packages/agent-core/test/config/workspace-local.test.ts @@ -10,7 +10,10 @@ import { appendWorkspaceAdditionalDir, loadWorkspaceLocalConfig, normalizeAdditionalDirs, + readSubagentBinding, + readSubagentBindings, readWorkspaceAdditionalDirs, + writeSubagentBinding, } from '../../src/config/workspace-local'; const tempDirs: string[] = []; @@ -202,3 +205,81 @@ describe('workspace local config', () => { ).toEqual(['shared', 'nested/dir', 'nested/final']); }); }); + +describe('subagent bindings', () => { + it('returns undefined when no binding exists for the type', async () => { + const root = await makeProject(); + + await expect( + readSubagentBinding(testKaos, join(root, 'packages', 'app'), 'coder'), + ).resolves.toBeUndefined(); + await expect(readSubagentBindings(testKaos, root)).resolves.toEqual({}); + }); + + it('writes and reads back a model/effort binding', async () => { + const root = await makeProject(); + const workDir = join(root, 'packages', 'app'); + + const { configPath } = await writeSubagentBinding(testKaos, workDir, 'coder', { + model: 'kimi-code/kimi-for-coding', + thinkingEffort: 'high', + }); + + expect(configPath).toBe(join(root, '.kimi-code', 'local.toml')); + await expect(readSubagentBinding(testKaos, workDir, 'coder')).resolves.toEqual({ + model: 'kimi-code/kimi-for-coding', + thinkingEffort: 'high', + inherit: undefined, + }); + await expect(readSubagentBinding(testKaos, workDir, 'explore')).resolves.toBeUndefined(); + const text = await readFile(configPath, 'utf-8'); + expect(text).toContain('[subagent.coder]'); + expect(text).toContain('model = "kimi-code/kimi-for-coding"'); + expect(text).toContain('thinking_effort = "high"'); + }); + + it('records an explicit inherit choice', async () => { + const root = await makeProject(); + + await writeSubagentBinding(testKaos, root, 'explore', { inherit: true }); + + await expect(readSubagentBinding(testKaos, root, 'explore')).resolves.toEqual({ + model: undefined, + thinkingEffort: undefined, + inherit: true, + }); + }); + + it('preserves unrelated local.toml content and other type bindings', async () => { + const root = await makeProject(); + const sharedDir = join(root, 'shared'); + await mkdir(sharedDir, { recursive: true }); + const configPath = join(root, '.kimi-code', 'local.toml'); + await mkdir(join(root, '.kimi-code'), { recursive: true }); + await writeFile(configPath, '[workspace]\nadditional_dir = ["shared"]\n', 'utf-8'); + await writeSubagentBinding(testKaos, root, 'explore', { model: 'sub2/glm-5.2-x' }); + + await writeSubagentBinding(testKaos, root, 'coder', { model: 'kimi-code/k3' }); + + const text = await readFile(configPath, 'utf-8'); + expect(text).toContain('[workspace]'); + expect(text).toContain('"shared"'); + expect(text).toContain('[subagent.explore]'); + expect(text).toContain('[subagent.coder]'); + await expect(readSubagentBindings(testKaos, root)).resolves.toEqual({ + explore: { model: 'sub2/glm-5.2-x', thinkingEffort: undefined, inherit: undefined }, + coder: { model: 'kimi-code/k3', thinkingEffort: undefined, inherit: undefined }, + }); + }); + + it('clears a binding and drops the empty subagent table', async () => { + const root = await makeProject(); + await writeSubagentBinding(testKaos, root, 'coder', { model: 'kimi-code/k3' }); + + await writeSubagentBinding(testKaos, root, 'coder', undefined); + + await expect(readSubagentBinding(testKaos, root, 'coder')).resolves.toBeUndefined(); + const text = await readFile(join(root, '.kimi-code', 'local.toml'), 'utf-8'); + expect(text).not.toContain('subagent'); + }); +}); diff --git a/packages/agent-core/test/profile/agent-profile-loader.test.ts b/packages/agent-core/test/profile/agent-profile-loader.test.ts index c7ef6bdb0e..503ff537af 100644 --- a/packages/agent-core/test/profile/agent-profile-loader.test.ts +++ b/packages/agent-core/test/profile/agent-profile-loader.test.ts @@ -145,6 +145,30 @@ tools: ).toThrow(/agent -> coder -> agent/); }); + it('applies subagent model and thinking-effort bindings to the linked profile', () => { + const profiles = resolveAgentProfiles([ + { + name: 'agent', + subagents: { + coder: { + description: 'Coder child subagent', + modelAlias: 'cheap-model', + thinkingEffort: 'high', + }, + explore: { description: 'Plain subagent' }, + }, + }, + { name: 'coder', systemPromptTemplate: 'coder prompt' }, + { name: 'explore', systemPromptTemplate: 'explore prompt' }, + ]); + + expect(profiles['coder']?.modelAlias).toBe('cheap-model'); + expect(profiles['coder']?.thinkingEffort).toBe('high'); + expect(profiles['agent']?.subagents?.['coder']).toBe(profiles['coder']); + expect(profiles['explore']?.modelAlias).toBeUndefined(); + expect(profiles['explore']?.thinkingEffort).toBeUndefined(); + }); + it('fails loudly when an embedded system prompt source is missing', () => { expect(() => loadAgentProfilesFromSources(['profile/default/agent.yaml'], { diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index bcaace75d6..eece76ab2a 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 { collectGitContext } from '../../src/session/git-context'; import { DEFAULT_SUBAGENT_TIMEOUT_MS, SessionSubagentHost, + SUBAGENT_MODEL_UNAVAILABLE_MESSAGE, formatSubagentTimeoutDescription, resolveSubagentTimeoutMs, type QueuedSubagentTask, @@ -1128,16 +1129,26 @@ describe('SessionSubagentHost', () => { expect(userTextMessages(histories[1] ?? [])).toEqual(['Implement the retry-safe change']); }); - it('realigns a resumed subagent to the parent agent current model', async () => { - const parent = testAgent(); + it('keeps a resumed subagent on its configured model (sticky)', async () => { + const twoModelConfig = { + providers: {}, + models: { + 'subagent-model': { + provider: 'test-provider', + model: 'subagent-model', + maxContextSize: 1_000_000, + }, + }, + }; + const parent = testAgent({ initialConfig: twoModelConfig }); parent.configure(); parent.agent.permission.setMode('yolo'); - const child = testAgent(); + const child = testAgent({ initialConfig: twoModelConfig }); child.configure({ tools: ['Read'] }); - // The child was originally spawned with a model that no longer matches the - // parent agent's current model (as if the parent ran setModel afterwards). - child.agent.config.update({ modelAlias: 'stale-model-from-initial-spawn' }); + // The child was originally spawned with a different model than the + // parent's current one. Sticky semantics: resume keeps it. + child.agent.config.update({ modelAlias: 'subagent-model' }); child.agent.useProfile( profile({ name: 'explore', tools: ['Read'], systemPrompt: 'explore prompt' }), ); @@ -1165,10 +1176,99 @@ describe('SessionSubagentHost', () => { }); await handle.completion; - // resume must realign the child to the parent agent's current model rather - // than leave it on the stale model from its initial spawn. - expect(child.agent.config.modelAlias).toBe(parent.agent.config.modelAlias); - expect(child.agent.config.modelAlias).not.toBe('stale-model-from-initial-spawn'); + expect(child.agent.config.modelAlias).toBe('subagent-model'); + expect(child.agent.config.modelAlias).not.toBe(parent.agent.config.modelAlias); + expect(handle.modelAlias).toBe('subagent-model'); + }); + + it('fails to resume a subagent whose configured model is no longer resolvable', async () => { + const parent = testAgent(); + parent.configure(); + parent.agent.permission.setMode('yolo'); + + const child = testAgent(); + child.configure({ tools: ['Read'] }); + // The child was originally spawned with a model that is no longer present + // in the current configuration. + child.agent.config.update({ modelAlias: 'stale-model-from-initial-spawn' }); + child.agent.useProfile( + profile({ name: 'explore', tools: ['Read'], systemPrompt: 'explore prompt' }), + ); + child.agent.context.appendUserMessage([{ type: 'text', text: 'Earlier context' }]); + + const session = fakeSession(parent.agent, child.agent, { + 'agent-0': { + homedir: '/tmp/kimi-session/agents/agent-0', + type: 'sub', + parentAgentId: 'main', + }, + }); + const host = new SessionSubagentHost(session, 'main'); + + await expect( + host.resume('agent-0', { + parentToolCallId: 'call_agent', + prompt: 'Continue from context', + description: 'Continue work', + runInBackground: false, + signal, + }), + ).rejects.toThrow(SUBAGENT_MODEL_UNAVAILABLE_MESSAGE); + }); + + it('passes an explicit model alias and thinking effort to a spawned child', async () => { + const twoModelConfig = { + providers: {}, + models: { + 'subagent-model': { + provider: 'test-provider', + model: 'subagent-model', + maxContextSize: 1_000_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + }, + }, + }; + const parent = testAgent({ initialConfig: twoModelConfig }); + parent.configure(); + parent.newEvents(); + + const child = testAgent({ type: 'sub', initialConfig: twoModelConfig }); + child.configure(); + const summary = + 'Completed the delegated work on the explicitly selected model and effort, returning a detailed implementation and verification summary so the parent can continue without repeating the work. '.repeat( + 2, + ); + child.mockNextResponse({ type: 'text', text: summary }); + const host = new SessionSubagentHost(fakeSession(parent.agent, child.agent), 'main'); + + const handle = await host.spawn({ + profileName: 'coder', + parentToolCallId: 'call_agent', + prompt: 'Implement the fix', + description: 'Fix bug', + runInBackground: false, + signal, + modelAlias: 'subagent-model', + thinkingEffort: 'high', + }); + await handle.completion; + + expect(child.agent.config.modelAlias).toBe('subagent-model'); + expect(child.agent.config.thinkingEffort).toBe('high'); + expect(handle.modelAlias).toBe('subagent-model'); + expect(handle.thinkingEffort).toBe('high'); + expect(parent.allEvents).toContainEqual( + expect.objectContaining({ + type: '[rpc]', + event: 'subagent.spawned', + args: expect.objectContaining({ + subagentId: 'agent-0', + modelAlias: 'subagent-model', + thinkingEffort: 'high', + }), + }), + ); }); }); diff --git a/packages/agent-core/test/tools/agent.test.ts b/packages/agent-core/test/tools/agent.test.ts index ec5109c2ef..541d078f0a 100644 --- a/packages/agent-core/test/tools/agent.test.ts +++ b/packages/agent-core/test/tools/agent.test.ts @@ -895,6 +895,164 @@ describe('AgentTool', () => { vi.useRealTimers(); } }); + + it('applies a workspace binding mechanically on spawn when enabled', async () => { + const host = mockSubagentHost({ + spawn: vi.fn().mockResolvedValue({ + agentId: 'agent-child', + profileName: 'explore', + resumed: false, + modelAlias: 'kimi-code/kimi-for-coding', + thinkingEffort: 'high', + completion: Promise.resolve({ result: 'child result' }), + }), + }); + const readBinding = vi.fn(async () => ({ + model: 'kimi-code/kimi-for-coding', + thinkingEffort: 'high', + })); + const tool = agentTool(host, createBackgroundManager().manager, undefined, { + modelSelectionEnabled: true, + readBinding, + }); + + const result = await executeTool( + tool, + context({ prompt: 'Investigate', description: 'Find cause', subagent_type: 'explore' }), + ); + + expect(readBinding).toHaveBeenCalledWith('explore'); + expect(host.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + profileName: 'explore', + modelAlias: 'kimi-code/kimi-for-coding', + thinkingEffort: 'high', + }), + ); + expect(result.output).toContain('model: kimi-code/kimi-for-coding'); + expect(result.output).toContain('thinking_effort: high'); + }); + + it('ignores bindings when the experiment is disabled', async () => { + const host = mockSubagentHost({ + spawn: vi.fn().mockResolvedValue({ + agentId: 'agent-child', + profileName: 'coder', + resumed: false, + completion: Promise.resolve({ result: 'child result' }), + }), + }); + const readBinding = vi.fn(async () => ({ model: 'kimi-code/kimi-for-coding' })); + const tool = agentTool(host, createBackgroundManager().manager, undefined, { + modelSelectionEnabled: false, + readBinding, + }); + + await executeTool(tool, context({ prompt: 'Investigate', description: 'Find cause' })); + + expect(readBinding).not.toHaveBeenCalled(); + expect(host.spawn).toHaveBeenCalledWith( + expect.objectContaining({ modelAlias: undefined, thinkingEffort: undefined }), + ); + }); + + it('asks once on the first unbound spawn and applies the persisted binding', async () => { + const host = mockSubagentHost({ + spawn: vi.fn().mockResolvedValue({ + agentId: 'agent-child', + profileName: 'coder', + resumed: false, + modelAlias: 'sub2/glm-5.2-x', + completion: Promise.resolve({ result: 'child result' }), + }), + }); + const readBinding = vi.fn(async () => undefined); + const askBinding = vi.fn(async () => ({ model: 'sub2/glm-5.2-x' })); + const tool = agentTool(host, createBackgroundManager().manager, undefined, { + modelSelectionEnabled: true, + readBinding, + askBinding, + }); + + await executeTool(tool, context({ prompt: 'Investigate', description: 'Find cause' })); + + expect(askBinding).toHaveBeenCalledWith('coder'); + expect(host.spawn).toHaveBeenCalledWith( + expect.objectContaining({ modelAlias: 'sub2/glm-5.2-x' }), + ); + }); + + it('inherits plainly when the binding is an explicit inherit choice', async () => { + const host = mockSubagentHost({ + spawn: vi.fn().mockResolvedValue({ + agentId: 'agent-child', + profileName: 'coder', + resumed: false, + completion: Promise.resolve({ result: 'child result' }), + }), + }); + const askBinding = vi.fn(); + const tool = agentTool(host, createBackgroundManager().manager, undefined, { + modelSelectionEnabled: true, + readBinding: async () => ({ inherit: true }), + askBinding, + }); + + await executeTool(tool, context({ prompt: 'Investigate', description: 'Find cause' })); + + expect(askBinding).not.toHaveBeenCalled(); + expect(host.spawn).toHaveBeenCalledWith( + expect.objectContaining({ modelAlias: undefined, thinkingEffort: undefined }), + ); + }); + + it('shows the bound model in the approval label', async () => { + const host = mockSubagentHost({ spawn: vi.fn() }); + const tool = agentTool(host, createBackgroundManager().manager, undefined, { + modelSelectionEnabled: true, + readBinding: async () => ({ model: 'kimi-code/kimi-for-coding' }), + }); + + const execution = await tool.resolveExecution({ + prompt: 'Investigate', + description: 'Find cause', + subagent_type: 'explore', + }); + if (execution.isError === true) throw new Error('expected runnable execution'); + + expect(execution.display).toMatchObject({ + kind: 'agent_call', + agent_name: 'explore · model kimi-code/kimi-for-coding', + }); + }); + + it('does not read bindings on resume', async () => { + const host = mockSubagentHost({ + spawn: vi.fn(), + resume: vi.fn().mockResolvedValue({ + agentId: 'agent-existing', + profileName: 'explore', + resumed: true, + completion: Promise.resolve({ result: 'child result' }), + }), + }); + const readBinding = vi.fn(async () => ({ model: 'kimi-code/kimi-for-coding' })); + const tool = agentTool(host, createBackgroundManager().manager, undefined, { + modelSelectionEnabled: true, + readBinding, + }); + + await executeTool( + tool, + context({ prompt: 'Continue', description: 'Continue work', resume: 'agent-existing' }), + ); + + expect(readBinding).not.toHaveBeenCalled(); + expect(host.resume).toHaveBeenCalledWith( + 'agent-existing', + expect.objectContaining({ modelAlias: undefined, thinkingEffort: undefined }), + ); + }); }); function profile(input: { diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 1315812c0b..02386b29cf 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -33,6 +33,7 @@ import type { CreateGoalInput, ForkSessionInput, GetConfigOptions, + GetSubagentBindingsResult, McpServerConfig, GoalSnapshot, GoalToolResult, @@ -56,6 +57,8 @@ import type { ResumeSessionInput, ResumedSessionSummary, SessionSummary, + SetSubagentBindingInput, + SetSubagentBindingResult, SkillSummary, PluginCommandDef, Unsubscribe, @@ -377,6 +380,20 @@ export abstract class SDKRpcClientBase { return rpc.addAdditionalDir({ sessionId: input.id, path: input.path, persist: input.persist }); } + async getSubagentBindings(input: SessionIdRpcInput): Promise { + const rpc = await this.getRpc(); + return rpc.getSubagentBindings({ sessionId: input.sessionId }); + } + + async setSubagentBinding(input: SetSubagentBindingInput): Promise { + const rpc = await this.getRpc(); + return rpc.setSubagentBinding({ + sessionId: input.id, + agentType: input.agentType, + binding: input.binding, + }); + } + async startBtw(input: SessionIdRpcInput): Promise { const agentId = this.interactiveAgentId; const rpc = await this.getRpc(); diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 571c2355ff..0a207b2579 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -15,6 +15,7 @@ import type { CompactOptions, CreateGoalInput, GetCronTasksResult, + GetSubagentBindingsResult, GoalSnapshot, GoalToolResult, JsonObject, @@ -32,8 +33,10 @@ import type { SessionStatus, SessionSummary, SessionUsage, + SetSubagentBindingResult, SkillSummary, PluginCommandDef, + SubagentBinding, ThinkingEffort, Unsubscribe, } from '#/types'; @@ -182,6 +185,24 @@ export class Session { return this.rpc.startBtw({ sessionId: this.id }); } + async getSubagentBindings(): Promise { + this.ensureOpen(); + return this.rpc.getSubagentBindings({ sessionId: this.id }); + } + + async setSubagentBinding( + agentType: string, + binding?: SubagentBinding, + ): Promise { + this.ensureOpen(); + const normalized = normalizeRequiredString( + agentType, + 'Subagent type cannot be empty', + ErrorCodes.REQUEST_INVALID, + ); + return this.rpc.setSubagentBinding({ id: this.id, agentType: normalized, binding }); + } + async cancel(): Promise { this.ensureOpen(); await this.rpc.cancel({ sessionId: this.id }); diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 0968d96a18..2068252922 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -155,6 +155,26 @@ export interface AddAdditionalDirOptions { readonly persist: boolean; } +/** Per-workspace model/effort binding for one subagent type. */ +export interface SubagentBinding { + readonly model?: string; + readonly thinkingEffort?: string; + readonly inherit?: boolean; +} + +export type GetSubagentBindingsResult = Readonly>; + +export interface SetSubagentBindingInput { + readonly id: string; + readonly agentType: string; + /** Omit to clear the binding for this subagent type. */ + readonly binding?: SubagentBinding; +} + +export interface SetSubagentBindingResult { + readonly configPath: string; +} + export interface ForkSessionInput { readonly id: string; readonly forkId?: string; diff --git a/packages/node-sdk/test/session-context.test.ts b/packages/node-sdk/test/session-context.test.ts index 503c416de0..6ff34e378f 100644 --- a/packages/node-sdk/test/session-context.test.ts +++ b/packages/node-sdk/test/session-context.test.ts @@ -26,6 +26,32 @@ afterEach(async () => { }); describe('Session context', () => { + it('round-trips subagent model bindings through the public Session API', async () => { + const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-subagent-home-'); + const workDir = await makeTempDir(tempDirs, 'kimi-sdk-subagent-work-'); + await writeTestConfig(homeDir, 200_000); + const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); + + try { + const session = await harness.createSession({ id: 'ses_subagent_binding', workDir }); + await expect(session.getSubagentBindings()).resolves.toEqual({}); + + const { configPath } = await session.setSubagentBinding('coder', { + model: 'test-model', + thinkingEffort: 'high', + }); + expect(configPath).toContain('local.toml'); + await expect(session.getSubagentBindings()).resolves.toEqual({ + coder: { model: 'test-model', thinkingEffort: 'high', inherit: undefined }, + }); + + await session.setSubagentBinding('coder', undefined); + await expect(session.getSubagentBindings()).resolves.toEqual({}); + } finally { + await harness.close(); + } + }); + it('restores a session-only additional directory after close and resume', async () => { const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-additional-home-'); const workDir = await makeTempDir(tempDirs, 'kimi-sdk-additional-work-'); diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 6f9178d145..84b34f727b 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -771,6 +771,10 @@ export interface SubagentSpawnedEvent { readonly description?: string; readonly swarmIndex?: number; readonly runInBackground: boolean; + /** Effective model alias the subagent will run with (after override resolution). */ + readonly modelAlias?: string; + /** Effective thinking effort the subagent will run with (after override resolution). */ + readonly thinkingEffort?: string; } export interface SubagentStartedEvent { @@ -1632,6 +1636,8 @@ export const subagentSpawnedEventSchema = z.object({ description: z.string().optional(), swarmIndex: z.number().optional(), runInBackground: z.boolean(), + modelAlias: z.string().optional(), + thinkingEffort: z.string().optional(), }) satisfies z.ZodType; export const subagentStartedEventSchema = z.object({ From c34b6000ddbe0fef1245879be278d8fac9f688ae Mon Sep 17 00:00:00 2001 From: yorha9e Date: Mon, 20 Jul 2026 01:32:41 +0800 Subject: [PATCH 2/8] fix(agent-core): gate sticky resume and profile bindings behind the experiment Address review feedback on #1928: - resume/retry only keep the child's configured model when subagent-model-selection is enabled; with the experiment off, resume realigns to the parent's current model exactly as before - spawn-time profile modelAlias/thinkingEffort bindings are likewise ignored unless the experiment is enabled - docs: workspace bindings apply to Agent tool spawns only; AgentSwarm does not read bindings yet (swarm-wide routing is future work) - note the shared-target limitation of profile override resolution --- .changeset/subagent-model-binding.md | 2 +- docs/en/customization/agents.md | 2 +- docs/zh/customization/agents.md | 2 +- packages/agent-core/src/profile/resolve.ts | 5 ++ .../agent-core/src/session/subagent-host.ts | 31 +++++++--- .../test/session/subagent-host.test.ts | 61 +++++++++++++++++-- 6 files changed, 89 insertions(+), 14 deletions(-) diff --git a/.changeset/subagent-model-binding.md b/.changeset/subagent-model-binding.md index 5422e94d9a..fb4f5d24cf 100644 --- a/.changeset/subagent-model-binding.md +++ b/.changeset/subagent-model-binding.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -Add experimental per-workspace model and thinking-effort bindings for subagent types. Enable the `subagent-model-selection` experiment to bind configured model aliases to subagent types in `.kimi-code/local.toml`; bindings are applied mechanically at spawn (the calling agent cannot override them), are managed via the `/subagent-model` command, and resumed subagents always keep the model they were configured with. +Add experimental per-workspace model and thinking-effort bindings for subagent types. Enable the `subagent-model-selection` experiment to bind configured model aliases to subagent types in `.kimi-code/local.toml`; bindings are applied mechanically to `Agent` tool spawns (the calling agent cannot override them; AgentSwarm does not read bindings yet), are managed via the `/subagent-model` command, and — with the experiment on — resumed subagents always keep the model they were configured with instead of realigning to the parent. diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md index bcfcf57007..274b5f6584 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -43,7 +43,7 @@ model = "kimi-code/kimi-for-coding" thinking_effort = "high" ``` -The first time an unbound sub-agent type is spawned in a workspace, you are asked once whether to bind a model (answering "keep inheriting" is remembered too). Afterwards the binding is applied mechanically to every new sub-agent of that type — the calling Agent cannot see or override it. Manage bindings anytime with the `/subagent-model` command (`list` / `set ` / `clear `). Precedence: workspace binding > profile binding (for profiles shipped with the app) > inherit the calling Agent's current model and effort. +The first time an unbound sub-agent type is spawned in a workspace, you are asked once whether to bind a model (answering "keep inheriting" is remembered too). Afterwards the binding is applied mechanically to every new sub-agent of that type spawned through the `Agent` tool — the calling Agent cannot see or override it. (`AgentSwarm` batches do not read bindings yet; swarm-wide model routing is future work.) Manage bindings anytime with the `/subagent-model` command (`list` / `set ` / `clear `). Precedence: workspace binding > profile binding (for profiles shipped with the app) > inherit the calling Agent's current model and effort. Bound values are fixed at spawn: resuming a sub-agent always keeps the model and effort it was configured with, mid-conversation switches are not possible, and both are restored after a session restart. A bound alias is validated against your models configuration — if it no longer resolves (for example after being removed from `config.toml`), spawning or resuming fails with a configuration error until you update or clear the binding. diff --git a/docs/zh/customization/agents.md b/docs/zh/customization/agents.md index 628efacbca..71d7465c16 100644 --- a/docs/zh/customization/agents.md +++ b/docs/zh/customization/agents.md @@ -43,7 +43,7 @@ model = "kimi-code/kimi-for-coding" thinking_effort = "high" ``` -某个子 Agent 类型在工作区首次被派发且没有绑定时,会询问你一次是否绑定模型(选择"保持继承"也会被记住)。之后该绑定会机械地应用到该类型的每个新子 Agent 上——主 Agent 看不到也无法覆盖它。随时可以用 `/subagent-model` 命令管理绑定(`list` / `set <类型>` / `clear <类型>`)。优先级:工作区绑定 > profile 绑定(随应用分发的 profile)> 继承主 Agent 当前的模型与思考强度。 +某个子 Agent 类型在工作区首次被派发且没有绑定时,会询问你一次是否绑定模型(选择"保持继承"也会被记住)。之后该绑定会机械地应用到通过 `Agent` 工具派发的该类型每个新子 Agent 上——主 Agent 看不到也无法覆盖它。(`AgentSwarm` 批量派发暂不读取绑定,swarm 的模型指定方案留作后续。)随时可以用 `/subagent-model` 命令管理绑定(`list` / `set <类型>` / `clear <类型>`)。优先级:工作区绑定 > profile 绑定(随应用分发的 profile)> 继承主 Agent 当前的模型与思考强度。 绑定值在派发时固化:唤回(resume)子 Agent 总是保持它已配置的模型与思考强度,无法在对话中途切换,会话重启后也会恢复。绑定的别名会对照你的模型配置校验——如果它不再可解析(比如已从 `config.toml` 删除),派发或唤回会以配置错误失败,直到你更新或清除该绑定。 diff --git a/packages/agent-core/src/profile/resolve.ts b/packages/agent-core/src/profile/resolve.ts index 338691edfd..d602593939 100644 --- a/packages/agent-core/src/profile/resolve.ts +++ b/packages/agent-core/src/profile/resolve.ts @@ -170,6 +170,11 @@ function applySubagentOverrides( mergedProfiles: Map, resolvedProfiles: Map, ): void { + // Note: overrides are applied to the shared resolved target profile, the + // same mechanism `description` already uses. With a single bundled owner + // profile this is safe; if multiple owners ever bind the same subagent type + // differently, the first owner processed wins for all of them. Move + // overrides onto the owner→subagent edge if that becomes a real config. for (const [ownerName, owner] of mergedProfiles) { if (owner.subagents === undefined) continue; for (const [subagentName, subagent] of Object.entries(owner.subagents)) { diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 8369ac076f..cd632a5bc8 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -169,10 +169,17 @@ export class SessionSubagentHost { const parent = await this.session.ensureAgentResumed(this.ownerAgentId); const profile = this.resolveProfile(parent, options.profileName); // Model/effort precedence: per-run override (workspace binding) > profile - // binding > inherit the parent agent. - const modelAlias = this.resolveChildModel(parent, options.modelAlias ?? profile.modelAlias); + // binding > inherit the parent agent. Profile bindings are part of the + // subagent-model-selection experiment and are ignored when it is off. + const modelSelectionEnabled = parent.experimentalFlags.enabled('subagent-model-selection'); + const modelAlias = this.resolveChildModel( + parent, + options.modelAlias ?? (modelSelectionEnabled ? profile.modelAlias : undefined), + ); const thinkingEffort = - options.thinkingEffort ?? profile.thinkingEffort ?? parent.config.thinkingEffort; + options.thinkingEffort ?? + (modelSelectionEnabled ? profile.thinkingEffort : undefined) ?? + parent.config.thinkingEffort; const { id, agent } = await this.session.createAgent( { type: 'sub', generate: parent.rawGenerate }, { parentAgentId: this.ownerAgentId, swarmItem: options.swarmItem }, @@ -204,9 +211,16 @@ export class SessionSubagentHost { options.signal.throwIfAborted(); const parent = await this.session.ensureAgentResumed(this.ownerAgentId); const { child, profileName } = await this.ensureIdleSubagent(agentId, parent); - // Sticky semantics: a resumed child always keeps its configured - // model/effort; mid-conversation model switches are not supported. - const modelAlias = this.resolveChildModel(parent, child.config.modelAlias); + // Sticky resume is part of the subagent-model-selection experiment: with + // the flag on, a resumed child always keeps its configured model/effort + // (no mid-conversation switches); with it off, resume realigns the child + // to the parent's current model exactly as before. + const sticky = parent.experimentalFlags.enabled('subagent-model-selection'); + const modelAlias = sticky + ? this.resolveChildModel(parent, child.config.modelAlias) + : parent.config.modelAlias; + // Effort is never touched on resume (same as the pre-change behavior); + // the child keeps whatever it was configured with at spawn. const thinkingEffort = child.config.thinkingEffort; const completion = this.runWithActiveChild(agentId, options, async (runOptions) => { this.emitSubagentSpawned(parent, agentId, profileName, runOptions, { @@ -229,7 +243,10 @@ export class SessionSubagentHost { const parent = await this.session.ensureAgentResumed(this.ownerAgentId); const { child, profileName } = await this.ensureIdleSubagent(agentId, parent); // Sticky semantics, same as resume(). - const modelAlias = this.resolveChildModel(parent, child.config.modelAlias); + const sticky = parent.experimentalFlags.enabled('subagent-model-selection'); + const modelAlias = sticky + ? this.resolveChildModel(parent, child.config.modelAlias) + : parent.config.modelAlias; const thinkingEffort = child.config.thinkingEffort; const completion = this.runWithActiveChild(agentId, options, async (runOptions) => { try { diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index eece76ab2a..f51b50344a 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -8,6 +8,7 @@ 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 { FLAG_DEFINITIONS, FlagResolver } from '../../src/flags'; import type { ResolvedAgentProfile } from '../../src/profile'; import type { SDKSessionRPC } from '../../src/rpc'; import { Session } from '../../src/session'; @@ -1129,7 +1130,7 @@ describe('SessionSubagentHost', () => { expect(userTextMessages(histories[1] ?? [])).toEqual(['Implement the retry-safe change']); }); - it('keeps a resumed subagent on its configured model (sticky)', async () => { + it('keeps a resumed subagent on its configured model (sticky, experiment on)', async () => { const twoModelConfig = { providers: {}, models: { @@ -1140,7 +1141,12 @@ describe('SessionSubagentHost', () => { }, }, }; - const parent = testAgent({ initialConfig: twoModelConfig }); + const parent = testAgent({ + initialConfig: twoModelConfig, + experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS, { + 'subagent-model-selection': true, + }), + }); parent.configure(); parent.agent.permission.setMode('yolo'); @@ -1181,8 +1187,12 @@ describe('SessionSubagentHost', () => { expect(handle.modelAlias).toBe('subagent-model'); }); - it('fails to resume a subagent whose configured model is no longer resolvable', async () => { - const parent = testAgent(); + it('fails to resume a subagent whose configured model is no longer resolvable (experiment on)', async () => { + const parent = testAgent({ + experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS, { + 'subagent-model-selection': true, + }), + }); parent.configure(); parent.agent.permission.setMode('yolo'); @@ -1216,6 +1226,49 @@ describe('SessionSubagentHost', () => { ).rejects.toThrow(SUBAGENT_MODEL_UNAVAILABLE_MESSAGE); }); + it('realigns a resumed subagent to the parent model when the experiment is off', async () => { + const parent = testAgent(); + parent.configure(); + parent.agent.permission.setMode('yolo'); + + const child = testAgent(); + child.configure({ tools: ['Read'] }); + // The child was originally spawned with a model that no longer matches the + // parent agent's current model (as if the parent ran setModel afterwards). + child.agent.config.update({ modelAlias: 'stale-model-from-initial-spawn' }); + child.agent.useProfile( + profile({ name: 'explore', tools: ['Read'], systemPrompt: 'explore prompt' }), + ); + child.agent.context.appendUserMessage([{ type: 'text', text: 'Earlier context' }]); + child.mockNextResponse({ + type: 'text', + text: 'Resumed the subagent from its earlier context and carried the task through to completion, then reported a full and detailed technical summary so the parent agent can continue without repeating prior work.', + }); + + const session = fakeSession(parent.agent, child.agent, { + 'agent-0': { + homedir: '/tmp/kimi-session/agents/agent-0', + type: 'sub', + parentAgentId: 'main', + }, + }); + const 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 off, resume keeps the pre-change realign behavior. + expect(child.agent.config.modelAlias).toBe(parent.agent.config.modelAlias); + expect(child.agent.config.modelAlias).not.toBe('stale-model-from-initial-spawn'); + expect(handle.modelAlias).toBe(parent.agent.config.modelAlias); + }); + it('passes an explicit model alias and thinking effort to a spawned child', async () => { const twoModelConfig = { providers: {}, From a868e4980284886e8cf90ed2473dadafa46bc9ad Mon Sep 17 00:00:00 2001 From: yorha9e Date: Tue, 21 Jul 2026 18:42:05 +0800 Subject: [PATCH 3/8] fix(agent-core): repair or warn on workspace bindings with unknown model aliases When a stored subagent binding references a model alias missing from the user's models config, interactive spawns re-ask with the missing-model reason and persist the repaired binding; non-interactive spawns inherit the main agent model with an explicit warning in the tool result. --- .../src/agent/tool/subagent-binding.ts | 33 +++++++- .../src/tools/builtin/collaboration/agent.ts | 72 ++++++++++++---- packages/agent-core/test/tools/agent.test.ts | 83 +++++++++++++++++++ 3 files changed, 170 insertions(+), 18 deletions(-) diff --git a/packages/agent-core/src/agent/tool/subagent-binding.ts b/packages/agent-core/src/agent/tool/subagent-binding.ts index 4beea9edb5..c50e8c6da3 100644 --- a/packages/agent-core/src/agent/tool/subagent-binding.ts +++ b/packages/agent-core/src/agent/tool/subagent-binding.ts @@ -23,10 +23,22 @@ export type ReadSubagentBindingCallback = ( profileName: string, ) => Promise; +export interface AskSubagentBindingContext { + /** + * Set when the stored binding references a model alias that no longer + * exists in the user's models config — the ask explains why it is + * happening again and the new choice repairs the broken binding. + */ + readonly missingModel?: string; +} + export type AskSubagentBindingCallback = ( profileName: string, + context?: AskSubagentBindingContext, ) => Promise; +export type IsModelAliasKnownCallback = (alias: string) => boolean; + const INHERIT_LABEL = 'Keep inheriting from the main agent'; /** @@ -41,17 +53,30 @@ export function createSubagentBindingCallbacks( ): { readBinding: ReadSubagentBindingCallback; askBinding?: AskSubagentBindingCallback; + isModelAliasKnown: IsModelAliasKnownCallback; } { const readBinding: ReadSubagentBindingCallback = (profileName) => readSubagentBinding(kaos, workDir, profileName); + // Without a models config there is nothing to validate against — stay + // silent rather than nagging about every binding. + const isModelAliasKnown: IsModelAliasKnownCallback = (alias) => { + const models = agent.kimiConfig?.models; + if (models === undefined) return true; + return alias in models; + }; + const requestQuestion = agent.rpc?.requestQuestion?.bind(agent.rpc); - if (requestQuestion === undefined) return { readBinding }; + if (requestQuestion === undefined) return { readBinding, isModelAliasKnown }; - const askBinding: AskSubagentBindingCallback = async (profileName) => { + const askBinding: AskSubagentBindingCallback = async (profileName, context) => { const models = agent.kimiConfig?.models ?? {}; const aliases = Object.keys(models).toSorted(); - const modelQuestion = `Subagent type "${profileName}" has no model binding in this workspace. Bind a model for it?`; + const missingModel = context?.missingModel; + const modelQuestion = + missingModel === undefined + ? `Subagent type "${profileName}" has no model binding in this workspace. Bind a model for it?` + : `Subagent type "${profileName}" is bound to model "${missingModel}", but that alias no longer exists in your models config. Bind a model for it?`; const modelResult = await requestQuestion({ questions: [ { @@ -101,7 +126,7 @@ export function createSubagentBindingCallbacks( return binding; }; - return { readBinding, askBinding }; + return { readBinding, askBinding, isModelAliasKnown }; } function answerFor(result: QuestionResult, question: string): string | undefined { diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.ts b/packages/agent-core/src/tools/builtin/collaboration/agent.ts index c290e21488..8ec66e8039 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.ts @@ -20,6 +20,7 @@ import { z } from 'zod'; import type { BuiltinTool } from '../../../agent/tool'; import type { AskSubagentBindingCallback, + IsModelAliasKnownCallback, ReadSubagentBindingCallback, } from '../../../agent/tool/subagent-binding'; import type { Logger } from '../../../logging'; @@ -123,6 +124,7 @@ export class AgentTool implements BuiltinTool { modelSelectionEnabled?: boolean | (() => boolean); readBinding?: ReadSubagentBindingCallback; askBinding?: AskSubagentBindingCallback; + isModelAliasKnown?: IsModelAliasKnownCallback; }, ) { const log = options?.log; @@ -137,6 +139,7 @@ export class AgentTool implements BuiltinTool { : () => modelSelectionEnabled; this.readBinding = options?.readBinding; this.askBinding = options?.askBinding; + this.isModelAliasKnown = options?.isModelAliasKnown; const typeLines = buildSubagentDescriptions(subagents); const baseDescription = `${AGENT_DESCRIPTION_BASE}\n\n${ this.allowBackground ? AGENT_BACKGROUND_DESCRIPTION : AGENT_BACKGROUND_DISABLED_DESCRIPTION @@ -153,27 +156,63 @@ export class AgentTool implements BuiltinTool { private readonly isModelSelectionEnabled: () => boolean; private readonly readBinding?: ReadSubagentBindingCallback; private readonly askBinding?: AskSubagentBindingCallback; + private readonly isModelAliasKnown?: IsModelAliasKnownCallback; /** * Effective workspace binding for a spawn: read the stored binding; when * absent, asking is enabled, and an interactive ask callback exists, ask - * the user once and persist the answer. Returns `undefined` for resume, - * when the experiment is disabled, or when no binding applies (plain - * inheritance). + * the user once and persist the answer. When the stored binding references + * a model alias missing from the user's models config, interactively + * re-ask (repairing the binding) or — where asking is unavailable — fall + * back to plain inheritance with an explicit `warning`. Returns + * `undefined` for resume, when the experiment is disabled, or when no + * binding applies (plain inheritance). */ private async resolveSpawnBinding( profileName: string, operation: 'spawn' | 'resume', allowAsk: boolean, - ): Promise<{ readonly modelAlias?: string; readonly thinkingEffort?: string } | undefined> { + ): Promise< + | { + readonly modelAlias?: string; + readonly thinkingEffort?: string; + readonly warning?: string; + } + | undefined + > { if (operation !== 'spawn' || !this.isModelSelectionEnabled()) return undefined; + let warning: string | undefined; let binding = await this.readBinding?.(profileName); - if (binding === undefined && allowAsk && this.askBinding !== undefined) { - binding = await this.askBinding(profileName); + if (binding === undefined) { + if (allowAsk && this.askBinding !== undefined) { + binding = await this.askBinding(profileName); + } + } else if ( + binding.inherit !== true && + binding.model !== undefined && + this.isModelAliasKnown !== undefined && + !this.isModelAliasKnown(binding.model) + ) { + const missingModel = binding.model; + if (allowAsk && this.askBinding !== undefined) { + // A dismissed re-ask leaves the broken binding in place; the user is + // asked again on the next spawn, matching the unbound-ask semantics. + binding = await this.askBinding(profileName, { missingModel }); + } else { + warning = + `warning: workspace binding for subagent type "${profileName}" references unknown ` + + `model alias "${missingModel}"; inheriting the main agent model. Update it with ` + + `/subagent-model set ${profileName} or in .kimi-code/local.toml.`; + binding = undefined; + } } - if (binding === undefined || binding.inherit === true) return undefined; - if (binding.model === undefined && binding.thinkingEffort === undefined) return undefined; - return { modelAlias: binding.model, thinkingEffort: binding.thinkingEffort }; + if (binding === undefined || binding.inherit === true) { + return warning === undefined ? undefined : { warning }; + } + if (binding.model === undefined && binding.thinkingEffort === undefined) { + return warning === undefined ? undefined : { warning }; + } + return { modelAlias: binding.model, thinkingEffort: binding.thinkingEffort, warning }; } async resolveExecution(args: AgentToolInput): Promise { @@ -244,6 +283,11 @@ export class AgentTool implements BuiltinTool { // Workspace model binding (experiment-gated): applied mechanically at // spawn; the first unbound spawn may ask the user once interactively. const binding = await this.resolveSpawnBinding(requestedProfileName ?? 'coder', operation, true); + const bindingWarning = binding?.warning; + const withWarning = (result: ExecutableToolResult): ExecutableToolResult => + bindingWarning === undefined + ? result + : { ...result, output: `${bindingWarning}\n${result.output}` }; const runOptions = { parentToolCallId: toolCallId, prompt: args.prompt, @@ -303,28 +347,28 @@ export class AgentTool implements BuiltinTool { } if (runInBackground) { - return { + return withWarning({ output: formatBackgroundAgentResult( taskId, handle, args.description, this.allowBackground, ), - }; + }); } const release = await this.backgroundManager.waitForForegroundRelease(taskId); if (release === 'detached') { - return { + return withWarning({ output: formatBackgroundAgentResult( taskId, handle, args.description, this.allowBackground, ), - }; + }); } - return await this.formatForegroundResult(taskId, handle); + return withWarning(await this.formatForegroundResult(taskId, handle)); } catch (error) { return { output: `subagent error: ${launchErrorMessage(error, signal)}`, isError: true }; } diff --git a/packages/agent-core/test/tools/agent.test.ts b/packages/agent-core/test/tools/agent.test.ts index 541d078f0a..de241985b1 100644 --- a/packages/agent-core/test/tools/agent.test.ts +++ b/packages/agent-core/test/tools/agent.test.ts @@ -982,6 +982,89 @@ describe('AgentTool', () => { ); }); + it('re-asks with the missing-model reason when the bound alias is gone', async () => { + const host = mockSubagentHost({ + spawn: vi.fn().mockResolvedValue({ + agentId: 'agent-child', + profileName: 'coder', + resumed: false, + modelAlias: 'sub2/glm-5.2-x', + completion: Promise.resolve({ result: 'child result' }), + }), + }); + const readBinding = vi.fn(async () => ({ model: 'gone/model-x' })); + const askBinding = vi.fn(async () => ({ model: 'sub2/glm-5.2-x' })); + const tool = agentTool(host, createBackgroundManager().manager, undefined, { + modelSelectionEnabled: true, + readBinding, + askBinding, + isModelAliasKnown: (alias) => alias !== 'gone/model-x', + }); + + await executeTool(tool, context({ prompt: 'Investigate', description: 'Find cause' })); + + expect(askBinding).toHaveBeenCalledTimes(1); + expect(askBinding).toHaveBeenCalledWith('coder', { missingModel: 'gone/model-x' }); + expect(host.spawn).toHaveBeenCalledWith( + expect.objectContaining({ modelAlias: 'sub2/glm-5.2-x' }), + ); + }); + + it('inherits with an explicit warning when the bound alias is gone and asking is unavailable', async () => { + const host = mockSubagentHost({ + spawn: vi.fn().mockResolvedValue({ + agentId: 'agent-child', + profileName: 'coder', + resumed: false, + completion: Promise.resolve({ result: 'child result' }), + }), + }); + const readBinding = vi.fn(async () => ({ model: 'gone/model-x' })); + const tool = agentTool(host, createBackgroundManager().manager, undefined, { + modelSelectionEnabled: true, + readBinding, + isModelAliasKnown: () => false, + }); + + const result = await executeTool( + tool, + context({ prompt: 'Investigate', description: 'Find cause' }), + ); + + expect(host.spawn).toHaveBeenCalledWith( + expect.objectContaining({ modelAlias: undefined, thinkingEffort: undefined }), + ); + expect(result.output).toContain('warning:'); + expect(result.output).toContain('gone/model-x'); + expect(result.output).toContain('inheriting the main agent model'); + }); + + it('does not fall through to the plain ask when the missing-model re-ask is dismissed', async () => { + const host = mockSubagentHost({ + spawn: vi.fn().mockResolvedValue({ + agentId: 'agent-child', + profileName: 'coder', + resumed: false, + completion: Promise.resolve({ result: 'child result' }), + }), + }); + const readBinding = vi.fn(async () => ({ model: 'gone/model-x' })); + const askBinding = vi.fn(async () => undefined); + const tool = agentTool(host, createBackgroundManager().manager, undefined, { + modelSelectionEnabled: true, + readBinding, + askBinding, + isModelAliasKnown: () => false, + }); + + await executeTool(tool, context({ prompt: 'Investigate', description: 'Find cause' })); + + expect(askBinding).toHaveBeenCalledTimes(1); + expect(host.spawn).toHaveBeenCalledWith( + expect.objectContaining({ modelAlias: undefined, thinkingEffort: undefined }), + ); + }); + it('inherits plainly when the binding is an explicit inherit choice', async () => { const host = mockSubagentHost({ spawn: vi.fn().mockResolvedValue({ From ffdedf1b53b6ba885c1749a36bdb2c29608b159d Mon Sep 17 00:00:00 2001 From: yorha9e Date: Tue, 21 Jul 2026 18:51:46 +0800 Subject: [PATCH 4/8] docs(agent-core): describe stale workspace binding repair/warn behavior --- docs/en/customization/agents.md | 2 +- docs/zh/customization/agents.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md index 274b5f6584..e3c0ab1521 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -45,7 +45,7 @@ thinking_effort = "high" The first time an unbound sub-agent type is spawned in a workspace, you are asked once whether to bind a model (answering "keep inheriting" is remembered too). Afterwards the binding is applied mechanically to every new sub-agent of that type spawned through the `Agent` tool — the calling Agent cannot see or override it. (`AgentSwarm` batches do not read bindings yet; swarm-wide model routing is future work.) Manage bindings anytime with the `/subagent-model` command (`list` / `set ` / `clear `). Precedence: workspace binding > profile binding (for profiles shipped with the app) > inherit the calling Agent's current model and effort. -Bound values are fixed at spawn: resuming a sub-agent always keeps the model and effort it was configured with, mid-conversation switches are not possible, and both are restored after a session restart. A bound alias is validated against your models configuration — if it no longer resolves (for example after being removed from `config.toml`), spawning or resuming fails with a configuration error until you update or clear the binding. +Bound values are fixed at spawn: resuming a sub-agent always keeps the model and effort it was configured with, mid-conversation switches are not possible, and both are restored after a session restart. Resume validates the sub-agent's frozen alias strictly and fails with a configuration error when it no longer resolves. The workspace binding's alias is likewise validated against your models configuration before each spawn — if it no longer resolves (for example after being removed from `config.toml`), interactive environments re-ask the binding question with the reason stated and persist your new choice as the repair; non-interactive environments (such as `kimi -p`) inherit the main agent's model with an explicit warning in the tool result, until you update or clear the binding. ## Context Isolation and Resource Cost diff --git a/docs/zh/customization/agents.md b/docs/zh/customization/agents.md index 71d7465c16..bd94eff4b9 100644 --- a/docs/zh/customization/agents.md +++ b/docs/zh/customization/agents.md @@ -45,7 +45,7 @@ thinking_effort = "high" 某个子 Agent 类型在工作区首次被派发且没有绑定时,会询问你一次是否绑定模型(选择"保持继承"也会被记住)。之后该绑定会机械地应用到通过 `Agent` 工具派发的该类型每个新子 Agent 上——主 Agent 看不到也无法覆盖它。(`AgentSwarm` 批量派发暂不读取绑定,swarm 的模型指定方案留作后续。)随时可以用 `/subagent-model` 命令管理绑定(`list` / `set <类型>` / `clear <类型>`)。优先级:工作区绑定 > profile 绑定(随应用分发的 profile)> 继承主 Agent 当前的模型与思考强度。 -绑定值在派发时固化:唤回(resume)子 Agent 总是保持它已配置的模型与思考强度,无法在对话中途切换,会话重启后也会恢复。绑定的别名会对照你的模型配置校验——如果它不再可解析(比如已从 `config.toml` 删除),派发或唤回会以配置错误失败,直到你更新或清除该绑定。 +绑定值在派发时固化:唤回(resume)子 Agent 总是保持它已配置的模型与思考强度,无法在对话中途切换,会话重启后也会恢复。唤回时对子 Agent 已固化的别名做严格校验,不可解析会以配置错误失败。而工作区绑定里的别名在派发前同样会对照你的模型配置校验——如果它不再可解析(比如已从 `config.toml` 删除),交互环境下会重新弹出绑定询问并注明原因,你的新选择会写回修复该绑定;非交互环境(如 `kimi -p`)则继承主 Agent 的模型,并在工具结果中给出显式警告,直到你更新或清除该绑定。 ## 上下文隔离与资源开销 From 85baad252bedde4c3c1506cdaf7fbdaf003c692a Mon Sep 17 00:00:00 2001 From: yorha9e Date: Tue, 21 Jul 2026 21:25:04 +0800 Subject: [PATCH 5/8] fix(agent-core): scope profile subagent overrides to the owner edge applySubagentOverrides mutated the shared resolved target profile, so once two owners bound the same subagent type differently the first processed owner would win for all of them. linkResolvedSubagents now builds an edge-scoped view of the target ({...target, overrides}) instead of mutating shared state. Single-owner behavior is unchanged; adds regression coverage for two owners binding the same type. --- packages/agent-core/src/profile/resolve.ts | 44 ++++-------- .../test/profile/agent-profile-loader.test.ts | 68 ++++++++++++++----- 2 files changed, 63 insertions(+), 49 deletions(-) diff --git a/packages/agent-core/src/profile/resolve.ts b/packages/agent-core/src/profile/resolve.ts index d602593939..1459e09856 100644 --- a/packages/agent-core/src/profile/resolve.ts +++ b/packages/agent-core/src/profile/resolve.ts @@ -46,7 +46,6 @@ export function resolveAgentProfiles( resolvedCache.set(profile.name, toResolvedProfile(merged)); } - applySubagentOverrides(mergedCache, resolvedCache); linkResolvedSubagents(mergedCache, resolvedCache); const result: Record = {}; @@ -166,49 +165,30 @@ function buildTemplateVars( }; } -function applySubagentOverrides( - mergedProfiles: Map, - resolvedProfiles: Map, -): void { - // Note: overrides are applied to the shared resolved target profile, the - // same mechanism `description` already uses. With a single bundled owner - // profile this is safe; if multiple owners ever bind the same subagent type - // differently, the first owner processed wins for all of them. Move - // overrides onto the owner→subagent edge if that becomes a real config. - for (const [ownerName, owner] of mergedProfiles) { - if (owner.subagents === undefined) continue; - for (const [subagentName, subagent] of Object.entries(owner.subagents)) { - const target = resolvedProfiles.get(subagentName); - if (target === undefined) { - throwMissingSubagent(ownerName, subagentName); - } - if (target.description === undefined && subagent.description !== undefined) { - target.description = subagent.description; - } - if (target.modelAlias === undefined && subagent.modelAlias !== undefined) { - target.modelAlias = subagent.modelAlias; - } - if (target.thinkingEffort === undefined && subagent.thinkingEffort !== undefined) { - target.thinkingEffort = subagent.thinkingEffort; - } - } - } -} - function linkResolvedSubagents( mergedProfiles: Map, resolvedProfiles: Map, ): void { + // Overrides (`description` / `modelAlias` / `thinkingEffort` on an owner's + // `subagents` entry) are properties of the owner→subagent edge, not of the + // shared target profile. Each edge gets its own view of the target with + // the overrides applied, so two owners can bind the same subagent type + // differently without leaking into each other or into the shared profile. for (const [ownerName, owner] of mergedProfiles) { if (owner.subagents === undefined) continue; const subagents: Record = {}; - for (const subagentName of Object.keys(owner.subagents)) { + for (const [subagentName, subagent] of Object.entries(owner.subagents)) { const target = resolvedProfiles.get(subagentName); if (target === undefined) { throwMissingSubagent(ownerName, subagentName); } - subagents[subagentName] = target; + subagents[subagentName] = { + ...target, + description: target.description ?? subagent.description, + modelAlias: target.modelAlias ?? subagent.modelAlias, + thinkingEffort: target.thinkingEffort ?? subagent.thinkingEffort, + }; } const resolved = resolvedProfiles.get(ownerName); diff --git a/packages/agent-core/test/profile/agent-profile-loader.test.ts b/packages/agent-core/test/profile/agent-profile-loader.test.ts index 503ff537af..4b8d7da6f8 100644 --- a/packages/agent-core/test/profile/agent-profile-loader.test.ts +++ b/packages/agent-core/test/profile/agent-profile-loader.test.ts @@ -103,12 +103,16 @@ tools: ]); const coderPrompt = profiles['coder']?.systemPrompt(promptContext); - expect(profiles['coder']?.description).toBe('Coder child subagent'); + // Edge descriptions apply on the owner→subagent edge, not the shared target. + expect(profiles['coder']?.description).toBeUndefined(); + expect(profiles['agent']?.subagents?.['coder']?.description).toBe('Coder child subagent'); expect(profiles['coder']?.tools).toEqual(['Bash', 'Skill']); - expect(profiles['agent']?.subagents?.['shared']).toBe(profiles['shared']); - expect(profiles['agent']?.subagents?.['coder']).toBe(profiles['coder']); + // Edges are scoped views of the target, not the shared profile object. + expect(profiles['agent']?.subagents?.['shared']?.name).toBe('shared'); + expect(profiles['agent']?.subagents?.['coder']?.name).toBe('coder'); + expect(profiles['agent']?.subagents?.['coder']).not.toBe(profiles['coder']); expect(profiles['coder']?.subagents).toBeUndefined(); - expect(profiles['shared']?.description).toBe('Shared parent subagent'); + expect(profiles['agent']?.subagents?.['shared']?.description).toBe('Shared parent subagent'); expect(coderPrompt).toContain('os=macOS'); expect(coderPrompt).toContain('cwd=/workspace'); expect(coderPrompt).toContain('listing=README.md'); @@ -145,7 +149,7 @@ tools: ).toThrow(/agent -> coder -> agent/); }); - it('applies subagent model and thinking-effort bindings to the linked profile', () => { + it('applies subagent model and thinking-effort bindings to the edge, not the shared target', () => { const profiles = resolveAgentProfiles([ { name: 'agent', @@ -162,13 +166,44 @@ tools: { name: 'explore', systemPromptTemplate: 'explore prompt' }, ]); - expect(profiles['coder']?.modelAlias).toBe('cheap-model'); - expect(profiles['coder']?.thinkingEffort).toBe('high'); - expect(profiles['agent']?.subagents?.['coder']).toBe(profiles['coder']); + // The owner→subagent edge carries the override... + expect(profiles['agent']?.subagents?.['coder']?.modelAlias).toBe('cheap-model'); + expect(profiles['agent']?.subagents?.['coder']?.thinkingEffort).toBe('high'); + // ...while the shared resolved target stays unmodified. + expect(profiles['coder']?.modelAlias).toBeUndefined(); + expect(profiles['coder']?.thinkingEffort).toBeUndefined(); expect(profiles['explore']?.modelAlias).toBeUndefined(); expect(profiles['explore']?.thinkingEffort).toBeUndefined(); }); + it('lets two owners bind the same subagent type differently without leakage', () => { + const profiles = resolveAgentProfiles([ + { + name: 'agent', + subagents: { + coder: { description: 'Owner A coder', modelAlias: 'model-a' }, + }, + }, + { + name: 'other', + systemPromptTemplate: 'other prompt', + subagents: { + coder: { description: 'Owner B coder', modelAlias: 'model-b', thinkingEffort: 'low' }, + }, + }, + { name: 'coder', systemPromptTemplate: 'coder prompt' }, + ]); + + expect(profiles['agent']?.subagents?.['coder']?.modelAlias).toBe('model-a'); + expect(profiles['agent']?.subagents?.['coder']?.description).toBe('Owner A coder'); + expect(profiles['other']?.subagents?.['coder']?.modelAlias).toBe('model-b'); + expect(profiles['other']?.subagents?.['coder']?.thinkingEffort).toBe('low'); + expect(profiles['other']?.subagents?.['coder']?.description).toBe('Owner B coder'); + // Neither edge leaks into the shared target profile. + expect(profiles['coder']?.modelAlias).toBeUndefined(); + expect(profiles['coder']?.description).toBeUndefined(); + }); + it('fails loudly when an embedded system prompt source is missing', () => { expect(() => loadAgentProfilesFromSources(['profile/default/agent.yaml'], { @@ -180,15 +215,14 @@ tools: describe('default agent profiles', () => { it('links bundled subagents and keeps role-specific tool sets observable', () => { - expect(DEFAULT_AGENT_PROFILES['agent']?.subagents?.['coder']).toBe( - DEFAULT_AGENT_PROFILES['coder'], - ); - expect(DEFAULT_AGENT_PROFILES['agent']?.subagents?.['explore']).toBe( - DEFAULT_AGENT_PROFILES['explore'], - ); - expect(DEFAULT_AGENT_PROFILES['agent']?.subagents?.['plan']).toBe( - DEFAULT_AGENT_PROFILES['plan'], - ); + const agentSubagents = DEFAULT_AGENT_PROFILES['agent']?.subagents; + expect(agentSubagents?.['coder']?.name).toBe('coder'); + expect(agentSubagents?.['explore']?.name).toBe('explore'); + expect(agentSubagents?.['plan']?.name).toBe('plan'); + // Edges are views; the shared targets stay unmodified (no bundled owner + // declares model/effort overrides today). + expect(DEFAULT_AGENT_PROFILES['coder']?.modelAlias).toBeUndefined(); + expect(agentSubagents?.['coder']?.tools).toEqual(DEFAULT_AGENT_PROFILES['coder']?.tools); expect(DEFAULT_AGENT_PROFILES['agent']?.tools).toEqual( expect.arrayContaining([ From cd0d154c08ed40cc69fc856fc6c09c597cc618f4 Mon Sep 17 00:00:00 2001 From: yorha9e Date: Tue, 21 Jul 2026 21:37:12 +0800 Subject: [PATCH 6/8] fix(agent-core): warn on inheritance when the stale-binding re-ask is dismissed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In non-interactive sessions the question channel exists but is never answered (e.g. kimi -p), so the missing-model re-ask always dismisses — previously that path inherited the parent model silently. A dismissed re-ask now emits the same explicit warning as the no-ask path. --- .../src/tools/builtin/collaboration/agent.ts | 17 ++++++++++++++--- packages/agent-core/test/tools/agent.test.ts | 13 +++++++++++-- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.ts b/packages/agent-core/src/tools/builtin/collaboration/agent.ts index 8ec66e8039..ea50ac61b1 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.ts @@ -195,9 +195,20 @@ export class AgentTool implements BuiltinTool { ) { const missingModel = binding.model; if (allowAsk && this.askBinding !== undefined) { - // A dismissed re-ask leaves the broken binding in place; the user is - // asked again on the next spawn, matching the unbound-ask semantics. - binding = await this.askBinding(profileName, { missingModel }); + const repaired = await this.askBinding(profileName, { missingModel }); + if (repaired !== undefined) { + binding = repaired; + } else { + // Dismissed re-ask — including non-interactive sessions where the + // question channel exists but can never be answered (e.g. `kimi + // -p`). Inherit, but say so explicitly: a silently ignored broken + // binding is worse than a warned one. + warning = + `warning: workspace binding for subagent type "${profileName}" references unknown ` + + `model alias "${missingModel}"; inheriting the main agent model. Update it with ` + + `/subagent-model set ${profileName} or in .kimi-code/local.toml.`; + binding = undefined; + } } else { warning = `warning: workspace binding for subagent type "${profileName}" references unknown ` + diff --git a/packages/agent-core/test/tools/agent.test.ts b/packages/agent-core/test/tools/agent.test.ts index de241985b1..81bd6f52a1 100644 --- a/packages/agent-core/test/tools/agent.test.ts +++ b/packages/agent-core/test/tools/agent.test.ts @@ -1039,7 +1039,7 @@ describe('AgentTool', () => { expect(result.output).toContain('inheriting the main agent model'); }); - it('does not fall through to the plain ask when the missing-model re-ask is dismissed', async () => { + it('warns on inheritance when the missing-model re-ask is dismissed', async () => { const host = mockSubagentHost({ spawn: vi.fn().mockResolvedValue({ agentId: 'agent-child', @@ -1057,12 +1057,21 @@ describe('AgentTool', () => { isModelAliasKnown: () => false, }); - await executeTool(tool, context({ prompt: 'Investigate', description: 'Find cause' })); + const result = await executeTool( + tool, + context({ prompt: 'Investigate', description: 'Find cause' }), + ); + // Dismissed re-ask (e.g. non-interactive `kimi -p` where the question + // channel exists but is never answered): no second ask, inherit, and an + // explicit warning — never a silently ignored broken binding. expect(askBinding).toHaveBeenCalledTimes(1); expect(host.spawn).toHaveBeenCalledWith( expect.objectContaining({ modelAlias: undefined, thinkingEffort: undefined }), ); + expect(result.output).toContain('warning:'); + expect(result.output).toContain('gone/model-x'); + expect(result.output).toContain('inheriting the main agent model'); }); it('inherits plainly when the binding is an explicit inherit choice', async () => { From 7b4d25cdc2fab5fa6bf0d1c2b33b3fe09d80c10a Mon Sep 17 00:00:00 2001 From: yorha9e Date: Tue, 21 Jul 2026 20:41:25 +0800 Subject: [PATCH 7/8] feat(agent-core): named binding slots for instance-level model routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the per-workspace type bindings: users pre-configure named slots under [subagent-slot.] and the Agent tool selects one via a new binding_slot parameter — the caller names a slot, never a model. Slots outrank type bindings; unconfigured slots are created through the same ask-once flow, and broken or missing slots fall back to the type binding with an explicit warning where asking is unavailable. --- .../src/agent/tool/subagent-binding.ts | 53 +++++-- .../agent-core/src/config/workspace-local.ts | 97 ++++++++++-- .../src/tools/builtin/collaboration/agent.ts | 96 ++++++++++-- .../test/config/workspace-local.test.ts | 49 ++++++ packages/agent-core/test/tools/agent.test.ts | 142 ++++++++++++++++++ 5 files changed, 395 insertions(+), 42 deletions(-) diff --git a/packages/agent-core/src/agent/tool/subagent-binding.ts b/packages/agent-core/src/agent/tool/subagent-binding.ts index c50e8c6da3..3f4e026335 100644 --- a/packages/agent-core/src/agent/tool/subagent-binding.ts +++ b/packages/agent-core/src/agent/tool/subagent-binding.ts @@ -1,12 +1,14 @@ /** * Workspace subagent model bindings — callback factory for the Agent tool. * - * Bindings live in `/.kimi-code/local.toml` under - * `[subagent.]` (see `config/workspace-local.ts`). The Agent tool uses - * these callbacks to (a) apply a binding mechanically on spawn and (b) ask - * the user interactively the first time an unbound subagent type is spawned - * in this workspace, persisting the answer — including an explicit "keep - * inheriting" choice so the question never repeats for that type. + * Bindings live in `/.kimi-code/local.toml`: per-type bindings + * under `[subagent.]` and named binding slots under + * `[subagent-slot.]` (see `config/workspace-local.ts`). The Agent tool + * uses these callbacks to (a) apply a binding mechanically on spawn and + * (b) ask the user interactively the first time an unbound subagent type or + * requested slot is spawned in this workspace, persisting the answer — + * including an explicit "keep inheriting" choice so the question never + * repeats for that type or slot. */ import type { Kaos } from '@moonshot-ai/kaos'; @@ -15,7 +17,9 @@ import type { Agent } from '../index'; import type { QuestionAnswers, QuestionResult } from '../../rpc'; import { readSubagentBinding, + readSubagentSlotBinding, writeSubagentBinding, + writeSubagentSlotBinding, type SubagentBinding, } from '../../config/workspace-local'; @@ -23,6 +27,10 @@ export type ReadSubagentBindingCallback = ( profileName: string, ) => Promise; +export type ReadSubagentSlotBindingCallback = ( + slot: string, +) => Promise; + export interface AskSubagentBindingContext { /** * Set when the stored binding references a model alias that no longer @@ -30,6 +38,12 @@ export interface AskSubagentBindingContext { * happening again and the new choice repairs the broken binding. */ readonly missingModel?: string; + /** + * Set when the ask concerns a named binding slot (requested via the Agent + * tool's `binding_slot` parameter) rather than a subagent type; the answer + * is persisted under `[subagent-slot.]`. + */ + readonly slot?: string; } export type AskSubagentBindingCallback = ( @@ -52,12 +66,16 @@ export function createSubagentBindingCallbacks( workDir: string, ): { readBinding: ReadSubagentBindingCallback; + readSlotBinding: ReadSubagentSlotBindingCallback; askBinding?: AskSubagentBindingCallback; isModelAliasKnown: IsModelAliasKnownCallback; } { const readBinding: ReadSubagentBindingCallback = (profileName) => readSubagentBinding(kaos, workDir, profileName); + const readSlotBinding: ReadSubagentSlotBindingCallback = (slot) => + readSubagentSlotBinding(kaos, workDir, slot); + // Without a models config there is nothing to validate against — stay // silent rather than nagging about every binding. const isModelAliasKnown: IsModelAliasKnownCallback = (alias) => { @@ -67,16 +85,25 @@ export function createSubagentBindingCallbacks( }; const requestQuestion = agent.rpc?.requestQuestion?.bind(agent.rpc); - if (requestQuestion === undefined) return { readBinding, isModelAliasKnown }; + if (requestQuestion === undefined) return { readBinding, readSlotBinding, isModelAliasKnown }; const askBinding: AskSubagentBindingCallback = async (profileName, context) => { const models = agent.kimiConfig?.models ?? {}; const aliases = Object.keys(models).toSorted(); const missingModel = context?.missingModel; + const slot = context?.slot; + const subject = slot === undefined ? `Subagent type "${profileName}"` : `Binding slot "${slot}"`; + const persist = async (binding: SubagentBinding): Promise => { + if (slot === undefined) { + await writeSubagentBinding(kaos, workDir, profileName, binding); + } else { + await writeSubagentSlotBinding(kaos, workDir, slot, binding); + } + }; const modelQuestion = missingModel === undefined - ? `Subagent type "${profileName}" has no model binding in this workspace. Bind a model for it?` - : `Subagent type "${profileName}" is bound to model "${missingModel}", but that alias no longer exists in your models config. Bind a model for it?`; + ? `${subject} has no model binding in this workspace. Bind a model for it?` + : `${subject} is bound to model "${missingModel}", but that alias no longer exists in your models config. Bind a model for it?`; const modelResult = await requestQuestion({ questions: [ { @@ -96,7 +123,7 @@ export function createSubagentBindingCallbacks( if (chosen === undefined) return undefined; // dismissed — ask again next time if (chosen === INHERIT_LABEL) { const binding: SubagentBinding = { inherit: true }; - await writeSubagentBinding(kaos, workDir, profileName, binding); + await persist(binding); return binding; } @@ -104,7 +131,7 @@ export function createSubagentBindingCallbacks( let thinkingEffort: string | undefined; const supportEfforts = models[model]?.supportEfforts ?? []; if (supportEfforts.length > 0) { - const effortQuestion = `Thinking effort for subagent type "${profileName}" on ${model}?`; + const effortQuestion = `Thinking effort for ${subject} on ${model}?`; const effortResult = await requestQuestion({ questions: [ { @@ -122,11 +149,11 @@ export function createSubagentBindingCallbacks( } const binding: SubagentBinding = { model, thinkingEffort }; - await writeSubagentBinding(kaos, workDir, profileName, binding); + await persist(binding); return binding; }; - return { readBinding, askBinding, isModelAliasKnown }; + return { readBinding, readSlotBinding, askBinding, isModelAliasKnown }; } function answerFor(result: QuestionResult, question: string): string | undefined { diff --git a/packages/agent-core/src/config/workspace-local.ts b/packages/agent-core/src/config/workspace-local.ts index c9f6c14c5c..dd68c83d88 100644 --- a/packages/agent-core/src/config/workspace-local.ts +++ b/packages/agent-core/src/config/workspace-local.ts @@ -21,6 +21,7 @@ const WorkspaceLocalTomlSchema = z.object({ }) .optional(), subagent: z.record(z.string(), SubagentBindingTomlSchema).optional(), + 'subagent-slot': z.record(z.string(), SubagentBindingTomlSchema).optional(), }); type WorkspaceLocalToml = z.infer; @@ -119,11 +120,78 @@ export async function readSubagentBinding( kaos: Kaos, workDir: string, agentType: string, +): Promise { + return readBindingEntry(kaos, workDir, 'subagent', agentType); +} + +/** Read all subagent bindings for the workspace (for display/management). */ +export async function readSubagentBindings( + kaos: Kaos, + workDir: string, +): Promise>> { + return readBindingSection(kaos, workDir, 'subagent'); +} + +/** + * Write (or clear, when `binding` is `undefined`) the binding for one + * subagent type, preserving unrelated TOML content. + */ +export async function writeSubagentBinding( + kaos: Kaos, + workDir: string, + agentType: string, + binding: SubagentBinding | undefined, +): Promise<{ readonly configPath: string }> { + return writeBindingEntry(kaos, workDir, 'subagent', agentType, binding); +} + +/** + * Named binding slots (`[subagent-slot.]`): instance-level bindings the + * Agent tool selects via its `binding_slot` parameter. Unlike type bindings, + * slots let a caller address a specific pre-configured model/effort pair — + * the caller names a slot, never a model. + */ +export async function readSubagentSlotBinding( + kaos: Kaos, + workDir: string, + slot: string, +): Promise { + return readBindingEntry(kaos, workDir, 'subagent-slot', slot); +} + +/** Read all named slot bindings for the workspace (for display/management). */ +export async function readSubagentSlotBindings( + kaos: Kaos, + workDir: string, +): Promise>> { + return readBindingSection(kaos, workDir, 'subagent-slot'); +} + +/** + * Write (or clear, when `binding` is `undefined`) the binding for one named + * slot, preserving unrelated TOML content. + */ +export async function writeSubagentSlotBinding( + kaos: Kaos, + workDir: string, + slot: string, + binding: SubagentBinding | undefined, +): Promise<{ readonly configPath: string }> { + return writeBindingEntry(kaos, workDir, 'subagent-slot', slot, binding); +} + +type BindingSection = 'subagent' | 'subagent-slot'; + +async function readBindingEntry( + kaos: Kaos, + workDir: string, + section: BindingSection, + name: string, ): Promise { const projectRoot = await findProjectRoot(kaos, workDir); const configPath = getWorkspaceLocalConfigPath(projectRoot); const file = await readWorkspaceLocalToml(kaos, configPath); - const entry = file?.parsed.subagent?.[agentType]; + const entry = file?.parsed[section]?.[name]; if (entry === undefined) return undefined; return { model: entry.model, @@ -132,15 +200,15 @@ export async function readSubagentBinding( }; } -/** Read all subagent bindings for the workspace (for display/management). */ -export async function readSubagentBindings( +async function readBindingSection( kaos: Kaos, workDir: string, + section: BindingSection, ): Promise>> { const projectRoot = await findProjectRoot(kaos, workDir); const configPath = getWorkspaceLocalConfigPath(projectRoot); const file = await readWorkspaceLocalToml(kaos, configPath); - const entries = file?.parsed.subagent ?? {}; + const entries = file?.parsed[section] ?? {}; return Object.fromEntries( Object.entries(entries).map(([type, entry]) => [ type, @@ -149,34 +217,31 @@ export async function readSubagentBindings( ); } -/** - * Write (or clear, when `binding` is `undefined`) the binding for one - * subagent type, preserving unrelated TOML content. - */ -export async function writeSubagentBinding( +async function writeBindingEntry( kaos: Kaos, workDir: string, - agentType: string, + section: BindingSection, + name: string, binding: SubagentBinding | undefined, ): Promise<{ readonly configPath: string }> { const projectRoot = await findProjectRoot(kaos, workDir); const configPath = getWorkspaceLocalConfigPath(projectRoot); const file = (await readWorkspaceLocalToml(kaos, configPath)) ?? { raw: {}, parsed: {} }; - const subagent = cloneRecord(file.raw['subagent']); + const record = cloneRecord(file.raw[section]); if (binding === undefined) { - delete subagent[agentType]; + delete record[name]; } else { const entry: Record = {}; if (binding.model !== undefined) entry['model'] = binding.model; if (binding.thinkingEffort !== undefined) entry['thinking_effort'] = binding.thinkingEffort; if (binding.inherit !== undefined) entry['inherit'] = binding.inherit; - subagent[agentType] = entry; + record[name] = entry; } - if (Object.keys(subagent).length === 0) { - delete file.raw['subagent']; + if (Object.keys(record).length === 0) { + delete file.raw[section]; } else { - file.raw['subagent'] = subagent; + file.raw[section] = record; } await kaos.mkdir(dirname(configPath), { parents: true, existOk: true }); diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.ts b/packages/agent-core/src/tools/builtin/collaboration/agent.ts index ea50ac61b1..a51d3d5609 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.ts @@ -22,6 +22,7 @@ import type { AskSubagentBindingCallback, IsModelAliasKnownCallback, ReadSubagentBindingCallback, + ReadSubagentSlotBindingCallback, } from '../../../agent/tool/subagent-binding'; import type { Logger } from '../../../logging'; import { ToolAccesses } from '../../../loop/tool-access'; @@ -83,6 +84,12 @@ export const AgentToolInputSchema = z.preprocess( .describe( 'If true, return immediately without waiting for completion. Prefer false unless the task can run independently and there is a clear benefit to not waiting.', ), + binding_slot: z + .string() + .optional() + .describe( + 'Named binding slot pre-configured by the user for this workspace (.kimi-code/local.toml under [subagent-slot.]). Set ONLY when the task or preset explicitly names a slot — a slot selects a user-configured model/effort. Never invent slot names, and never use this to choose a model yourself.', + ), }), ); @@ -123,6 +130,7 @@ export class AgentTool implements BuiltinTool { subagentTimeoutMs?: number | undefined; modelSelectionEnabled?: boolean | (() => boolean); readBinding?: ReadSubagentBindingCallback; + readSlotBinding?: ReadSubagentSlotBindingCallback; askBinding?: AskSubagentBindingCallback; isModelAliasKnown?: IsModelAliasKnownCallback; }, @@ -138,6 +146,7 @@ export class AgentTool implements BuiltinTool { ? modelSelectionEnabled : () => modelSelectionEnabled; this.readBinding = options?.readBinding; + this.readSlotBinding = options?.readSlotBinding; this.askBinding = options?.askBinding; this.isModelAliasKnown = options?.isModelAliasKnown; const typeLines = buildSubagentDescriptions(subagents); @@ -155,33 +164,84 @@ export class AgentTool implements BuiltinTool { private readonly subagentTimeoutMs?: number; private readonly isModelSelectionEnabled: () => boolean; private readonly readBinding?: ReadSubagentBindingCallback; + private readonly readSlotBinding?: ReadSubagentSlotBindingCallback; private readonly askBinding?: AskSubagentBindingCallback; private readonly isModelAliasKnown?: IsModelAliasKnownCallback; /** - * Effective workspace binding for a spawn: read the stored binding; when - * absent, asking is enabled, and an interactive ask callback exists, ask - * the user once and persist the answer. When the stored binding references - * a model alias missing from the user's models config, interactively - * re-ask (repairing the binding) or — where asking is unavailable — fall - * back to plain inheritance with an explicit `warning`. Returns - * `undefined` for resume, when the experiment is disabled, or when no - * binding applies (plain inheritance). + * Effective workspace binding for a spawn. A requested `bindingSlot` + * resolves first (instance-level); an unconfigured or broken slot falls + * through to the type binding. Type bindings read the stored binding; + * when absent, asking is enabled, and an interactive ask callback exists, + * ask the user once and persist the answer. When a stored binding + * references a model alias missing from the user's models config, + * interactively re-ask (repairing the binding) or — where asking is + * unavailable — fall back with an explicit `warning`. Returns `undefined` + * for resume, when the experiment is disabled, or when no binding applies + * (plain inheritance). */ private async resolveSpawnBinding( profileName: string, operation: 'spawn' | 'resume', allowAsk: boolean, + bindingSlot?: string, ): Promise< | { readonly modelAlias?: string; readonly thinkingEffort?: string; + readonly bindingSlot?: string; readonly warning?: string; } | undefined > { if (operation !== 'spawn' || !this.isModelSelectionEnabled()) return undefined; let warning: string | undefined; + + // Named binding slot requested: instance-level binding outranks the type + // binding. An unconfigured or broken slot falls through to the type + // chain (with a warning where asking is unavailable). + if (bindingSlot !== undefined) { + let slotBinding = await this.readSlotBinding?.(bindingSlot); + if (slotBinding === undefined) { + if (allowAsk && this.askBinding !== undefined) { + slotBinding = await this.askBinding(profileName, { slot: bindingSlot }); + } else { + warning = + `warning: binding slot "${bindingSlot}" is not configured in this workspace; ` + + `falling back to the subagent type binding. Configure it in ` + + `.kimi-code/local.toml under [subagent-slot.${bindingSlot}].`; + } + } else if ( + slotBinding.inherit !== true && + slotBinding.model !== undefined && + this.isModelAliasKnown !== undefined && + !this.isModelAliasKnown(slotBinding.model) + ) { + const missingModel = slotBinding.model; + if (allowAsk && this.askBinding !== undefined) { + slotBinding = await this.askBinding(profileName, { slot: bindingSlot, missingModel }); + } else { + warning = + `warning: binding slot "${bindingSlot}" references unknown model alias ` + + `"${missingModel}"; falling back to the subagent type binding. Update it in ` + + `.kimi-code/local.toml.`; + slotBinding = undefined; + } + } + if ( + slotBinding !== undefined && + slotBinding.inherit !== true && + (slotBinding.model !== undefined || slotBinding.thinkingEffort !== undefined) + ) { + return { + modelAlias: slotBinding.model, + thinkingEffort: slotBinding.thinkingEffort, + bindingSlot, + warning, + }; + } + } + let binding = await this.readBinding?.(profileName); if (binding === undefined) { if (allowAsk && this.askBinding !== undefined) { @@ -235,7 +295,7 @@ export class AgentTool implements BuiltinTool { } // Read-only binding lookup for the approval label; the interactive // first-use ask happens later in execute(). - const binding = await this.resolveSpawnBinding(profileName, operation, false); + const binding = await this.resolveSpawnBinding(profileName, operation, false, args.binding_slot); const prefix = args.run_in_background === true ? 'Launching background' : 'Launching'; return { description: `${prefix} ${profileName} agent: ${args.description}`, @@ -293,12 +353,22 @@ export class AgentTool implements BuiltinTool { const operation = resumeAgentId !== undefined && resumeAgentId.length > 0 ? 'resume' : 'spawn'; // Workspace model binding (experiment-gated): applied mechanically at // spawn; the first unbound spawn may ask the user once interactively. - const binding = await this.resolveSpawnBinding(requestedProfileName ?? 'coder', operation, true); - const bindingWarning = binding?.warning; + const binding = await this.resolveSpawnBinding( + requestedProfileName ?? 'coder', + operation, + true, + args.binding_slot, + ); + const outputPrefix = [ + binding?.warning, + binding?.bindingSlot === undefined ? undefined : `binding_slot: ${binding.bindingSlot}`, + ] + .filter((line): line is string => line !== undefined) + .join('\n'); const withWarning = (result: ExecutableToolResult): ExecutableToolResult => - bindingWarning === undefined + outputPrefix.length === 0 ? result - : { ...result, output: `${bindingWarning}\n${result.output}` }; + : { ...result, output: `${outputPrefix}\n${result.output}` }; const runOptions = { parentToolCallId: toolCallId, prompt: args.prompt, diff --git a/packages/agent-core/test/config/workspace-local.test.ts b/packages/agent-core/test/config/workspace-local.test.ts index 74f0558372..16bb327f58 100644 --- a/packages/agent-core/test/config/workspace-local.test.ts +++ b/packages/agent-core/test/config/workspace-local.test.ts @@ -12,8 +12,11 @@ import { normalizeAdditionalDirs, readSubagentBinding, readSubagentBindings, + readSubagentSlotBinding, + readSubagentSlotBindings, readWorkspaceAdditionalDirs, writeSubagentBinding, + writeSubagentSlotBinding, } from '../../src/config/workspace-local'; const tempDirs: string[] = []; @@ -282,4 +285,50 @@ describe('subagent bindings', () => { const text = await readFile(join(root, '.kimi-code', 'local.toml'), 'utf-8'); expect(text).not.toContain('subagent'); }); + + it('writes and reads back a named slot binding', async () => { + const root = await makeProject(); + + const { configPath } = await writeSubagentSlotBinding(testKaos, root, 'debater_a', { + model: 'deepseek/deepseek-v4', + thinkingEffort: 'high', + }); + + expect(configPath).toBe(join(root, '.kimi-code', 'local.toml')); + await expect(readSubagentSlotBinding(testKaos, root, 'debater_a')).resolves.toEqual({ + model: 'deepseek/deepseek-v4', + thinkingEffort: 'high', + inherit: undefined, + }); + // Slot storage is independent from the type-binding table. + await expect(readSubagentBinding(testKaos, root, 'debater_a')).resolves.toBeUndefined(); + const text = await readFile(configPath, 'utf-8'); + expect(text).toContain('[subagent-slot.debater_a]'); + expect(text).toContain('model = "deepseek/deepseek-v4"'); + }); + + it('keeps slot bindings and type bindings side by side and clears slots independently', async () => { + const root = await makeProject(); + await writeSubagentBinding(testKaos, root, 'coder', { model: 'kimi-code/k3' }); + await writeSubagentSlotBinding(testKaos, root, 'debater_a', { model: 'deepseek/deepseek-v4' }); + await writeSubagentSlotBinding(testKaos, root, 'debater_b', { model: 'openrouter/claude' }); + + await expect(readSubagentSlotBindings(testKaos, root)).resolves.toEqual({ + debater_a: { model: 'deepseek/deepseek-v4', thinkingEffort: undefined, inherit: undefined }, + debater_b: { model: 'openrouter/claude', thinkingEffort: undefined, inherit: undefined }, + }); + await expect(readSubagentBinding(testKaos, root, 'coder')).resolves.toMatchObject({ + model: 'kimi-code/k3', + }); + + await writeSubagentSlotBinding(testKaos, root, 'debater_a', undefined); + + await expect(readSubagentSlotBinding(testKaos, root, 'debater_a')).resolves.toBeUndefined(); + await expect(readSubagentSlotBinding(testKaos, root, 'debater_b')).resolves.toMatchObject({ + model: 'openrouter/claude', + }); + const text = await readFile(join(root, '.kimi-code', 'local.toml'), 'utf-8'); + expect(text).toContain('[subagent.coder]'); + expect(text).not.toContain('debater_a'); + }); }); diff --git a/packages/agent-core/test/tools/agent.test.ts b/packages/agent-core/test/tools/agent.test.ts index 81bd6f52a1..fc6496222e 100644 --- a/packages/agent-core/test/tools/agent.test.ts +++ b/packages/agent-core/test/tools/agent.test.ts @@ -1074,6 +1074,148 @@ describe('AgentTool', () => { expect(result.output).toContain('inheriting the main agent model'); }); + it('prefers a configured binding slot over the type binding', async () => { + const host = mockSubagentHost({ + spawn: vi.fn().mockResolvedValue({ + agentId: 'agent-child', + profileName: 'coder', + resumed: false, + modelAlias: 'slot/model-a', + completion: Promise.resolve({ result: 'child result' }), + }), + }); + const tool = agentTool(host, createBackgroundManager().manager, undefined, { + modelSelectionEnabled: true, + readBinding: async () => ({ model: 'type/model-b' }), + readSlotBinding: async () => ({ model: 'slot/model-a' }), + isModelAliasKnown: () => true, + }); + + const result = await executeTool( + tool, + context({ prompt: 'Investigate', description: 'Find cause', binding_slot: 'debater_a' }), + ); + + expect(host.spawn).toHaveBeenCalledWith( + expect.objectContaining({ modelAlias: 'slot/model-a' }), + ); + expect(result.output).toContain('binding_slot: debater_a'); + }); + + it('asks to configure a requested slot and applies the answer', async () => { + const host = mockSubagentHost({ + spawn: vi.fn().mockResolvedValue({ + agentId: 'agent-child', + profileName: 'coder', + resumed: false, + modelAlias: 'sub2/glm-5.2-x', + completion: Promise.resolve({ result: 'child result' }), + }), + }); + const askBinding = vi.fn(async () => ({ model: 'sub2/glm-5.2-x' })); + const tool = agentTool(host, createBackgroundManager().manager, undefined, { + modelSelectionEnabled: true, + readBinding: async () => undefined, + readSlotBinding: async () => undefined, + askBinding, + isModelAliasKnown: () => true, + }); + + await executeTool( + tool, + context({ prompt: 'Investigate', description: 'Find cause', binding_slot: 'debater_a' }), + ); + + expect(askBinding).toHaveBeenCalledTimes(1); + expect(askBinding).toHaveBeenCalledWith('coder', { slot: 'debater_a' }); + expect(host.spawn).toHaveBeenCalledWith( + expect.objectContaining({ modelAlias: 'sub2/glm-5.2-x' }), + ); + }); + + it('warns and falls back to the type binding when the slot is unconfigured and asking is unavailable', async () => { + const host = mockSubagentHost({ + spawn: vi.fn().mockResolvedValue({ + agentId: 'agent-child', + profileName: 'coder', + resumed: false, + modelAlias: 'type/model-b', + completion: Promise.resolve({ result: 'child result' }), + }), + }); + const tool = agentTool(host, createBackgroundManager().manager, undefined, { + modelSelectionEnabled: true, + readBinding: async () => ({ model: 'type/model-b' }), + readSlotBinding: async () => undefined, + isModelAliasKnown: () => true, + }); + + const result = await executeTool( + tool, + context({ prompt: 'Investigate', description: 'Find cause', binding_slot: 'debater_a' }), + ); + + expect(host.spawn).toHaveBeenCalledWith( + expect.objectContaining({ modelAlias: 'type/model-b' }), + ); + expect(result.output).toContain('warning:'); + expect(result.output).toContain('binding slot "debater_a" is not configured'); + }); + + it('warns and falls back to the type binding when the slot alias is unknown and asking is unavailable', async () => { + const host = mockSubagentHost({ + spawn: vi.fn().mockResolvedValue({ + agentId: 'agent-child', + profileName: 'coder', + resumed: false, + modelAlias: 'type/model-b', + completion: Promise.resolve({ result: 'child result' }), + }), + }); + const tool = agentTool(host, createBackgroundManager().manager, undefined, { + modelSelectionEnabled: true, + readBinding: async () => ({ model: 'type/model-b' }), + readSlotBinding: async () => ({ model: 'gone/model-x' }), + isModelAliasKnown: (alias) => alias !== 'gone/model-x', + }); + + const result = await executeTool( + tool, + context({ prompt: 'Investigate', description: 'Find cause', binding_slot: 'debater_a' }), + ); + + expect(host.spawn).toHaveBeenCalledWith( + expect.objectContaining({ modelAlias: 'type/model-b' }), + ); + expect(result.output).toContain('binding slot "debater_a" references unknown model alias'); + }); + + it('ignores binding slots when the experiment is disabled', async () => { + const host = mockSubagentHost({ + spawn: vi.fn().mockResolvedValue({ + agentId: 'agent-child', + profileName: 'coder', + resumed: false, + completion: Promise.resolve({ result: 'child result' }), + }), + }); + const readSlotBinding = vi.fn(async () => ({ model: 'slot/model-a' })); + const tool = agentTool(host, createBackgroundManager().manager, undefined, { + modelSelectionEnabled: false, + readSlotBinding, + }); + + await executeTool( + tool, + context({ prompt: 'Investigate', description: 'Find cause', binding_slot: 'debater_a' }), + ); + + expect(readSlotBinding).not.toHaveBeenCalled(); + expect(host.spawn).toHaveBeenCalledWith( + expect.objectContaining({ modelAlias: undefined, thinkingEffort: undefined }), + ); + }); + it('inherits plainly when the binding is an explicit inherit choice', async () => { const host = mockSubagentHost({ spawn: vi.fn().mockResolvedValue({ From 63eeb55603e1f474ea1fd42124cd9eb10931ef14 Mon Sep 17 00:00:00 2001 From: yorha9e Date: Tue, 21 Jul 2026 22:01:37 +0800 Subject: [PATCH 8/8] feat(agent-core): share binding resolution on the spawn path; swarm slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move the stored workspace type-binding read into SessionSubagentHost.spawn via an injected SubagentBindingResolver, so AgentSwarm batches resolve the same bindings as Agent tool spawns (per-run overrides still win; stale aliases are ignored with a log warning — interactive repair stays at the tool layer) - AgentSwarm gains a whole-batch binding_slot parameter, resolved once per run and stamped onto item-based spawn tasks through SubagentBatch; resumed members are untouched, unconfigured/stale slots fall back with a warning - Warn on inheritance when the slot stale-binding re-ask is dismissed, matching the type-binding behavior --- packages/agent-core/src/agent/tool/index.ts | 13 +- .../src/agent/tool/subagent-binding.ts | 23 ++++ .../agent-core/src/session/subagent-batch.ts | 6 + .../agent-core/src/session/subagent-host.ts | 63 ++++++++- .../builtin/collaboration/agent-swarm.ts | 84 +++++++++++- .../src/tools/builtin/collaboration/agent.ts | 14 +- .../test/session/subagent-host.test.ts | 128 ++++++++++++++++++ .../test/tools/builtin-current.test.ts | 112 ++++++++++++++- 8 files changed, 434 insertions(+), 9 deletions(-) diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index 2d645aa455..ea90350b33 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -10,7 +10,7 @@ import { makeErrorPayload } from '../../errors'; import type { ExecutableTool, ToolUpdate } from '../../loop'; import { createMcpAuthTool } from '../../mcp/auth-tool'; import type { McpConnectionManager, McpServerEntry } from '../../mcp'; -import { createSubagentBindingCallbacks } from './subagent-binding'; +import { createSubagentBindingCallbacks, createSubagentBindingResolver } from './subagent-binding'; import { mcpResultToExecutableOutput } from '../../mcp/output'; import { isMcpToolName, qualifyMcpToolName } from '../../mcp/tool-naming'; import type { MCPClient, MCPToolDefinition } from '../../mcp/types'; @@ -689,6 +689,11 @@ export class ToolManager { config: { cwd, provider, modelCapabilities }, background, } = this.agent; + // Wire stored workspace bindings into the shared spawn path so AgentSwarm + // batches resolve the same type bindings the Agent tool does. + this.agent.subagentHost?.setBindingResolver( + createSubagentBindingResolver(this.agent, kaos, cwd), + ); const videoUploader = this.createVideoUploader(provider); const workspace = extendWorkspaceWithSkillRoots( { @@ -767,6 +772,12 @@ export class ToolManager { this.agent.subagentHost, this.agent.swarmMode, resolveSubagentTimeoutMs(this.agent.kimiConfig?.subagent?.timeoutMs), + { + log: this.agent.log, + modelSelectionEnabled: () => + this.agent.experimentalFlags.enabled('subagent-model-selection'), + ...createSubagentBindingCallbacks(this.agent, kaos, cwd), + }, ), toolServices?.webSearcher && new b.WebSearchTool(toolServices.webSearcher), toolServices?.urlFetcher && new b.FetchURLTool(toolServices.urlFetcher), diff --git a/packages/agent-core/src/agent/tool/subagent-binding.ts b/packages/agent-core/src/agent/tool/subagent-binding.ts index 3f4e026335..5ce927e3fa 100644 --- a/packages/agent-core/src/agent/tool/subagent-binding.ts +++ b/packages/agent-core/src/agent/tool/subagent-binding.ts @@ -55,6 +55,29 @@ export type IsModelAliasKnownCallback = (alias: string) => boolean; const INHERIT_LABEL = 'Keep inheriting from the main agent'; +/** + * Read-only binding resolver for the shared spawn path + * (`SessionSubagentHost.spawn`): stored type bindings plus alias validation, + * without any interactive capability. + */ +export function createSubagentBindingResolver( + agent: Agent, + kaos: Kaos, + workDir: string, +): { + readTypeBinding: (profileName: string) => Promise; + isAliasKnown: IsModelAliasKnownCallback; +} { + return { + readTypeBinding: (profileName) => readSubagentBinding(kaos, workDir, profileName), + isAliasKnown: (alias) => { + const models = agent.kimiConfig?.models; + if (models === undefined) return true; + return alias in models; + }, + }; +} + /** * Build the binding callbacks for the Agent tool. `askBinding` is returned * only when the agent can question the user interactively; in diff --git a/packages/agent-core/src/session/subagent-batch.ts b/packages/agent-core/src/session/subagent-batch.ts index 7646de3874..47e31b82d5 100644 --- a/packages/agent-core/src/session/subagent-batch.ts +++ b/packages/agent-core/src/session/subagent-batch.ts @@ -48,6 +48,10 @@ type BaseQueuedSubagentTask = { readonly description: string; readonly swarmIndex?: number; readonly swarmItem?: string; + /** Per-batch spawn override (e.g. from a binding slot); resume ignores it. */ + readonly modelAlias?: string; + /** Per-batch thinking-effort override; same semantics as `modelAlias`. */ + readonly thinkingEffort?: string; readonly runInBackground: boolean; readonly timeout?: number; readonly signal?: AbortSignal; @@ -305,6 +309,8 @@ export class SubagentBatch { prompt: task.prompt, description: task.description, swarmIndex: task.swarmIndex, + modelAlias: task.modelAlias, + thinkingEffort: task.thinkingEffort, runInBackground: task.runInBackground, signal: attempt.controller.signal, onReady: () => { diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index cd632a5bc8..454c2fe9c7 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -6,6 +6,7 @@ import { import type { Agent } from '../agent'; import type { PromptOrigin } from '../agent/context'; +import type { SubagentBinding } from '../config/workspace-local'; import { ErrorCodes, KimiError } from '../errors'; import { DenyAllPermissionPolicy } from '../agent/permission/policies/deny-all'; import { InMemoryAgentRecordPersistence } from '../agent/records'; @@ -133,6 +134,17 @@ export interface SpawnSubagentOptions extends RunSubagentOptions { readonly swarmItem?: string; } +/** + * Read-only access to workspace subagent bindings, injected into the host so + * the shared spawn path (Agent tool AND AgentSwarm batches) resolves stored + * type bindings uniformly. Interactive asks and warnings stay at the tool + * layer — the host only applies (or safely ignores) what is already stored. + */ +export interface SubagentBindingResolver { + readonly readTypeBinding: (profileName: string) => Promise; + readonly isAliasKnown: (alias: string) => boolean; +} + type SubagentCompletion = { readonly result: string; readonly usage?: TokenUsage; @@ -163,21 +175,37 @@ export class SessionSubagentHost { private readonly ownerAgentId: string, ) {} + private bindingResolver?: SubagentBindingResolver; + + setBindingResolver(resolver: SubagentBindingResolver): void { + this.bindingResolver = resolver; + } + async spawn(options: SpawnSubagentOptions): Promise { options.signal.throwIfAborted(); const parent = await this.session.ensureAgentResumed(this.ownerAgentId); const profile = this.resolveProfile(parent, options.profileName); - // Model/effort precedence: per-run override (workspace binding) > profile - // binding > inherit the parent agent. Profile bindings are part of the - // subagent-model-selection experiment and are ignored when it is off. + // Model/effort precedence: per-run override (slot or explicit) > workspace + // type binding > profile binding > inherit the parent agent. Bindings are + // part of the subagent-model-selection experiment and are ignored when it + // is off. The workspace read lives here — the shared spawn path — so + // AgentSwarm batches resolve the same stored bindings the Agent tool does. const modelSelectionEnabled = parent.experimentalFlags.enabled('subagent-model-selection'); + const workspaceBinding = await this.readWorkspaceTypeBinding( + parent, + options, + modelSelectionEnabled, + ); const modelAlias = this.resolveChildModel( parent, - options.modelAlias ?? (modelSelectionEnabled ? profile.modelAlias : undefined), + options.modelAlias ?? + workspaceBinding?.model ?? + (modelSelectionEnabled ? profile.modelAlias : undefined), ); const thinkingEffort = options.thinkingEffort ?? + workspaceBinding?.thinkingEffort ?? (modelSelectionEnabled ? profile.thinkingEffort : undefined) ?? parent.config.thinkingEffort; const { id, agent } = await this.session.createAgent( @@ -370,6 +398,33 @@ export class SessionSubagentHost { return profile; } + /** + * Stored workspace type binding for the shared spawn path. Skipped when the + * caller passed an explicit per-run override (slot/type resolution at the + * tool layer wins), when the experiment is off, or when no resolver is + * wired. A stored alias that no longer resolves is ignored with a log + * warning — the host cannot ask; interactive repair stays at the tool + * layer. + */ + private async readWorkspaceTypeBinding( + parent: Agent, + options: SpawnSubagentOptions, + modelSelectionEnabled: boolean, + ): Promise { + if (options.modelAlias !== undefined || !modelSelectionEnabled) return undefined; + if (this.bindingResolver === undefined) return undefined; + const binding = await this.bindingResolver.readTypeBinding(options.profileName); + if (binding === undefined || binding.inherit === true) return undefined; + if (binding.model !== undefined && !this.bindingResolver.isAliasKnown(binding.model)) { + parent.log?.warn('ignoring workspace binding with unknown model alias', { + profileName: options.profileName, + modelAlias: binding.model, + }); + return undefined; + } + return binding; + } + private runWithActiveChild( childId: string, options: RunSubagentOptions, 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..c8f2131b15 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts @@ -2,6 +2,11 @@ import { z } from 'zod'; import type { SwarmMode } from '../../../agent/swarm'; import type { BuiltinTool } from '../../../agent/tool'; +import type { + IsModelAliasKnownCallback, + ReadSubagentSlotBindingCallback, +} from '../../../agent/tool/subagent-binding'; +import type { Logger } from '../../../logging'; import { DEFAULT_SUBAGENT_TIMEOUT_MS, type QueuedSubagentTask, @@ -52,6 +57,14 @@ export const AgentSwarmToolInputSchema = z .describe( 'Map of existing subagent agent_id to the prompt used to resume that subagent. These resumed subagents are launched before new item-based subagents.', ), + binding_slot: z + .string() + .trim() + .min(1) + .optional() + .describe( + 'Named binding slot pre-configured by the user for this workspace (.kimi-code/local.toml under [subagent-slot.]), applied to every new item-based spawn in this batch. Set ONLY when the task or preset explicitly names a slot — a slot selects a user-configured model/effort. Never invent slot names, and never use this to choose a model yourself.', + ), }) .strict(); @@ -94,7 +107,27 @@ 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, - ) {} + options?: { + log?: Logger; + modelSelectionEnabled?: boolean | (() => boolean); + readSlotBinding?: ReadSubagentSlotBindingCallback; + isModelAliasKnown?: IsModelAliasKnownCallback; + }, + ) { + this.log = options?.log; + const modelSelectionEnabled = options?.modelSelectionEnabled ?? false; + this.isModelSelectionEnabled = + typeof modelSelectionEnabled === 'function' + ? modelSelectionEnabled + : () => modelSelectionEnabled; + this.readSlotBinding = options?.readSlotBinding; + this.isModelAliasKnown = options?.isModelAliasKnown; + } + + private readonly log?: Logger; + private readonly isModelSelectionEnabled: () => boolean; + private readonly readSlotBinding?: ReadSubagentSlotBindingCallback; + private readonly isModelAliasKnown?: IsModelAliasKnownCallback; resolveExecution(args: AgentSwarmToolInput): ToolExecution { const agentCount = (args.items?.length ?? 0) + Object.keys(args.resume_agent_ids ?? {}).length; @@ -135,6 +168,7 @@ export class AgentSwarmTool implements BuiltinTool { toolCallId: string, ): Promise { const profileName = normalizeOptionalString(args.subagent_type) ?? DEFAULT_SUBAGENT_TYPE; + const slot = await this.resolveBatchSlot(args); const specs = createAgentSwarmSpecs(args, (agentId) => this.subagentHost.getSwarmItem(agentId)); const tasks = specs.map((spec): QueuedSubagentTask => { const descriptionName = spec.kind === 'resume' ? 'resume' : profileName; @@ -160,10 +194,56 @@ export class AgentSwarmTool implements BuiltinTool { return { ...common, kind: 'spawn', + modelAlias: slot?.modelAlias, + thinkingEffort: slot?.thinkingEffort, }; }); const results = await this.subagentHost.runQueued(tasks); - return renderSwarmResults(results.map(({ task, ...result }) => ({ spec: task.data, ...result }))); + const rendered = renderSwarmResults( + results.map(({ task, ...result }) => ({ spec: task.data, ...result })), + ); + return slot?.warning === undefined ? rendered : `${slot.warning}\n${rendered}`; + } + + /** + * Whole-batch binding slot: one lookup per swarm run (never per member). + * Unconfigured or stale slots degrade to the workspace type binding — + * resolved by the host's shared spawn path — with an explicit warning. + * Swarm launches never ask interactively. + */ + private async resolveBatchSlot( + args: AgentSwarmToolInput, + ): Promise< + { readonly modelAlias?: string; readonly thinkingEffort?: string; readonly warning?: string } | undefined + > { + const bindingSlot = normalizeOptionalString(args.binding_slot); + if (bindingSlot === undefined || !this.isModelSelectionEnabled()) return undefined; + const binding = await this.readSlotBinding?.(bindingSlot); + if (binding !== undefined && binding.inherit !== true) { + if ( + binding.model === undefined || + this.isModelAliasKnown === undefined || + this.isModelAliasKnown(binding.model) + ) { + return { modelAlias: binding.model, thinkingEffort: binding.thinkingEffort }; + } + this.log?.warn('swarm binding slot references unknown model alias', { + bindingSlot, + modelAlias: binding.model, + }); + return { + warning: + `warning: binding slot "${bindingSlot}" references unknown model alias ` + + `"${binding.model}"; falling back to the subagent type binding. Update it in ` + + `.kimi-code/local.toml.`, + }; + } + return { + warning: + `warning: binding slot "${bindingSlot}" is not configured in this workspace; ` + + `falling back to the subagent type binding. Configure it in ` + + `.kimi-code/local.toml under [subagent-slot.${bindingSlot}].`, + }; } } diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.ts b/packages/agent-core/src/tools/builtin/collaboration/agent.ts index a51d3d5609..11c386d6f7 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.ts @@ -219,7 +219,19 @@ export class AgentTool implements BuiltinTool { ) { const missingModel = slotBinding.model; if (allowAsk && this.askBinding !== undefined) { - slotBinding = await this.askBinding(profileName, { slot: bindingSlot, missingModel }); + const repaired = await this.askBinding(profileName, { slot: bindingSlot, missingModel }); + if (repaired !== undefined) { + slotBinding = repaired; + } else { + // Dismissed re-ask (e.g. `kimi -p` where the question channel + // exists but is never answered): fall back with an explicit + // warning rather than silently ignoring the broken slot. + warning = + `warning: binding slot "${bindingSlot}" references unknown model alias ` + + `"${missingModel}"; falling back to the subagent type binding. Update it in ` + + `.kimi-code/local.toml.`; + slotBinding = undefined; + } } else { warning = `warning: binding slot "${bindingSlot}" references unknown 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 f51b50344a..94da1ab368 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -1323,6 +1323,134 @@ describe('SessionSubagentHost', () => { }), ); }); + + it('resolves a stored workspace type binding on the shared spawn path (experiment on)', async () => { + const twoModelConfig = { + providers: {}, + models: { + 'subagent-model': { + provider: 'test-provider', + model: 'subagent-model', + maxContextSize: 1_000_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + }, + }, + }; + const parent = testAgent({ + initialConfig: twoModelConfig, + experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS, { + 'subagent-model-selection': true, + }), + }); + parent.configure(); + parent.newEvents(); + + const child = testAgent({ type: 'sub', initialConfig: twoModelConfig }); + child.configure(); + child.mockNextResponse({ + type: 'text', + text: 'Completed the delegated work on the workspace-bound model, returning a detailed implementation and verification summary so the parent can continue without repeating the work. '.repeat(3), + }); + const host = new SessionSubagentHost(fakeSession(parent.agent, child.agent), 'main'); + host.setBindingResolver({ + readTypeBinding: async () => ({ model: 'subagent-model', thinkingEffort: 'high' }), + isAliasKnown: () => true, + }); + + const handle = await host.spawn({ + profileName: 'coder', + parentToolCallId: 'call_agent', + prompt: 'Implement the fix', + description: 'Fix bug', + runInBackground: false, + signal, + }); + await handle.completion; + + expect(child.agent.config.modelAlias).toBe('subagent-model'); + expect(child.agent.config.thinkingEffort).toBe('high'); + expect(handle.modelAlias).toBe('subagent-model'); + expect(handle.thinkingEffort).toBe('high'); + }); + + it('does not read the workspace binding when the caller passes an explicit override', async () => { + const twoModelConfig = { + providers: {}, + models: { + 'subagent-model': { + provider: 'test-provider', + model: 'subagent-model', + maxContextSize: 1_000_000, + }, + }, + }; + const parent = testAgent({ + initialConfig: twoModelConfig, + experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS, { + 'subagent-model-selection': true, + }), + }); + parent.configure(); + + const child = testAgent({ type: 'sub', initialConfig: twoModelConfig }); + child.configure(); + child.mockNextResponse({ + type: 'text', + text: 'Completed the delegated work on the explicitly overridden model, returning a detailed implementation and verification summary so the parent can continue. '.repeat(3), + }); + const host = new SessionSubagentHost(fakeSession(parent.agent, child.agent), 'main'); + const readTypeBinding = vi.fn(async () => ({ model: 'subagent-model' })); + host.setBindingResolver({ readTypeBinding, isAliasKnown: () => true }); + + const handle = await host.spawn({ + profileName: 'coder', + parentToolCallId: 'call_agent', + prompt: 'Implement the fix', + description: 'Fix bug', + runInBackground: false, + signal, + modelAlias: 'subagent-model', + }); + await handle.completion; + + expect(readTypeBinding).not.toHaveBeenCalled(); + expect(handle.modelAlias).toBe('subagent-model'); + }); + + it('ignores a stored binding whose alias is unknown and inherits the parent', async () => { + const parent = testAgent({ + experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS, { + 'subagent-model-selection': true, + }), + }); + parent.configure(); + + const child = testAgent({ type: 'sub' }); + child.configure(); + child.mockNextResponse({ + type: 'text', + text: 'Completed the delegated work on the inherited parent model after the stale binding was ignored, returning a detailed summary so the parent can continue. '.repeat(3), + }); + const host = new SessionSubagentHost(fakeSession(parent.agent, child.agent), 'main'); + host.setBindingResolver({ + readTypeBinding: async () => ({ model: 'gone-model' }), + isAliasKnown: () => false, + }); + + const handle = await host.spawn({ + profileName: 'coder', + parentToolCallId: 'call_agent', + prompt: 'Implement the fix', + description: 'Fix bug', + runInBackground: false, + signal, + }); + await handle.completion; + + expect(child.agent.config.modelAlias).toBe(parent.agent.config.modelAlias); + expect(handle.modelAlias).toBe(parent.agent.config.modelAlias); + }); }); describe('Session resume permission parent chain', () => { diff --git a/packages/agent-core/test/tools/builtin-current.test.ts b/packages/agent-core/test/tools/builtin-current.test.ts index f9bae6ea6f..0e70b26860 100644 --- a/packages/agent-core/test/tools/builtin-current.test.ts +++ b/packages/agent-core/test/tools/builtin-current.test.ts @@ -388,7 +388,7 @@ describe('current builtin collaboration tools', () => { }, }); expect(Object.keys(tool.parameters['properties'] as Record).at(-1)).toBe( - 'resume_agent_ids', + 'binding_slot', ); const result = await executeTool(tool, context(input, 'call_swarm')); @@ -435,6 +435,116 @@ describe('current builtin collaboration tools', () => { expect(result.isError).toBeUndefined(); }); + it('AgentSwarm stamps a binding slot onto every item-based spawn task', async () => { + const host = mockSubagentHost({ + runQueued: vi.fn().mockResolvedValue([ + { + task: { + kind: 'spawn', + data: { kind: 'spawn', index: 1, item: 'src/a.ts', prompt: 'Review src/a.ts' }, + profileName: 'coder', + parentToolCallId: 'call_swarm', + prompt: 'Review src/a.ts', + description: 'Review files #1 (coder)', + runInBackground: false, + }, + agentId: 'agent-1', + status: 'completed', + result: 'result a', + }, + ]), + }); + const tool = new AgentSwarmTool(host, mockSwarmMode(), undefined, { + modelSelectionEnabled: true, + readSlotBinding: async () => ({ model: 'slot/model-a', thinkingEffort: 'high' }), + isModelAliasKnown: () => true, + }); + + await executeTool( + tool, + context( + { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts'], + resume_agent_ids: { 'agent-old': 'continue' }, + binding_slot: 'debater_a', + }, + 'call_swarm', + ), + ); + + const tasks = (host.runQueued as ReturnType).mock.calls[0]![0] as Array< + Record + >; + const spawnTask = tasks.find((task) => task['kind'] === 'spawn')!; + const resumeTask = tasks.find((task) => task['kind'] === 'resume')!; + expect(spawnTask['modelAlias']).toBe('slot/model-a'); + expect(spawnTask['thinkingEffort']).toBe('high'); + // Resumed members keep their original configuration — no slot stamping. + expect(resumeTask['modelAlias']).toBeUndefined(); + }); + + it('AgentSwarm warns and falls back when the binding slot is unconfigured', async () => { + const host = mockSubagentHost({ + runQueued: vi.fn().mockResolvedValue([ + { + task: { + kind: 'spawn', + data: { kind: 'spawn', index: 1, item: 'src/a.ts', prompt: 'Review src/a.ts' }, + profileName: 'coder', + parentToolCallId: 'call_swarm', + prompt: 'Review src/a.ts', + description: 'Review files #1 (coder)', + runInBackground: false, + }, + agentId: 'agent-1', + status: 'completed', + result: 'result a', + }, + { + task: { + kind: 'spawn', + data: { kind: 'spawn', index: 2, item: 'src/b.ts', prompt: 'Review src/b.ts' }, + profileName: 'coder', + parentToolCallId: 'call_swarm', + prompt: 'Review src/b.ts', + description: 'Review files #2 (coder)', + runInBackground: false, + }, + agentId: 'agent-2', + status: 'completed', + result: 'result b', + }, + ]), + }); + const tool = new AgentSwarmTool(host, mockSwarmMode(), undefined, { + modelSelectionEnabled: true, + readSlotBinding: async () => undefined, + isModelAliasKnown: () => true, + }); + + const result = await executeTool( + tool, + context( + { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + binding_slot: 'debater_a', + }, + 'call_swarm', + ), + ); + + expect(result.output).toContain('warning:'); + expect(result.output).toContain('binding slot "debater_a" is not configured'); + const tasks = (host.runQueued as ReturnType).mock.calls[0]![0] as Array< + Record + >; + expect(tasks[0]!['modelAlias']).toBeUndefined(); + }); + it('AgentSwarm does not expose permission rule argument matching', () => { const tool = new AgentSwarmTool(mockSubagentHost({}), mockSwarmMode()); const execution = tool.resolveExecution({