Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
16c026a
feat(agent-core): custom agent files and secondary model on the v1 en…
7Sageer Jul 27, 2026
da65991
fix(cli): guard optional agentFiles in the prompt runner
7Sageer Jul 27, 2026
2d86f42
fix(agent-core): preserve custom agent bindings on v1
7Sageer Jul 27, 2026
ab4740c
fix(agent-core): narrow secondary model error hints
7Sageer Jul 27, 2026
1d20929
fix(agent-core): persist custom agent profile bindings
7Sageer Jul 27, 2026
d760a7d
Delete .changeset/sdk-agent-profile-options.md
7Sageer Jul 27, 2026
e05779d
Update v1-custom-agent-files.md
7Sageer Jul 27, 2026
e6fbd0d
Update v1-secondary-model.md
7Sageer Jul 27, 2026
943efda
Update v1-custom-agent-files.md
7Sageer Jul 27, 2026
e297ee7
fix(agent-core): keep SYSTEM.md a prompt-only overlay for delegation
7Sageer Jul 27, 2026
47f2fb3
docs: update agent file and secondary model availability wording
7Sageer Jul 27, 2026
eddf5ee
fix(cli): reject --agent-file combined with session resume
7Sageer Jul 27, 2026
3273045
refactor(agent-core): share prompt-section prose and note v2 twins in…
7Sageer Jul 27, 2026
725b4f4
feat(cli): add /secondary_model command for the subagent model
7Sageer Jul 27, 2026
fe5ebbe
feat(tui): show the bound model in subagent run stats
7Sageer Jul 27, 2026
8c83203
fix(agent-core): validate agent profile before session persistence
7Sageer Jul 27, 2026
97cb8f3
fix(agent-core): refresh subagent tools after model switch
7Sageer Jul 27, 2026
4332d72
fix(agent-core): show subagent model preferences
7Sageer Jul 27, 2026
249f050
fix(agent-core): preserve secondary model recipe on live apply
7Sageer Jul 27, 2026
22a0a69
fix(agent-core): make secondary model apply explicit
7Sageer Jul 27, 2026
26f87e2
fix(tui): refresh secondary model display state
7Sageer Jul 27, 2026
4d31304
chore: merge secondary model changesets into one
7Sageer Jul 27, 2026
2709ba3
Add /secondary_model command for subagent configuration
7Sageer Jul 27, 2026
bb0fd01
fix(agent-core): align explicit agent file precedence
7Sageer Jul 27, 2026
08cd432
fix(agent-core): let disallowedTools deny select_tools
7Sageer Jul 28, 2026
38c7df5
chore(cli): drop engine mention from --agent/--agent-file help text
7Sageer Jul 28, 2026
1c8abe5
feat(cli): support --agent/--agent-file in the interactive TUI
7Sageer Jul 28, 2026
1d4275e
fix(agent-core): persist new secondary-model selections under env ove…
7Sageer Jul 28, 2026
7f76b85
fix(cli): report the effective secondary model when env overrides the…
7Sageer Jul 28, 2026
a977778
feat(tui): show the bound model name in the AgentSwarm panel header
7Sageer Jul 28, 2026
e83cd85
Merge remote-tracking branch 'origin/main' into v1-subagent
7Sageer Jul 29, 2026
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/v1-custom-agent-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Support Markdown-defined custom agents on agent-core.
5 changes: 5 additions & 0 deletions .changeset/v1-secondary-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add the /secondary_model slash command to configure the secondary model used by subagents.
42 changes: 42 additions & 0 deletions apps/kimi-code/src/cli/agent-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';

import { parseAgentFileText, resolveAgentPath } from '@moonshot-ai/kimi-code-sdk';

import type { CLIOptions } from './options';

