Skip to content
5 changes: 5 additions & 0 deletions .changeset/subagent-model-binding.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
35 changes: 35 additions & 0 deletions apps/kimi-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -254,6 +279,16 @@ export const BUILTIN_SLASH_COMMANDS = [
argumentHint: '[list] | <path>',
completeArgs: addDirArgumentCompletions,
},
{
name: 'subagent-model',
aliases: [],
description: 'Manage per-workspace model bindings for subagent types',
priority: 60,
availability: 'idle-only',
argumentHint: '[list] | set <type> | clear <type>',
completeArgs: subagentModelArgumentCompletions,
experimentalFlag: 'subagent-model-selection',
},
{
name: 'experiments',
aliases: ['experimental'],
Expand Down
175 changes: 175 additions & 0 deletions apps/kimi-code/src/tui/commands/subagent-model.ts
Original file line number Diff line number Diff line change
@@ -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 <type> pick a model (and effort) for a subagent type
* /subagent-model clear <type> remove a binding
*/
export async function handleSubagentModelCommand(
host: SlashCommandHost,
args: string,
): Promise<void> {
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 <type> 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 <type>');
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 <type>');
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 <type> | clear <type>');
}

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<void> {
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<void> {
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));
}
}
145 changes: 145 additions & 0 deletions apps/kimi-code/test/tui/commands/subagent-model.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, { model?: string; thinkingEffort?: string; inherit?: boolean }>;
availableModels?: Record<string, { supportEfforts?: string[] }>;
}) {
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<typeof vi.fn>;
showStatus: ReturnType<typeof vi.fn>;
mountEditorReplacement: ReturnType<typeof vi.fn>;
restoreEditor: ReturnType<typeof vi.fn>;
};
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 <type>');
});
});
Loading