Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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