/**
* Resolve which agent profile the launch flags select.
*
* `--agent` carries the profile name directly; `--agent-file` implicitly
* selects the profile the file defines, so the file is parsed here (fatal on
* error) so a bad file fails before any session work. Returns undefined when
* neither flag is present.
*/
export async function resolveAgentProfileSelection(
opts: Pick<CLIOptions, 'agent' | 'agentFiles'>,
workDir: string,
): Promise<string | undefined> {
if (opts.agent !== undefined) return opts.agent;
const agentFile = opts.agentFiles?.[0];
if (agentFile === undefined) return undefined;

const path = resolveAgentPath(agentFile, workDir, homedir());
let text: string;
try {
text = await readFile(path, 'utf8');
} catch (error) {
throw new Error(
`Failed to read agent file "${path}": ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
try {
return parseAgentFileText({ path, source: 'explicit', text }).name;
} catch (error) {
throw new Error(
`Invalid agent file "${path}": ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
}
4 changes: 2 additions & 2 deletions apps/kimi-code/src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export function createProgram(
.addOption(
new Option(
'--agent <name>',
'Agent profile to use for this invocation (v2 engine only). Custom profiles are discovered from agent directories or loaded via --agent-file.',
'Agent profile to start the new session with. Custom profiles are discovered from agent directories or loaded via --agent-file. Cannot be combined with --session/--continue.',
)
.argParser((value: string, previous: string | undefined) => {
if (previous !== undefined) {
Expand All @@ -90,7 +90,7 @@ export function createProgram(
.addOption(
new Option(
'--agent-file <path>',
'Load an agent definition from a Markdown file and select it (v2 engine only).',
'Load an agent definition from a Markdown file and select it for the new session. Cannot be combined with --session/--continue.',
)
.argParser((value: string, previous: string[] | undefined) => {
if ((previous?.length ?? 0) > 0) {
Expand Down
6 changes: 2 additions & 4 deletions apps/kimi-code/src/cli/options.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { isKimiV2Enabled } from './experimental-v2';

export type UIMode = 'shell' | 'print';
export type PromptOutputFormat = 'text' | 'stream-json';

Expand Down Expand Up @@ -101,10 +99,10 @@ export function validateOptions(
}
if (
(opts.agent !== undefined || opts.agentFiles.length > 0) &&
(!promptMode || !isKimiV2Enabled(env))
(opts.session !== undefined || opts.continue)
) {
throw new OptionConflictError(
'--agent/--agent-file are only available with the v2 engine (kimi -p with KIMI_CODE_EXPERIMENTAL_FLAG=1).',
'Cannot combine --agent/--agent-file with --session/--continue: the agent is bound at session creation and the bound agent is restored automatically on resume.',
);
}
if (promptMode && opts.session === '') {
Expand Down
7 changes: 7 additions & 0 deletions apps/kimi-code/src/cli/run-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { resolve } from 'pathe';

import { CLI_SHUTDOWN_TIMEOUT_MS, PROMPT_CLEANUP_TIMEOUT_MS } from '#/constant/app';

import { resolveAgentProfileSelection } from './agent-selection';
import { isKimiV2Enabled } from './experimental-v2';
import { resolveOutputFormat } from './options';
import type { CLIOptions, PromptOutputFormat } from './options';
Expand Down Expand Up @@ -296,6 +297,9 @@ async function resolvePromptSession(
stderr: PromptOutput,
setRestorePermission: (restorePermission: () => Promise<void>) => void,
): Promise<ResolvedPromptSession> {
// `--agent`/`--agent-file` are creation-only: validateOptions rejects them
// together with --session/--continue, so resume paths never forward a
// profile — the bound agent is restored from the session itself.
if (opts.session !== undefined) {
const sessions = await harness.listSessions({ sessionId: opts.session, workDir });
const target = sessions[0];
Expand Down Expand Up @@ -365,12 +369,15 @@ async function resolvePromptSession(
stderr.write(`No sessions to continue under "${workDir}"; starting a fresh session.\n`);
}

const agentProfile = await resolveAgentProfileSelection(opts, workDir);
const model = requireConfiguredModel(opts.model, defaultModel);
const session = await harness.createSession({
workDir,
model,
permission: 'auto',
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
agentProfile,
agentFiles: opts.agentFiles?.length ? opts.agentFiles : undefined,
drainAgentTasksOnStop: true,
});
installHeadlessHandlers(session);
Expand Down
5 changes: 5 additions & 0 deletions apps/kimi-code/src/cli/run-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { toTerminalHyperlink } from '#/utils/terminal-hyperlink';
import { restoreTerminalModes } from '#/utils/terminal-restore';

import type { CLIOptions } from './options';
import { resolveAgentProfileSelection } from './agent-selection';
import { isKimiV2Enabled } from './experimental-v2';
import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry';
import { createKimiCodeHostIdentity } from './version';
Expand Down Expand Up @@ -110,8 +111,12 @@ export async function runShell(
configWarning = combineStartupNotice(configWarning, warning);
}
const configMs = Date.now() - configStartedAt;
// Resolve --agent/--agent-file once for the startup session; validateOptions
// has already rejected them alongside --session/--continue.
const agentProfile = await resolveAgentProfileSelection(opts, workDir);
const tui = new KimiTUI(harness, {
cliOptions: opts,
agentProfile,
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
tuiConfig,
version,
Expand Down
27 changes: 7 additions & 20 deletions apps/kimi-code/src/cli/v2/run-v2-print.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,27 +293,14 @@ async function resolveNativeSession(
}
}

// `--agent` / `--agent-file` bind an explicit profile; without them the
// historical setModel path (default profile on first bind) is kept. A
// same-name re-select on a resumed session keeps the profile and only applies
// an explicitly requested model; a different name is rejected by the
// engine's first-bind guard inside `bind`.
const applyProfileSelection = async (
// `--agent` / `--agent-file` are creation-only: validateOptions rejects them
// together with --session/--continue, so resume paths only apply an
// explicitly requested model — the bound profile is restored by the engine.
const applyModelOverride = async (
profile: IAgentProfileService,
model: string | undefined,
): Promise<void> => {
if (agentProfileName !== undefined) {
if (profile.data().profileName === agentProfileName) {
if (model !== undefined) await profile.setModel(model);
return;
}
await profile.bind({
profile: agentProfileName,
model: requireConfiguredModel(model ?? profile.getModel(), defaultModel),
});
} else if (model !== undefined) {
await profile.setModel(model);
}
if (model !== undefined) await profile.setModel(model);
};

const resumeById = async (id: string): Promise<ISessionScopeHandle> => {
Expand Down Expand Up @@ -353,7 +340,7 @@ async function resolveNativeSession(
const session = await resumeById(opts.session);
const agent = await ensureMainAgent(session);
const profile = agent.accessor.get(IAgentProfileService);
await applyProfileSelection(profile, opts.model);
await applyModelOverride(profile, opts.model);
const currentModel = profile.getModel();
const { restorePermission } = forceAuto(agent);
return {
Expand All @@ -372,7 +359,7 @@ async function resolveNativeSession(
const session = await resumeById(previous.id);
const agent = await ensureMainAgent(session);
const profile = agent.accessor.get(IAgentProfileService);
await applyProfileSelection(profile, opts.model);
await applyModelOverride(profile, opts.model);
const currentModel = profile.getModel();
const { restorePermission } = forceAuto(agent);
return {
Expand Down
135 changes: 129 additions & 6 deletions apps/kimi-code/src/tui/commands/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
effectiveModelAlias,
SECONDARY_DERIVED_MODEL_ALIAS,
type ExperimentalFeatureState,
type KimiConfig,
type ModelAlias,
type PermissionMode,
type Session,
Expand Down Expand Up @@ -250,6 +252,25 @@ export async function handleModelCommand(host: SlashCommandHost, args: string):
showModelPicker(host, alias);
}

export async function handleSecondaryModelCommand(host: SlashCommandHost, args: string): Promise<void> {
const alias = args.trim();
await refreshModelsForPicker(host);
const models = pickerModelsForHost(host);
if (Object.keys(models).length === 0) {
host.showNotice(
'No models configured',
'Run /login to sign in to Kimi, or /provider to add another provider from a model catalog.',
);
return;
}
if (alias.length > 0 && models[alias] === undefined) {
host.showError(`Unknown model alias: ${alias}`);
return;
}
const secondary = (await host.harness.getConfig()).secondaryModel;
showSecondaryModelPicker(host, models, secondary?.model ?? '', secondary?.defaultEffort, alias);
}

export async function handleEffortCommand(host: SlashCommandHost, args: string): Promise<void> {
const alias = host.state.appState.model;
const model = host.state.appState.availableModels[alias];
Expand Down Expand Up @@ -390,13 +411,22 @@ async function applyEditorChoice(host: SlashCommandHost, value: string): Promise
);
}

export function showModelPicker(host: SlashCommandHost, selectedValue: string = host.state.appState.model): void {
const models = Object.fromEntries(
Object.entries(host.state.appState.availableModels).map(([alias, model]) => [
alias,
effectiveModelForHost(host, model),
]),
/**
* The models a picker may offer: the user's configured aliases with
* host-effective provider resolution applied, minus the synthesized
* `__secondary__` derived entry — a runtime artifact of the `[secondary_model]`
* recipe that must never be selectable as a primary or secondary model.
*/
function pickerModelsForHost(host: SlashCommandHost): Record<string, ModelAlias> {
return Object.fromEntries(
Object.entries(host.state.appState.availableModels)
.filter(([alias]) => alias !== SECONDARY_DERIVED_MODEL_ALIAS)
.map(([alias, model]) => [alias, effectiveModelForHost(host, model)]),
);
}

export function showModelPicker(host: SlashCommandHost, selectedValue: string = host.state.appState.model): void {
const models = pickerModelsForHost(host);
const entries = Object.entries(models);
if (entries.length === 0) {
host.showNotice(
Expand Down Expand Up @@ -553,6 +583,99 @@ async function persistModelSelection(
return true;
}

// ---------------------------------------------------------------------------
// Secondary model (`/secondary_model`)
// ---------------------------------------------------------------------------

function showSecondaryModelPicker(
host: SlashCommandHost,
models: Record<string, ModelAlias>,
currentValue: string,
currentEffort: string | undefined,
selectedValue?: string,
): void {
host.mountEditorReplacement(
new TabbedModelSelectorComponent({
models,
currentValue,
selectedValue,
currentThinkingEffort: currentEffort ?? 'off',
title: ' Select a secondary model (subagents)',
onSelect: ({ alias, thinking }) => {
host.restoreEditor();
void performSecondaryModelSwitch(host, alias, thinking);
},
onCancel: () => {
host.restoreEditor();
},
}),
);
}

/**
* Persist-first, then live-apply: the synthesized derived entry only exists in
* the core config after a reload. No session-only variant — a session-local
* recipe with patch fields would bind a derived alias the core config cannot
* resolve.
*/
async function performSecondaryModelSwitch(
host: SlashCommandHost,
alias: string,
effort: ThinkingEffort,
): Promise<void> {
const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]);
let updatedConfig: KimiConfig;
try {
updatedConfig = await host.harness.setConfig({
secondaryModel: { model: alias, defaultEffort: effort },
});
} catch (error) {
host.showError(`Failed to save secondary model: ${formatErrorMessage(error)}`);
return;
}
if (host.session !== undefined) {
try {
await host.session.applyPersistedSecondaryModel();
} catch (error) {
host.showError(
`Saved ${displayName} as the secondary model, but failed to apply it to this session: ${formatErrorMessage(error)}`,
);
return;
}
}
host.setAppState({ availableModels: updatedConfig.models ?? {} });
// Report the effective binding from the reloaded config, not the picked
// value: KIMI_SECONDARY_MODEL / KIMI_SECONDARY_EFFORT override the recipe at
// runtime, and the session binds the overlaid snapshot (mirrors how
// /model displays the effective alias read back from the session).
const effective = updatedConfig.secondaryModel;
const envOverrides: string[] = [];
if (effective?.model !== undefined && effective.model !== alias) {
envOverrides.push(`KIMI_SECONDARY_MODEL=${effective.model}`);
}
if (effective?.defaultEffort !== undefined && effective.defaultEffort !== effort) {
envOverrides.push(`KIMI_SECONDARY_EFFORT=${effective.defaultEffort}`);
}
if (envOverrides.length > 0 && effective?.model !== undefined) {
const effectiveName = modelDisplayName(
effective.model,
updatedConfig.models?.[effective.model],
);
host.showStatus(
`Saved ${displayName} as the secondary model, but ${envOverrides.join(' and ')} ` +
`overrides it at runtime — subagents bind ${effectiveName} until the env var is unset.`,
'warning',
);
return;
}
host.showStatus(
host.session === undefined
? `Secondary model set to ${displayName} with thinking ${effort}; applies to new sessions.`
: `Secondary model set to ${displayName} with thinking ${effort}.`,
'success',
);
}

function showThemePicker(host: SlashCommandHost): void {
host.mountEditorReplacement(
new ThemeSelectorComponent({
Expand Down
5 changes: 5 additions & 0 deletions apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
handleEffortCommand,
handleModelCommand,
handlePlanCommand,
handleSecondaryModelCommand,
handleThemeCommand,
handleYoloCommand,
showExperimentsPanel,
Expand Down Expand Up @@ -71,6 +72,7 @@ export {
handleEffortCommand,
handleModelCommand,
handlePlanCommand,
handleSecondaryModelCommand,
handleThemeCommand,
handleYoloCommand,
showModelPicker,
Expand Down Expand Up @@ -303,6 +305,9 @@ async function handleBuiltInSlashCommand(
case 'model':
await handleModelCommand(host, args);
return;
case 'secondary_model':
await handleSecondaryModelCommand(host, args);
return;
case 'effort':
await handleEffortCommand(host, args);
return;
Expand Down
Loading
Loading