diff --git a/.changeset/subagent-model-binding.md b/.changeset/subagent-model-binding.md new file mode 100644 index 0000000000..fb4f5d24cf --- /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 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/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..e3c0ab1521 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 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. 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 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..bd94eff4b9 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 上——主 Agent 看不到也无法覆盖它。(`AgentSwarm` 批量派发暂不读取绑定,swarm 的模型指定方案留作后续。)随时可以用 `/subagent-model` 命令管理绑定(`list` / `set <类型>` / `clear <类型>`)。优先级:工作区绑定 > profile 绑定(随应用分发的 profile)> 继承主 Agent 当前的模型与思考强度。 + +绑定值在派发时固化:唤回(resume)子 Agent 总是保持它已配置的模型与思考强度,无法在对话中途切换,会话重启后也会恢复。唤回时对子 Agent 已固化的别名做严格校验,不可解析会以配置错误失败。而工作区绑定里的别名在派发前同样会对照你的模型配置校验——如果它不再可解析(比如已从 `config.toml` 删除),交互环境下会重新弹出绑定询问并注明原因,你的新选择会写回修复该绑定;非交互环境(如 `kimi -p`)则继承主 Agent 的模型,并在工具结果中给出显式警告,直到你更新或清除该绑定。 + ## 上下文隔离与资源开销 每个子 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..ea90350b33 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, createSubagentBindingResolver } from './subagent-binding'; import { mcpResultToExecutableOutput } from '../../mcp/output'; import { isMcpToolName, qualifyMcpToolName } from '../../mcp/tool-naming'; import type { MCPClient, MCPToolDefinition } from '../../mcp/types'; @@ -688,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( { @@ -756,6 +762,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 && @@ -763,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 new file mode 100644 index 0000000000..5ce927e3fa --- /dev/null +++ b/packages/agent-core/src/agent/tool/subagent-binding.ts @@ -0,0 +1,189 @@ +/** + * Workspace subagent model bindings — callback factory for the Agent tool. + * + * 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'; + +import type { Agent } from '../index'; +import type { QuestionAnswers, QuestionResult } from '../../rpc'; +import { + readSubagentBinding, + readSubagentSlotBinding, + writeSubagentBinding, + writeSubagentSlotBinding, + type SubagentBinding, +} from '../../config/workspace-local'; + +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 + * 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; + /** + * 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 = ( + profileName: string, + context?: AskSubagentBindingContext, +) => Promise; + +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 + * non-interactive environments (e.g. print mode) spawns silently inherit. + */ +export function createSubagentBindingCallbacks( + agent: Agent, + kaos: Kaos, + 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) => { + 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, 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 + ? `${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: [ + { + 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 persist(binding); + return binding; + } + + const model = chosen; + let thinkingEffort: string | undefined; + const supportEfforts = models[model]?.supportEfforts ?? []; + if (supportEfforts.length > 0) { + const effortQuestion = `Thinking effort for ${subject} 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 persist(binding); + return binding; + }; + + return { readBinding, readSlotBinding, askBinding, isModelAliasKnown }; +} + +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..dd68c83d88 100644 --- a/packages/agent-core/src/config/workspace-local.ts +++ b/packages/agent-core/src/config/workspace-local.ts @@ -8,12 +8,20 @@ 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(), + 'subagent-slot': z.record(z.string(), SubagentBindingTomlSchema).optional(), }); type WorkspaceLocalToml = z.infer; @@ -94,6 +102,153 @@ 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 { + 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[section]?.[name]; + if (entry === undefined) return undefined; + return { + model: entry.model, + thinkingEffort: entry.thinking_effort, + inherit: entry.inherit, + }; +} + +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[section] ?? {}; + return Object.fromEntries( + Object.entries(entries).map(([type, entry]) => [ + type, + { model: entry.model, thinkingEffort: entry.thinking_effort, inherit: entry.inherit }, + ]), + ); +} + +async function writeBindingEntry( + kaos: Kaos, + workDir: 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 record = cloneRecord(file.raw[section]); + if (binding === undefined) { + 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; + record[name] = entry; + } + if (Object.keys(record).length === 0) { + delete file.raw[section]; + } else { + file.raw[section] = record; + } + + 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..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)); } - applySubagentDescriptions(mergedCache, resolvedCache); linkResolvedSubagents(mergedCache, resolvedCache); const result: Record = {}; @@ -166,38 +165,30 @@ function buildTemplateVars( }; } -function applySubagentDescriptions( - mergedProfiles: Map, - resolvedProfiles: Map, -): void { - 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; - } - } - } -} - 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/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-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 bb80a18209..454c2fe9c7 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -6,7 +6,8 @@ import { import type { Agent } from '../agent'; import type { PromptOrigin } from '../agent/context'; -import { ErrorCodes } from '../errors'; +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'; import { isAbortError } from '../loop/errors'; @@ -32,6 +33,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 +114,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; @@ -123,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; @@ -132,6 +154,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; }; @@ -149,19 +175,50 @@ 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 (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 ?? + 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( { 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 +229,57 @@ 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 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); + 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 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 { 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 +292,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`); @@ -317,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, @@ -400,12 +508,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 +527,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 +627,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 +639,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-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 e12a8c16df..11c386d6f7 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.ts @@ -18,6 +18,12 @@ import { z } from 'zod'; import type { BuiltinTool } from '../../../agent/tool'; +import type { + AskSubagentBindingCallback, + IsModelAliasKnownCallback, + ReadSubagentBindingCallback, + ReadSubagentSlotBindingCallback, +} from '../../../agent/tool/subagent-binding'; import type { Logger } from '../../../logging'; import { ToolAccesses } from '../../../loop/tool-access'; import { isAbortError } from '../../../loop/errors'; @@ -78,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.', + ), }), ); @@ -116,6 +128,11 @@ export class AgentTool implements BuiltinTool { log?: Logger; allowBackground?: boolean | undefined; subagentTimeoutMs?: number | undefined; + modelSelectionEnabled?: boolean | (() => boolean); + readBinding?: ReadSubagentBindingCallback; + readSlotBinding?: ReadSubagentSlotBindingCallback; + askBinding?: AskSubagentBindingCallback; + isModelAliasKnown?: IsModelAliasKnownCallback; }, ) { const log = options?.log; @@ -123,6 +140,15 @@ 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.readSlotBinding = options?.readSlotBinding; + 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 @@ -136,20 +162,159 @@ 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 readSlotBinding?: ReadSubagentSlotBindingCallback; + private readonly askBinding?: AskSubagentBindingCallback; + private readonly isModelAliasKnown?: IsModelAliasKnownCallback; + + /** + * 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) { + 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 ` + + `"${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) { + 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) { + 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 ` + + `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 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 { 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, args.binding_slot); 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 +363,30 @@ 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, + 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 => + outputPrefix.length === 0 + ? result + : { ...result, output: `${outputPrefix}\n${result.output}` }; const runOptions = { parentToolCallId: toolCallId, prompt: args.prompt, description: args.description, + modelAlias: binding?.modelAlias, + thinkingEffort: binding?.thinkingEffort, runInBackground, signal: controller.signal, }; @@ -255,28 +440,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 }; } @@ -314,6 +499,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 +517,7 @@ function formatBackgroundAgentResult( 'status: running', `agent_id: ${handle.agentId}`, `actual_subagent_type: ${handle.profileName}`, + ...handleModelLines(handle), 'automatic_notification: true', '', `description: ${description}`, @@ -340,6 +533,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 +549,7 @@ function formatForegroundAgentFailure( const lines = [ `agent_id: ${handle.agentId}`, `actual_subagent_type: ${handle.profileName}`, + ...handleModelLines(handle), 'status: failed', '', `subagent error: ${message}`, @@ -386,3 +581,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..16bb327f58 100644 --- a/packages/agent-core/test/config/workspace-local.test.ts +++ b/packages/agent-core/test/config/workspace-local.test.ts @@ -10,7 +10,13 @@ import { appendWorkspaceAdditionalDir, loadWorkspaceLocalConfig, normalizeAdditionalDirs, + readSubagentBinding, + readSubagentBindings, + readSubagentSlotBinding, + readSubagentSlotBindings, readWorkspaceAdditionalDirs, + writeSubagentBinding, + writeSubagentSlotBinding, } from '../../src/config/workspace-local'; const tempDirs: string[] = []; @@ -202,3 +208,127 @@ 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'); + }); + + 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/profile/agent-profile-loader.test.ts b/packages/agent-core/test/profile/agent-profile-loader.test.ts index c7ef6bdb0e..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,6 +149,61 @@ tools: ).toThrow(/agent -> coder -> agent/); }); + it('applies subagent model and thinking-effort bindings to the edge, not the shared target', () => { + 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' }, + ]); + + // 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'], { @@ -156,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([ diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index bcaace75d6..94da1ab368 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'; @@ -15,6 +16,7 @@ import { collectGitContext } from '../../src/session/git-context'; import { DEFAULT_SUBAGENT_TIMEOUT_MS, SessionSubagentHost, + SUBAGENT_MODEL_UNAVAILABLE_MESSAGE, formatSubagentTimeoutDescription, resolveSubagentTimeoutMs, type QueuedSubagentTask, @@ -1128,7 +1130,103 @@ describe('SessionSubagentHost', () => { expect(userTextMessages(histories[1] ?? [])).toEqual(['Implement the retry-safe change']); }); - it('realigns a resumed subagent to the parent agent current model', async () => { + it('keeps a resumed subagent on its configured model (sticky, experiment on)', 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(); + parent.agent.permission.setMode('yolo'); + + const child = testAgent({ initialConfig: twoModelConfig }); + child.configure({ tools: ['Read'] }); + // 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' }), + ); + 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; + 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 (experiment on)', async () => { + const parent = testAgent({ + experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS, { + 'subagent-model-selection': true, + }), + }); + 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('realigns a resumed subagent to the parent model when the experiment is off', async () => { const parent = testAgent(); parent.configure(); parent.agent.permission.setMode('yolo'); @@ -1165,10 +1263,193 @@ 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. + // 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: {}, + 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', + }), + }), + ); + }); + + 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); }); }); diff --git a/packages/agent-core/test/tools/agent.test.ts b/packages/agent-core/test/tools/agent.test.ts index ec5109c2ef..fc6496222e 100644 --- a/packages/agent-core/test/tools/agent.test.ts +++ b/packages/agent-core/test/tools/agent.test.ts @@ -895,6 +895,398 @@ 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('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('warns on inheritance 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, + }); + + 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('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({ + 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/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({ 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({