From efac96c8a95a3c3ca4e1ae9bce38082498a02b2e Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Wed, 29 Jul 2026 12:06:26 +0800 Subject: [PATCH] feat(agent-core): custom agent files and secondary model on the v1 engine (#2232) * feat(agent-core): custom agent files and secondary model on the v1 engine Migrate the custom agentfile and secondary-model capabilities from agent-core-v2 to the v1 engine so they work in the TUI and plain kimi -p sessions: - discover Markdown agent files from user/project/extra/explicit directories with the v2 precedence rules, a merged session profile catalog replacing the hardcoded builtin profile lookups, SYSTEM.md main prompt override, and ${base_prompt} backed by the effective default - --agent/--agent-file now work in print mode on the default engine; CreateSessionOptions gains agentProfile/agentFiles - [secondary_model] config + KIMI_SECONDARY_MODEL/EFFORT bind newly spawned subagents to a cheaper model behind the secondary-model experiment flag, with primary/secondary model params on Agent and AgentSwarm and upfront session warnings - full disallowedTools deny semantics (exact names + mcp__ globs) evaluated by the tool manager and persisted in the agent wire * fix(cli): guard optional agentFiles in the prompt runner runPrompt is also driven programmatically (headless goal flow) with options that never pass through the CLI parser defaults, so agentFiles can be undefined; mirror the addDirs optional-chaining pattern. Also extend the SDK experimental-feature assertion with the secondary-model flag. * fix(agent-core): preserve custom agent bindings on v1 * fix(agent-core): narrow secondary model error hints * fix(agent-core): persist custom agent profile bindings * Delete .changeset/sdk-agent-profile-options.md Signed-off-by: 7Sageer * Update v1-custom-agent-files.md Signed-off-by: 7Sageer * Update v1-secondary-model.md Signed-off-by: 7Sageer * Update v1-custom-agent-files.md Signed-off-by: 7Sageer * fix(agent-core): keep SYSTEM.md a prompt-only overlay for delegation * docs: update agent file and secondary model availability wording * fix(cli): reject --agent-file combined with session resume The resume path only forwards the agent file's name for the bound-profile assertion; the file's content is never re-applied (the session keeps its creation-time catalog snapshot). Previously the combination was silently accepted, so an edited file (or a same-named one) appeared to apply but did not. Reject it at option validation and document the constraint. * refactor(agent-core): share prompt-section prose and note v2 twins in agentfile headers The Windows notes, additional-dirs and skills prose blocks existed twice: inline in the builtin default template (system.md) and as constants in the agent-file renderer (from-file.ts). Extract them to profile/prompt-sections.ts as the single source: system.md renders them through injected KIMI_* template variables and from-file.ts imports the same constants. Rendered prompts are byte-identical for all four builtin profiles across macOS/Windows and skills/dirs on/off; a new test pins system.md to the shared constants. Also mark each profile/agentfile file with the path of its agent-core-v2 counterpart so format/semantics changes land in both engines. * feat(cli): add /secondary_model command for the subagent model Mirror /model: a picker with a thinking-effort step that persists [secondary_model] and live-applies to the current session via a new Session.setSecondaryModel RPC (node-sdk wrapper included), so newly spawned subagents bind the new model right away. The /model picker now hides the synthesized __secondary__ derived entry; docs and the update-config builtin skill mention the section. * feat(tui): show the bound model in subagent run stats Subagents report their model alias via agent.status.updated after spawn; resolve it to a display name and surface it in tool-call subagent stats and agent-group rows. * fix(agent-core): validate agent profile before session persistence * fix(agent-core): refresh subagent tools after model switch * fix(agent-core): show subagent model preferences * fix(agent-core): preserve secondary model recipe on live apply * fix(agent-core): make secondary model apply explicit * fix(tui): refresh secondary model display state * chore: merge secondary model changesets into one * Add /secondary_model command for subagent configuration Show each subagent's model in the subagent card header and agent-group rows. Requires the secondary-model experiment (KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1); run /secondary_model to pick a model and thinking effort, applied to the current session immediately. Signed-off-by: 7Sageer * fix(agent-core): align explicit agent file precedence * fix(agent-core): let disallowedTools deny select_tools * chore(cli): drop engine mention from --agent/--agent-file help text * feat(cli): support --agent/--agent-file in the interactive TUI Bind the selected agent profile to the startup session when launching the TUI with --agent/--agent-file, including the session created after an OAuth login at startup. Sessions created later in the process (/new) keep the default profile. Make both flags creation-only in every mode: combining them with --session/--continue is now rejected in print mode too, since resume restores the bound agent from the session automatically. * fix(agent-core): persist new secondary-model selections under env overrides stripSecondaryModelConfig restored secondary_model.model/default_effort from raw whenever KIMI_SECONDARY_MODEL/KIMI_SECONDARY_EFFORT was set, so a /secondary_model pick made under the env vars was silently discarded on write. Restore from raw only when the value being written still equals the env value (an overlay round-trip), mirroring the pointer check in stripEnvModelConfig; a genuinely different selection now reaches config.toml. * fix(cli): report the effective secondary model when env overrides the pick /secondary_model toasted the picked alias even when KIMI_SECONDARY_MODEL/KIMI_SECONDARY_EFFORT made the session bind a different model. Read the effective binding back from the reloaded config (as /model does from session status) and warn with the env-overridden values instead. * feat(tui): show the bound model name in the AgentSwarm panel header --------- Signed-off-by: 7Sageer --- .changeset/v1-custom-agent-files.md | 5 + .changeset/v1-secondary-model.md | 5 + apps/kimi-code/src/cli/agent-selection.ts | 42 + apps/kimi-code/src/cli/commands.ts | 4 +- apps/kimi-code/src/cli/options.ts | 6 +- apps/kimi-code/src/cli/run-prompt.ts | 7 + apps/kimi-code/src/cli/run-shell.ts | 5 + apps/kimi-code/src/cli/v2/run-v2-print.ts | 27 +- apps/kimi-code/src/tui/commands/config.ts | 135 +++- apps/kimi-code/src/tui/commands/dispatch.ts | 5 + apps/kimi-code/src/tui/commands/registry.ts | 8 + .../tui/components/dialogs/model-selector.ts | 4 +- .../dialogs/tabbed-model-selector.ts | 4 + .../tui/components/messages/agent-group.ts | 4 +- .../messages/agent-swarm-progress.ts | 17 +- .../src/tui/components/messages/tool-call.ts | 15 +- .../src/tui/controllers/auth-flow.ts | 6 + .../tui/controllers/subagent-event-handler.ts | 18 + apps/kimi-code/src/tui/kimi-tui.ts | 8 + apps/kimi-code/src/tui/types.ts | 4 + apps/kimi-code/test/cli/options.test.ts | 63 +- apps/kimi-code/test/cli/run-prompt.test.ts | 59 ++ apps/kimi-code/test/cli/run-shell.test.ts | 29 + .../test/tui/commands/registry.test.ts | 8 + .../test/tui/commands/secondary-model.test.ts | 230 ++++++ .../dialogs/tabbed-model-selector.test.ts | 16 + .../components/messages/agent-group.test.ts | 20 + .../messages/agent-swarm-progress.test.ts | 24 + .../tui/components/messages/tool-call.test.ts | 28 + .../test/tui/kimi-tui-startup.test.ts | 55 ++ docs/en/configuration/config-files.md | 4 +- docs/en/configuration/env-vars.md | 6 +- docs/en/customization/agents.md | 15 +- docs/en/reference/kimi-command.md | 11 +- docs/en/reference/slash-commands.md | 1 + docs/en/reference/tools.md | 4 +- docs/zh/configuration/config-files.md | 4 +- docs/zh/configuration/env-vars.md | 6 +- docs/zh/customization/agents.md | 15 +- docs/zh/reference/kimi-command.md | 11 +- docs/zh/reference/slash-commands.md | 1 + docs/zh/reference/tools.md | 4 +- .../app/skillCatalog/builtin/update-config.md | 2 +- packages/agent-core/src/agent/config/index.ts | 9 + packages/agent-core/src/agent/config/types.ts | 2 + packages/agent-core/src/agent/index.ts | 24 +- .../agent-core/src/agent/records/index.ts | 2 +- .../agent-core/src/agent/records/types.ts | 6 + packages/agent-core/src/agent/tool/index.ts | 75 +- packages/agent-core/src/config/index.ts | 1 + packages/agent-core/src/config/schema.ts | 18 + .../agent-core/src/config/secondary-model.ts | 154 ++++ packages/agent-core/src/config/toml.ts | 34 +- packages/agent-core/src/flags/registry.ts | 9 + packages/agent-core/src/index.ts | 1 + .../src/profile/agentfile/catalog.ts | 403 ++++++++++ .../src/profile/agentfile/discovery.ts | 128 +++ .../src/profile/agentfile/from-file.ts | 131 +++ .../agent-core/src/profile/agentfile/index.ts | 9 + .../src/profile/agentfile/parser.ts | 192 +++++ .../agent-core/src/profile/agentfile/paths.ts | 59 ++ .../agent-core/src/profile/agentfile/roots.ts | 111 +++ .../src/profile/agentfile/system-file.ts | 87 ++ .../agent-core/src/profile/agentfile/types.ts | 69 ++ .../src/profile/agentfile/validate.ts | 62 ++ .../agent-core/src/profile/default/system.md | 12 +- packages/agent-core/src/profile/index.ts | 1 + .../agent-core/src/profile/prompt-sections.ts | 26 + packages/agent-core/src/profile/resolve.ts | 14 + packages/agent-core/src/profile/types.ts | 17 + packages/agent-core/src/rpc/core-api.ts | 7 + packages/agent-core/src/rpc/core-impl.ts | 47 ++ packages/agent-core/src/session/index.ts | 244 +++++- .../src/session/main-agent-profile.ts | 19 + .../src/session/provider-manager.ts | 1 + .../agent-core/src/session/subagent-batch.ts | 3 + .../src/session/subagent-binding.ts | 118 +++ .../agent-core/src/session/subagent-host.ts | 107 ++- .../src/skill/builtin/update-config.md | 2 +- .../builtin/collaboration/agent-swarm.ts | 17 +- .../src/tools/builtin/collaboration/agent.ts | 45 +- .../test/agent/records/index.test.ts | 20 + .../test/agent/tool-select.e2e.test.ts | 26 + packages/agent-core/test/agent/tool.test.ts | 146 +++- packages/agent-core/test/agent/turn.test.ts | 9 +- .../test/config/secondary-model.test.ts | 247 ++++++ .../agent-core/test/harness/runtime.test.ts | 39 + .../test/mcp/tool-manager-mcp.test.ts | 50 ++ .../agent-core/test/profile/agentfile.test.ts | 755 ++++++++++++++++++ .../profile/default-agent-profiles.test.ts | 19 + packages/agent-core/test/session/init.test.ts | 167 ++++ .../test/session/subagent-host.test.ts | 265 +++++- packages/agent-core/test/tools/agent.test.ts | 68 +- .../test/tools/builtin-current.test.ts | 1 + packages/node-sdk/src/index.ts | 4 + packages/node-sdk/src/kimi-harness.ts | 2 + packages/node-sdk/src/rpc.ts | 5 + packages/node-sdk/src/session.ts | 12 + packages/node-sdk/src/types.ts | 12 + packages/node-sdk/test/config.test.ts | 11 + .../test/create-session-transport.test.ts | 166 +++- 101 files changed, 5069 insertions(+), 166 deletions(-) create mode 100644 .changeset/v1-custom-agent-files.md create mode 100644 .changeset/v1-secondary-model.md create mode 100644 apps/kimi-code/src/cli/agent-selection.ts create mode 100644 apps/kimi-code/test/tui/commands/secondary-model.test.ts create mode 100644 packages/agent-core/src/config/secondary-model.ts create mode 100644 packages/agent-core/src/profile/agentfile/catalog.ts create mode 100644 packages/agent-core/src/profile/agentfile/discovery.ts create mode 100644 packages/agent-core/src/profile/agentfile/from-file.ts create mode 100644 packages/agent-core/src/profile/agentfile/index.ts create mode 100644 packages/agent-core/src/profile/agentfile/parser.ts create mode 100644 packages/agent-core/src/profile/agentfile/paths.ts create mode 100644 packages/agent-core/src/profile/agentfile/roots.ts create mode 100644 packages/agent-core/src/profile/agentfile/system-file.ts create mode 100644 packages/agent-core/src/profile/agentfile/types.ts create mode 100644 packages/agent-core/src/profile/agentfile/validate.ts create mode 100644 packages/agent-core/src/profile/prompt-sections.ts create mode 100644 packages/agent-core/src/session/main-agent-profile.ts create mode 100644 packages/agent-core/src/session/subagent-binding.ts create mode 100644 packages/agent-core/test/config/secondary-model.test.ts create mode 100644 packages/agent-core/test/profile/agentfile.test.ts diff --git a/.changeset/v1-custom-agent-files.md b/.changeset/v1-custom-agent-files.md new file mode 100644 index 000000000..94b79e89a --- /dev/null +++ b/.changeset/v1-custom-agent-files.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Support Markdown-defined custom agents on agent-core. diff --git a/.changeset/v1-secondary-model.md b/.changeset/v1-secondary-model.md new file mode 100644 index 000000000..181ba6978 --- /dev/null +++ b/.changeset/v1-secondary-model.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the /secondary_model slash command to configure the secondary model used by subagents. diff --git a/apps/kimi-code/src/cli/agent-selection.ts b/apps/kimi-code/src/cli/agent-selection.ts new file mode 100644 index 000000000..0ffb53d59 --- /dev/null +++ b/apps/kimi-code/src/cli/agent-selection.ts @@ -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, + workDir: string, +): Promise { + 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 }, + ); + } +} diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 6ed555193..a090df4d0 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -77,7 +77,7 @@ export function createProgram( .addOption( new Option( '--agent ', - '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) { @@ -90,7 +90,7 @@ export function createProgram( .addOption( new Option( '--agent-file ', - '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) { diff --git a/apps/kimi-code/src/cli/options.ts b/apps/kimi-code/src/cli/options.ts index 6e422c3e2..004fd7cab 100644 --- a/apps/kimi-code/src/cli/options.ts +++ b/apps/kimi-code/src/cli/options.ts @@ -1,5 +1,3 @@ -import { isKimiV2Enabled } from './experimental-v2'; - export type UIMode = 'shell' | 'print'; export type PromptOutputFormat = 'text' | 'stream-json'; @@ -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 === '') { diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index abee29962..fd795e838 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -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'; @@ -296,6 +297,9 @@ async function resolvePromptSession( stderr: PromptOutput, setRestorePermission: (restorePermission: () => Promise) => void, ): Promise { + // `--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]; @@ -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); diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 3497c4398..220aa749f 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -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'; @@ -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, diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index ff0c96c59..0bf117b03 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -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 => { - 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 => { @@ -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 { @@ -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 { diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 58f1c27ac..2422dc564 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -1,6 +1,8 @@ import { effectiveModelAlias, + SECONDARY_DERIVED_MODEL_ALIAS, type ExperimentalFeatureState, + type KimiConfig, type ModelAlias, type PermissionMode, type Session, @@ -250,6 +252,25 @@ export async function handleModelCommand(host: SlashCommandHost, args: string): showModelPicker(host, alias); } +export async function handleSecondaryModelCommand(host: SlashCommandHost, args: string): Promise { + 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 { const alias = host.state.appState.model; const model = host.state.appState.availableModels[alias]; @@ -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 { + 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( @@ -553,6 +583,99 @@ async function persistModelSelection( return true; } +// --------------------------------------------------------------------------- +// Secondary model (`/secondary_model`) +// --------------------------------------------------------------------------- + +function showSecondaryModelPicker( + host: SlashCommandHost, + models: Record, + 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 { + 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({ diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index dcfb90473..b2feffaeb 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -29,6 +29,7 @@ import { handleEffortCommand, handleModelCommand, handlePlanCommand, + handleSecondaryModelCommand, handleThemeCommand, handleYoloCommand, showExperimentsPanel, @@ -71,6 +72,7 @@ export { handleEffortCommand, handleModelCommand, handlePlanCommand, + handleSecondaryModelCommand, handleThemeCommand, handleYoloCommand, showModelPicker, @@ -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; diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 063bcd7bf..2cea40491 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -184,6 +184,14 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 100, availability: 'always', }, + { + name: 'secondary_model', + aliases: [], + description: 'Configure the secondary model for subagents', + priority: 90, + availability: 'always', + experimentalFlag: 'secondary-model', + }, { name: 'effort', aliases: ['thinking'], diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index 64646e022..64537df22 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -67,6 +67,8 @@ export interface ModelSelectorOptions { /** Live thinking effort of the currently active model (e.g. 'off', 'on', * 'high'). Used to highlight the active segment for the current model. */ readonly currentThinkingEffort: ThinkingEffort; + /** Overrides the default ' Select a model' title line. */ + readonly title?: string; /** When true, typed characters filter the list (fuzzy) and a search line is shown. */ readonly searchable?: boolean; /** Items per page. Lists longer than this paginate (PgUp/PgDn). */ @@ -289,7 +291,7 @@ export class ModelSelectorComponent extends Container implements Focusable { const lines: string[] = [ currentTheme.fg('primary', '─'.repeat(width)), - currentTheme.boldFg('primary', ' Select a model') + titleSuffix, + currentTheme.boldFg('primary', this.opts.title ?? ' Select a model') + titleSuffix, currentTheme.fg('textMuted', ' ' + hintParts.join(' · ')), ]; if (this.opts.warning !== undefined) { diff --git a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts index 8adc5efa1..d94de3b06 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts @@ -40,6 +40,9 @@ export interface TabbedModelSelectorOptions { readonly currentValue: string; readonly selectedValue?: string; readonly currentThinkingEffort: string; + /** Forwarded to each inner selector; overrides the default ' Select a model' + * title line (e.g. the secondary-model picker). */ + readonly title?: string; /** When set, the tab for this provider id is initially active instead of the * tab derived from `currentValue`. */ readonly initialTabId?: string; @@ -180,6 +183,7 @@ function makeSelector( currentValue: opts.currentValue, ...(selectedValue !== undefined ? { selectedValue } : {}), currentThinkingEffort: opts.currentThinkingEffort, + title: opts.title, searchable: true, providerSwitchHint: true, warning: opts.warning, diff --git a/apps/kimi-code/src/tui/components/messages/agent-group.ts b/apps/kimi-code/src/tui/components/messages/agent-group.ts index 7e4752945..1fe4961e9 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-group.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-group.ts @@ -301,7 +301,9 @@ function formatBreakdownParts(counts: PhaseCounts): string[] { } function formatStats(snap: ToolCallSubagentSnapshot): string { - const parts = [`${String(snap.toolCount)} tool${snap.toolCount === 1 ? '' : 's'}`]; + const parts: string[] = []; + if (snap.model !== undefined) parts.push(snap.model); + parts.push(`${String(snap.toolCount)} tool${snap.toolCount === 1 ? '' : 's'}`); if (snap.elapsedSeconds !== undefined) parts.push(formatElapsed(snap.elapsedSeconds)); if (snap.tokens > 0) parts.push(formatTokens(snap.tokens)); return currentTheme.dim(` · ${parts.join(' · ')}`); diff --git a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts index 75c7266a2..ec2e49962 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -190,6 +190,7 @@ export class AgentSwarmProgressComponent implements Component { private description: string; private readonly requestRender: (() => void) | undefined; private readonly availableGridHeight: (() => number | undefined) | undefined; + private modelDisplay = ''; private inputComplete = false; private failed = false; private aborted = false; @@ -224,6 +225,16 @@ export class AgentSwarmProgressComponent implements Component { this.activitySpinnerText = provider; } + /** + * Show the bound model once in the header. Every swarm member binds to the + * same model, so the first child status update wins and later ones (e.g. + * from resumed agents that kept a different binding) do not churn it. + */ + setModelDisplay(modelDisplay: string): void { + if (this.modelDisplay.length > 0 || modelDisplay.length === 0) return; + this.modelDisplay = modelDisplay; + } + markToolCallEnded(): void { this.toolCallActive = false; this.activitySpinnerText = undefined; @@ -481,9 +492,13 @@ export class AgentSwarmProgressComponent implements Component { this.description.length > 0 ? chalk.hex(this.colors.primary)(' ─ ') + chalk.hex(this.colors.text)(this.description) : ''; + const model = + this.modelDisplay.length > 0 + ? chalk.hex(this.colors.primary)(' ─ ') + chalk.hex(this.colors.textDim)(this.modelDisplay) + : ''; const prefixText = '─ '; const labelWidth = Math.max(1, width - visibleWidth(prefixText) - 1); - const label = truncateToWidth(title + description, labelWidth); + const label = truncateToWidth(title + description + model, labelWidth); const suffixWidth = Math.max(0, width - visibleWidth(prefixText) - visibleWidth(label)); const suffix = suffixWidth === 0 ? '' : ` ${'─'.repeat(Math.max(0, suffixWidth - 1))}`; return chalk.hex(this.colors.primary)(prefixText) + label + chalk.hex(this.colors.primary)(suffix); diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 01f76053a..a669f6299 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -93,6 +93,8 @@ export interface ToolCallSubagentSnapshot { readonly toolName: string; readonly toolCallDescription: string; readonly agentName: string | undefined; + /** Display name of the model the subagent is bound to, when known (live only). */ + readonly model?: string; readonly phase: SubagentPhase | undefined; readonly toolCount: number; readonly elapsedSeconds: number | undefined; @@ -595,6 +597,8 @@ export class ToolCallComponent extends Container { private backgroundTaskTerminalPhase: 'done' | 'failed' | undefined; private subagentContextTokens: number | undefined; private subagentUsage: TokenUsage | undefined; + /** Display name of the model the subagent is bound to (from its `agent.status.updated`). */ + private subagentModel: string | undefined; private subagentResultSummary: string | undefined; private subagentError: string | undefined; private streamingProgressTimer: ReturnType | undefined; @@ -898,6 +902,7 @@ export class ToolCallComponent extends Container { toolName: this.toolCall.name, toolCallDescription: str(this.toolCall.args['description']) || str(this.toolCall.description), agentName: this.subagentAgentName, + model: this.subagentModel, phase: derivedPhase, toolCount: finished, elapsedSeconds: this.getSubagentElapsedSeconds(), @@ -1162,6 +1167,7 @@ export class ToolCallComponent extends Container { updateSubagentMetrics(payload: { contextTokens?: number | undefined; usage?: TokenUsage | undefined; + modelDisplay?: string | undefined; }): void { if (payload.contextTokens !== undefined && payload.contextTokens > 0) { this.subagentContextTokens = payload.contextTokens; @@ -1169,6 +1175,9 @@ export class ToolCallComponent extends Container { if (payload.usage !== undefined) { this.subagentUsage = payload.usage; } + if (payload.modelDisplay !== undefined) { + this.subagentModel = payload.modelDisplay; + } this.headerText.setText(this.buildHeader()); this.invalidate(); this.notifySnapshotChange(); @@ -1784,9 +1793,9 @@ export class ToolCallComponent extends Container { } private formatSingleSubagentStatsText(): string { - const parts = [ - `${String(this.subToolActivities.size)} tool${this.subToolActivities.size === 1 ? '' : 's'}`, - ]; + const parts: string[] = []; + if (this.subagentModel !== undefined) parts.push(this.subagentModel); + parts.push(`${String(this.subToolActivities.size)} tool${this.subToolActivities.size === 1 ? '' : 's'}`); const elapsed = this.getSubagentElapsedSeconds(); if (elapsed !== undefined) parts.push(formatElapsed(elapsed)); const tokens = diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index a7acc77ce..bf8699ff2 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -85,6 +85,12 @@ export class AuthFlowController { ? 'yolo' : undefined, planMode: host.state.appState.planMode ? true : undefined, + // The post-login session is still the startup session: carry the + // --agent/--agent-file binding resolved at launch. + agentProfile: host.options.startup.agentProfile, + agentFiles: host.options.startup.agentFiles?.length + ? [...host.options.startup.agentFiles] + : undefined, }; if (host.state.appState.additionalDirs.length > 0) { options.additionalDirs = [...host.state.appState.additionalDirs]; diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index f2281ea54..4368ea541 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -9,6 +9,7 @@ import { agentSwarmDescriptionFromArgs, agentSwarmGridHeightForTerminalRows, } from '../components/messages/agent-swarm-progress'; +import { modelDisplayName } from '../components/dialogs/model-selector'; import { MAIN_AGENT_ID } from '../constant/kimi-tui'; import type { BackgroundAgentMetadata, @@ -125,6 +126,14 @@ export class SubAgentEventHandler { toolCall.updateSubagentMetrics({ contextTokens: event.contextTokens, usage: totalUsage, + // The bound model alias rides every child status update (emitted right + // after spawn); surface it on the subagent card. `modelDisplayName` + // falls back to the alias itself when the entry is unknown (e.g. the + // synthesized `__secondary__` derived entry is missing). + modelDisplay: + event.model === undefined + ? undefined + : modelDisplayName(event.model, this.host.state.appState.availableModels[event.model]), }); } return true; @@ -502,6 +511,15 @@ export class SubAgentEventHandler { progress.appendModelDelta({ agentId: subagentId, delta: event.delta }); } else if (event.type === 'tool.call.started') { progress.recordToolCall({ agentId: subagentId, toolCallId: event.toolCallId }); + } else if (event.type === 'agent.status.updated' && event.model !== undefined) { + // The bound model alias rides every child status update (emitted right + // after spawn). Swarm members share one binding, so the panel shows it + // once in the header instead of per cell. `modelDisplayName` falls back + // to the alias itself when the entry is unknown (e.g. the synthesized + // `__secondary__` derived entry is missing). + progress.setModelDisplay( + modelDisplayName(event.model, this.host.state.appState.availableModels[event.model]), + ); } } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index cb931c5a3..02cf0494b 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -174,6 +174,8 @@ export type { export interface KimiTUIStartupInput { readonly cliOptions: CLIOptions; + /** Profile name resolved from cliOptions --agent/--agent-file (see resolveAgentProfileSelection). */ + readonly agentProfile?: string; readonly additionalDirs?: readonly string[]; readonly tuiConfig: TuiConfig; readonly version: string; @@ -385,6 +387,8 @@ export class KimiTUI { auto: startupInput.cliOptions.auto, plan: startupInput.cliOptions.plan, model: startupInput.cliOptions.model, + agentProfile: startupInput.agentProfile, + agentFiles: startupInput.cliOptions.agentFiles, startupNotice: startupInput.startupNotice, }, }; @@ -744,6 +748,10 @@ export class KimiTUI { model: startup.model, permission: startup.auto ? 'auto' : startup.yolo ? 'yolo' : undefined, planMode: startup.plan ? true : undefined, + // --agent/--agent-file bind the startup session only; sessions created + // later in this process fall back to the default profile. + agentProfile: startup.agentProfile, + agentFiles: startup.agentFiles?.length ? [...startup.agentFiles] : undefined, }; if (this.state.appState.additionalDirs.length > 0) { createSessionOptions.additionalDirs = [...this.state.appState.additionalDirs]; diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 34ea098ef..29cc57bff 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -244,6 +244,10 @@ export interface TUIStartupOptions { readonly auto: boolean; readonly plan: boolean; readonly model?: string; + /** Resolved profile name from --agent/--agent-file; bound to the startup session only. */ + readonly agentProfile?: string; + /** Raw --agent-file paths, passed to session creation alongside `agentProfile`. */ + readonly agentFiles?: readonly string[]; readonly startupNotice?: string; } diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 8bfe8561e..2adb0c217 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -1,3 +1,10 @@ +/** + * Scenario: top-level CLI option parsing, validation, and help discovery. + * Responsibilities: accepted arguments map to CLIOptions and invalid combinations fail early. + * Wiring: Commander is real; command handlers and output sinks are local test boundaries. + * Run: pnpm -C apps/kimi-code exec vitest run test/cli/options.test.ts + */ + import { describe, expect, it } from 'vitest'; import { createProgram } from '#/cli/commands'; @@ -393,6 +400,14 @@ describe('CLI options parsing', () => { }); describe('--agent / --agent-file', () => { + it('describes agent selectors as new-session-only', () => { + const help = createProgram('0.1.0-test', () => {}, () => {}).helpInformation(); + const normalizedHelp = help.replaceAll(/\s+/g, ' '); + + expect(normalizedHelp).toContain('Agent profile to start the new session with.'); + expect(normalizedHelp).not.toContain('print-mode invocation'); + }); + it('parses a single --agent', () => { const opts = parse(['-p', 'hi', '--agent', 'reviewer']); expect(opts.agent).toBe('reviewer'); @@ -437,6 +452,38 @@ describe('CLI options parsing', () => { ); }); + it('rejects --agent-file with --session', () => { + const opts = parse(['-p', 'hi', '--agent-file', 'a.md', '--session', 'ses_123']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow( + 'Cannot combine --agent/--agent-file with --session/--continue', + ); + }); + + it('rejects --agent-file with --continue', () => { + const opts = parse(['-p', 'hi', '--agent-file', 'a.md', '--continue']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow( + 'Cannot combine --agent/--agent-file with --session/--continue', + ); + }); + + it('rejects --agent with --session', () => { + const opts = parse(['-p', 'hi', '--agent', 'reviewer', '--session', 'ses_123']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow( + 'Cannot combine --agent/--agent-file with --session/--continue', + ); + }); + + it('rejects --agent with --continue in shell mode', () => { + const opts = parse(['--agent', 'reviewer', '--continue']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow( + 'Cannot combine --agent/--agent-file with --session/--continue', + ); + }); + it('rejects empty agent values', () => { const opts = parse(['-p', 'hi', '--agent', ' ']); expect(() => validateOptions(opts)).toThrow(OptionConflictError); @@ -449,20 +496,14 @@ describe('CLI options parsing', () => { expect(() => validateOptions(opts)).toThrow('Agent file path cannot be empty.'); }); - it('rejects the flags in shell mode', () => { - const opts = parse(['--agent', 'reviewer']); - expect(() => validateOptions(opts)).toThrow(OptionConflictError); - expect(() => validateOptions(opts)).toThrow( - '--agent/--agent-file are only available with the v2 engine', - ); + it('accepts the flags in shell mode', () => { + expect(validateOptions(parse(['--agent', 'reviewer']), {}).uiMode).toBe('shell'); + expect(validateOptions(parse(['--agent-file', 'a.md']), {}).uiMode).toBe('shell'); }); - it('rejects the flags in prompt mode without the v2 engine flag', () => { + it('accepts the flags in prompt mode without the v2 engine flag', () => { const opts = parse(['-p', 'hi', '--agent-file', 'a.md']); - expect(() => validateOptions(opts, {})).toThrow(OptionConflictError); - expect(() => validateOptions(opts, {})).toThrow( - '--agent/--agent-file are only available with the v2 engine', - ); + expect(validateOptions(opts, {}).uiMode).toBe('print'); }); it('accepts the flags in prompt mode with the v2 engine flag', () => { diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index 4bad127d8..d71e88b23 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -1,3 +1,14 @@ +/** + * Scenario: print-mode session startup and resume routing. + * Responsibilities: CLI options are translated into the SDK session contract and output is rendered. + * Wiring: the SDK/telemetry/process boundaries are mocked; the print driver is real. + * Run: pnpm -C apps/kimi-code exec vitest run test/cli/run-prompt.test.ts + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import type { createKimiDeviceId as createKimiDeviceIdFn } from '@moonshot-ai/kimi-code-oauth'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -282,6 +293,32 @@ describe('runPrompt', () => { expect(mocks.harnessClose).toHaveBeenCalled(); }); + it('selects the profile declared by an explicit agent file for a fresh v1 session', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kimi-run-prompt-agent-')); + const agentFile = join(dir, 'reviewer.md'); + await writeFile( + agentFile, + '---\nname: reviewer\ndescription: Reviews code.\n---\n\nReview the requested change.\n', + 'utf-8', + ); + + try { + await runPrompt(opts({ agentFiles: [agentFile] }), '1.2.3-test', { + stdout: writer(), + stderr: writer(), + }); + + expect(mocks.harnessCreateSession).toHaveBeenCalledWith( + expect.objectContaining({ + agentProfile: 'reviewer', + agentFiles: [agentFile], + }), + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + it('completes even if harness.close() never resolves (cleanup is time-bounded)', async () => { vi.useFakeTimers(); try { @@ -629,6 +666,17 @@ describe('runPrompt', () => { expect(mocks.harnessCreateSession).not.toHaveBeenCalled(); }); + it('does not forward an agent profile when resuming a concrete v1 session', async () => { + // validateOptions rejects --agent with --session; runPrompt must not + // forward a profile to resume even if a caller hands one over. + await runPrompt(opts({ session: 'ses_existing', agent: 'reviewer' }), '1.2.3-test', { + stdout: writer(), + stderr: writer(), + }); + + expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ id: 'ses_existing' }); + }); + it('allows resuming a concrete session when Windows workdir uses backslashes', async () => { const cwd = vi.spyOn(process, 'cwd').mockReturnValue(String.raw`C:\Users\kimi\project`); mocks.harnessListSessions.mockResolvedValueOnce([ @@ -901,6 +949,17 @@ describe('runPrompt', () => { expect(mocks.harnessCreateSession).not.toHaveBeenCalled(); }); + it('does not forward an agent profile when continuing a previous v1 session', async () => { + // validateOptions rejects --agent with --continue; runPrompt must not + // forward a profile to resume even if a caller hands one over. + await runPrompt(opts({ continue: true, agent: 'reviewer' }), '1.2.3-test', { + stdout: writer(), + stderr: writer(), + }); + + expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ id: 'ses_previous' }); + }); + it('continues a previous session without a configured default model', async () => { mocks.harnessGetConfig.mockResolvedValueOnce({ providers: {}, telemetry: true }); mocks.session.getStatus.mockResolvedValueOnce({ permission: 'manual', model: 'saved-model' }); diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index f835046ca..7e6260626 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -319,6 +319,35 @@ describe('runShell', () => { }); }); + it('resolves the --agent profile into the TUI startup input', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + + await runShell( + { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + agent: 'reviewer', + agentFiles: [], + }, + '1.2.3-test', + ); + + const [, , startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!; + expect(startupInput).toMatchObject({ agentProfile: 'reviewer' }); + }); + it('forwards skillsDirs from CLI options to the harness', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index bc4c5894f..bf875b52b 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -166,6 +166,7 @@ describe('built-in slash command registry', () => { 'plan', 'reload', 'reload-tui', + 'secondary_model', 'sessions', 'settings', 'status', @@ -188,4 +189,11 @@ describe('built-in slash command registry', () => { expect(resolveSlashCommandAvailability(reload!, '')).toBe('idle-only'); expect(resolveSlashCommandAvailability(reloadTui!, '')).toBe('always'); }); + + it('gates secondary_model behind the secondary-model experiment, always available', () => { + const command = findBuiltInSlashCommand('secondary_model'); + expect(command).toBeDefined(); + expect((command as KimiSlashCommand).experimentalFlag).toBe('secondary-model'); + expect(resolveSlashCommandAvailability(command!, '')).toBe('always'); + }); }); diff --git a/apps/kimi-code/test/tui/commands/secondary-model.test.ts b/apps/kimi-code/test/tui/commands/secondary-model.test.ts new file mode 100644 index 000000000..81b309ef0 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/secondary-model.test.ts @@ -0,0 +1,230 @@ +/** + * Scenario: /secondary_model command behavior in the interactive TUI. + * Responsibilities: picker filtering, persistence, live apply, and effective-model state refresh. + * Wiring: real command and selector with the SDK/session boundaries stubbed by a small host rig. + * Run: pnpm -C apps/kimi-code exec vitest run test/tui/commands/secondary-model.test.ts + */ +import type { ModelAlias, ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import type { SlashCommandHost } from '#/tui/commands'; +import { handleSecondaryModelCommand } from '#/tui/commands/config'; +import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector'; + +interface PickerOptions { + readonly models: Record; + readonly currentValue: string; + readonly currentThinkingEffort: string; + readonly title?: string; + readonly onSelect: (selection: { alias: string; thinking: ThinkingEffort }) => void; +} + +function model(name: string): ModelAlias { + return { + provider: 'test', + model: name, + maxContextSize: 200_000, + displayName: name, + } as unknown as ModelAlias; +} + +function makeHost(options?: { + readonly withSession?: boolean; + readonly secondaryModel?: { model: string; defaultEffort?: string }; + readonly persistedModels?: Record; + /** The secondary model the reloaded config carries — env overlays win. */ + readonly effectiveSecondary?: { model: string; defaultEffort?: string }; +}) { + const session = options?.withSession === false + ? undefined + : { applyPersistedSecondaryModel: vi.fn(async () => {}) }; + const appState = { + availableModels: { + k2: model('k2'), + cheap: model('cheap'), + // The synthesized derived entry must never be selectable. + '__secondary__': model('cheap'), + } as Record, + availableProviders: {}, + transcriptEntries: [], + }; + const host = { + state: { + appState, + transcriptEntries: [], + }, + authFlow: { + refreshOAuthProviderModels: vi.fn(async () => undefined), + }, + harness: { + getConfig: vi.fn(async () => ({ + providers: {}, + secondaryModel: options?.secondaryModel, + })), + setConfig: vi.fn(async () => ({ + providers: {}, + models: options?.persistedModels, + secondaryModel: options?.effectiveSecondary, + })), + }, + session, + setAppState: vi.fn((patch) => Object.assign(appState, patch)), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + showStatus: vi.fn(), + showError: vi.fn(), + showNotice: vi.fn(), + track: vi.fn(), + } as unknown as SlashCommandHost & { + harness: { + getConfig: ReturnType; + setConfig: ReturnType; + }; + mountEditorReplacement: ReturnType; + showStatus: ReturnType; + showError: ReturnType; + showNotice: ReturnType; + }; + return { host, session }; +} + +function mountedPicker(host: { mountEditorReplacement: ReturnType }): PickerOptions { + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + const component = host.mountEditorReplacement.mock.calls[0]![0]; + expect(component).toBeInstanceOf(TabbedModelSelectorComponent); + return (component as unknown as { opts: PickerOptions }).opts; +} + +describe('handleSecondaryModelCommand', () => { + it('opens the picker filtered to user models, with the configured recipe as current', async () => { + const { host } = makeHost({ secondaryModel: { model: 'cheap', defaultEffort: 'high' } }); + + await handleSecondaryModelCommand(host, ''); + + const opts = mountedPicker(host); + expect(Object.keys(opts.models)).toEqual(['k2', 'cheap']); + expect(opts.currentValue).toBe('cheap'); + expect(opts.currentThinkingEffort).toBe('high'); + expect(opts.title).toContain('secondary model'); + }); + + it('persists first, then live-applies the selection to the session', async () => { + const { host, session } = makeHost(); + + await handleSecondaryModelCommand(host, ''); + mountedPicker(host).onSelect({ alias: 'k2', thinking: 'high' }); + + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalled(); + }); + expect(host.harness.setConfig).toHaveBeenCalledWith({ + secondaryModel: { model: 'k2', defaultEffort: 'high' }, + }); + expect(session!.applyPersistedSecondaryModel).toHaveBeenCalledWith(); + expect(host.harness.setConfig.mock.invocationCallOrder[0]).toBeLessThan( + session!.applyPersistedSecondaryModel.mock.invocationCallOrder[0]!, + ); + expect(host.showError).not.toHaveBeenCalled(); + }); + + it('refreshes the effective model map after a live secondary-model switch', async () => { + const { host } = makeHost({ + persistedModels: { + k2: model('k2'), + cheap: model('cheap'), + '__secondary__': model('k2'), + }, + }); + + await handleSecondaryModelCommand(host, ''); + mountedPicker(host).onSelect({ alias: 'k2', thinking: 'high' }); + + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalled(); + }); + expect(host.state.appState.availableModels['__secondary__']?.displayName).toBe('k2'); + }); + + it('warns with the env-overridden effective binding instead of the picked model', async () => { + // KIMI_SECONDARY_MODEL / KIMI_SECONDARY_EFFORT win over the persisted + // recipe: the reloaded config carries the overlaid values, and the status + // message must name them rather than echo the pick. + const { host } = makeHost({ + effectiveSecondary: { model: 'cheap', defaultEffort: 'low' }, + }); + + await handleSecondaryModelCommand(host, ''); + mountedPicker(host).onSelect({ alias: 'k2', thinking: 'high' }); + + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalled(); + }); + const [message, color] = host.showStatus.mock.calls[0]!; + expect(message).toContain('KIMI_SECONDARY_MODEL=cheap'); + expect(message).toContain('KIMI_SECONDARY_EFFORT=low'); + expect(color).toBe('warning'); + expect(host.showError).not.toHaveBeenCalled(); + }); + + it('keeps the current effective model map when live apply fails', async () => { + const { host, session } = makeHost({ + persistedModels: { + k2: model('k2'), + cheap: model('cheap'), + '__secondary__': model('k2'), + }, + }); + session!.applyPersistedSecondaryModel.mockRejectedValueOnce(new Error('apply failed')); + + await handleSecondaryModelCommand(host, ''); + mountedPicker(host).onSelect({ alias: 'k2', thinking: 'high' }); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalled(); + }); + expect(host.state.appState.availableModels['__secondary__']?.displayName).toBe('cheap'); + }); + + it('persists only when there is no session', async () => { + const { host } = makeHost({ withSession: false }); + + await handleSecondaryModelCommand(host, ''); + mountedPicker(host).onSelect({ alias: 'k2', thinking: 'off' }); + + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalled(); + }); + expect(host.harness.setConfig).toHaveBeenCalledWith({ + secondaryModel: { model: 'k2', defaultEffort: 'off' }, + }); + expect(host.showStatus.mock.calls[0]![0]).toContain('new sessions'); + }); + + it('rejects an unknown alias argument without opening the picker', async () => { + const { host } = makeHost(); + + await handleSecondaryModelCommand(host, 'nope'); + + expect(host.showError).toHaveBeenCalledWith('Unknown model alias: nope'); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('rejects the synthesized derived alias as an argument', async () => { + const { host } = makeHost(); + + await handleSecondaryModelCommand(host, '__secondary__'); + + expect(host.showError).toHaveBeenCalledWith('Unknown model alias: __secondary__'); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('shows a notice when no models are configured', async () => { + const { host } = makeHost(); + host.state.appState.availableModels = {}; + + await handleSecondaryModelCommand(host, ''); + + expect(host.showNotice).toHaveBeenCalled(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts index 3f4287486..f6ffc6496 100644 --- a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts @@ -132,6 +132,22 @@ describe('TabbedModelSelectorComponent', () => { expect(hint!.indexOf('Tab toggle provider')).toBeLessThan(hint!.indexOf('↑↓ navigate')); }); + it('renders the default title, and a custom title when provided', () => { + expect(strip(make().component.render(120).join('\n'))).toContain('Select a model'); + + const titled = new TabbedModelSelectorComponent({ + models: { k2: model('Kimi K2', 'managed:kimi-code') }, + currentValue: 'k2', + currentThinkingEffort: 'off', + title: ' Select a secondary model (subagents)', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const out = strip(titled.render(120).join('\n')); + expect(out).toContain('Select a secondary model (subagents)'); + expect(out).not.toContain('Select a model '); + }); + it('keeps the tab strip between hint and list when a warning line is present', () => { const component = new TabbedModelSelectorComponent({ models: { diff --git a/apps/kimi-code/test/tui/components/messages/agent-group.test.ts b/apps/kimi-code/test/tui/components/messages/agent-group.test.ts index adf4d3b0b..e567e96ad 100644 --- a/apps/kimi-code/test/tui/components/messages/agent-group.test.ts +++ b/apps/kimi-code/test/tui/components/messages/agent-group.test.ts @@ -91,6 +91,26 @@ describe('AgentGroupComponent', () => { waiting.dispose(); }); + it('shows the bound model in the row stats once reported', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const running = createAgent('call_agent_1', 'inspect project', 'explore', ui); + startAgent(running, 'call_agent_1', 'explore'); + + group.attach('call_agent_1', running); + expect(renderText(group)).toContain('explore · inspect project · 0 tools'); + + running.updateSubagentMetrics({ modelDisplay: 'Kimi K2.5' }); + // Non-phase updates are throttled; flush the pending refresh. + vi.runOnlyPendingTimers(); + expect(renderText(group)).toContain('explore · inspect project · Kimi K2.5 · 0 tools'); + + group.dispose(); + running.dispose(); + }); + it('shows the Ctrl+B hint while agents are running and hides it once all are backgrounded', () => { vi.useFakeTimers(); vi.setSystemTime(0); diff --git a/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts b/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts index ad6389418..bb460ce0e 100644 --- a/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts +++ b/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts @@ -167,6 +167,30 @@ describe('AgentSwarmProgressComponent', () => { expect(output).not.toContain('01'); }); + it('shows the bound model display name in the header', () => { + const component = createComponent(); + + component.setModelDisplay('kimi-k2-thinking'); + const lines = renderLines(component); + const headerLine = lines.find((line) => line.includes('Agent Swarm')); + + expect(headerLine).toBeDefined(); + expect(headerLine).toContain('Review changed files ─ kimi-k2-thinking'); + }); + + it('keeps the first reported model when later status updates differ', () => { + const component = createComponent(); + + component.setModelDisplay('kimi-k2-thinking'); + component.setModelDisplay('other-model'); + component.setModelDisplay(''); + + const output = renderText(component); + + expect(output).toContain('kimi-k2-thinking'); + expect(output).not.toContain('other-model'); + }); + it('repaints from the active palette when the theme changes', () => { const previousLevel = chalk.level; chalk.level = 3; // force truecolor so palette differences surface as ANSI diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index f5ff1fc1e..4426e0e5a 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -1033,6 +1033,34 @@ describe('ToolCallComponent', () => { expect(out).not.toContain('summary fallback'); }); + it('shows the bound model in the subagent header and group snapshot once reported', () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + const component = new ToolCallComponent( + { + id: 'call_agent_model', + name: 'Agent', + args: { description: 'explore project' }, + }, + undefined, + ); + component.onSubagentSpawned({ + agentId: 'sub_model_1', + agentName: 'explore', + runInBackground: false, + }); + + let out = strip(component.render(120).join('\n')); + expect(out).toContain('Explore Agent Queued (explore project) · 0 tools'); + expect(out).not.toContain('Kimi K2.5'); + + component.updateSubagentMetrics({ modelDisplay: 'Kimi K2.5' }); + + out = strip(component.render(120).join('\n')); + expect(out).toContain('Explore Agent Queued (explore project) · Kimi K2.5 · 0 tools'); + expect(component.getSubagentSnapshot().model).toBe('Kimi K2.5'); + }); + it('shows Backgrounded after a foreground subagent is detached, even after setResult', () => { vi.useFakeTimers(); vi.setSystemTime(0); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 8f9549b85..b0689431e 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -281,6 +281,24 @@ describe('KimiTUI startup', () => { }); }); + it('binds the resolved agent profile and agent files to the startup session', async () => { + const session = makeSession(); + const harness = makeHarness(session); + const driver = makeDriver(harness, { + ...makeStartupInput({ agent: 'reviewer', agentFiles: ['reviewer.md'] }), + agentProfile: 'reviewer', + }); + + await expect(driver.init()).resolves.toBe(false); + + expect(harness.createSession).toHaveBeenCalledWith({ + workDir: '/tmp/proj-a', + agentProfile: 'reviewer', + agentFiles: ['reviewer.md'], + }); + expect(driver.state.startupState).toBe('ready'); + }); + it('resumes the latest session for --continue and marks history for replay', async () => { const session = makeSession({ id: 'ses-latest' }); const harness = makeHarness(session, { @@ -1205,6 +1223,43 @@ describe('KimiTUI startup', () => { }); }); + it('carries the agent binding into the post-login startup session', async () => { + const session = makeSession(); + const createSession = vi + .fn() + .mockRejectedValueOnce(loginRequiredError()) + .mockResolvedValueOnce(session); + const harness = makeHarness(session, { + getConfig: vi.fn(async () => ({ + defaultModel: 'k2', + thinking: { enabled: false }, + models: { + k2: { model: 'moonshot-v1', maxContextSize: 100 }, + }, + })), + createSession, + }); + const driver = makeDriver(harness, { + ...makeStartupInput({ agent: 'reviewer', agentFiles: ['reviewer.md'] }), + agentProfile: 'reviewer', + }); + + await expect(driver.init()).resolves.toBe(false); + + vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); + await handleLoginCommand(driver as any); + + expect(createSession).toHaveBeenNthCalledWith(2, { + workDir: '/tmp/proj-a', + model: 'k2', + thinking: 'off', + permission: undefined, + planMode: undefined, + agentProfile: 'reviewer', + agentFiles: ['reviewer.md'], + }); + }); + it('does not force manual permission after OAuth login without --yolo', async () => { const session = makeSession({ getStatus: vi.fn(async () => ({ diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 73c9dc287..490fc24ef 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -192,7 +192,9 @@ You can also switch models temporarily without touching the config file — by s The secondary model is a second model pointer next to the primary `default_model` — typically a cheaper model that features can bind to when they do not need the main model. Its consumer today is subagent spawning: when set, newly spawned subagents (`Agent` / `AgentSwarm`) bind to it by default instead of inheriting the main agent's model, and the main agent is told it can pick per spawn between `"secondary"` (this model) and `"primary"` (the main model). When unset, subagents inherit the main agent's model. -This feature is experimental and disabled by default. Under `kimi web`, enable it with `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`. Under `kimi -p`, `KIMI_CODE_EXPERIMENTAL_FLAG=1` is already required to select the v2 engine and also enables this feature. The interactive TUI ignores the configuration. +This feature is experimental and disabled by default. Enable it with `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`, or the master `KIMI_CODE_EXPERIMENTAL_FLAG=1`. It takes effect in every launch mode, including the interactive TUI. + +In the interactive TUI, the [`/secondary_model`](../reference/slash-commands.md) command opens a model picker that writes this section and live-applies it to the current session, so newly spawned subagents bind the new secondary model right away. | Field | Type | Default | Description | | --- | --- | --- | --- | diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 145774624..8cf72c632 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -128,9 +128,9 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | Override the plugin marketplace JSON loaded by `/plugins`; useful for dev loopback servers, staging CDN files, or alternate marketplace directories | `https://code.kimi.com/kimi-code/plugins/marketplace.json`; also accepts `http://`, `file://` URLs, and local paths | | `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | Cap how many AgentSwarm subagents run concurrently during the initial ramp; leave unset for no cap | Positive integer; invalid values fail fast | | `KIMI_SUBAGENT_TIMEOUT_MS` | Maximum wall-clock time (ms) a single subagent (`Agent` / `AgentSwarm`) may run; takes higher priority than `[subagent] timeout_ms` in `config.toml` (default `7200000`, i.e. 2 hours) | Positive integer; invalid values fall back to the config or default | -| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | Enable experimental secondary-model behavior under `kimi web`; `kimi -p` still requires `KIMI_CODE_EXPERIMENTAL_FLAG=1` to select the v2 engine, which also enables this feature | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | -| `KIMI_SECONDARY_MODEL` | Secondary model; takes higher priority than `[secondary_model] model` in `config.toml`. When the secondary-model experiment is enabled, newly spawned subagents (`Agent` / `AgentSwarm`) bind to it by default instead of inheriting the main agent's model (not supported in the TUI) | A model id from your configured `[models]`, e.g. `kimi-code/kimi-k2.5`; blank values are ignored | -| `KIMI_SECONDARY_EFFORT` | Thinking effort for the secondary model; takes higher priority than `[secondary_model] default_effort` in `config.toml` and applies only when both the model and its experiment are enabled (not supported in the TUI) | An effort value, e.g. `low`; blank values are ignored | +| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | Enable the experimental secondary-model feature in every launch mode, including the interactive TUI; the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | +| `KIMI_SECONDARY_MODEL` | Secondary model; takes higher priority than `[secondary_model] model` in `config.toml`. When the secondary-model experiment is enabled, newly spawned subagents (`Agent` / `AgentSwarm`) bind to it by default instead of inheriting the main agent's model | A model id from your configured `[models]`, e.g. `kimi-code/kimi-k2.5`; blank values are ignored | +| `KIMI_SECONDARY_EFFORT` | Thinking effort for the secondary model; takes higher priority than `[secondary_model] default_effort` in `config.toml` and applies only when both the model and its experiment are enabled | An effort value, e.g. `low`; blank values are ignored | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | Global default connection timeout (ms) for all MCP servers; takes higher priority than `[mcp] startup_timeout_ms` in `config.toml`, but a per-server `startupTimeoutMs` in `mcp.json` still wins (default `30000`) | Integer from `1` to `2147483647`; invalid values are ignored | | `KIMI_MCP_TOOL_TIMEOUT_MS` | Global default single tool-call timeout (ms) for all MCP servers; takes higher priority than `[mcp] tool_timeout_ms` in `config.toml`, but a per-server `toolTimeoutMs` in `mcp.json` still wins (default `60000`) | Integer from `1` to `2147483647`; invalid values are ignored | | `KIMI_LOOP_MAX_STEPS_PER_TURN` | Maximum Agent steps per turn; takes higher priority than `[loop_control] max_steps_per_turn` in `config.toml` (unset or `0` means unlimited) | Non-negative integer; invalid values are ignored | diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md index 209711bdc..4caedf6f4 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -109,7 +109,7 @@ The body is the agent's system prompt, and it is rendered as a template each tim Unknown fields are ignored, so newer files stay readable by older versions. Fields from other agent tools (such as Claude Code's `model` or OpenCode's `mode`) are ignored the same way, the comma-separated `tools` form keeps Claude Code-style agent files loadable, and a missing `name` falls back to the file name so OpenCode-style files load too — a minimal file with `description` and a body works across tools. -`model_preference` applies only to newly spawned subagents when the secondary-model experiment is enabled. Under `kimi web`, set `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`; under experimental `kimi -p`, the required `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it. The TUI currently ignores this field. It never names a concrete model alias, and resumed subagents keep their existing model. The selected preference is shown to the main agent alongside the profile description so it can still pass an explicit `model` when a task needs a different choice. +`model_preference` applies only to newly spawned subagents when the secondary-model experiment is enabled — set `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`, or the master `KIMI_CODE_EXPERIMENTAL_FLAG=1`. It takes effect in every launch mode, including the interactive TUI. The field never names a concrete model alias, and resumed subagents keep their existing model. The selected preference is shown to the main agent alongside the profile description so it can still pass an explicit `model` when a task needs a different choice. A file with invalid content discovered in a directory is skipped with a warning and does not affect other files. A file passed explicitly via `--agent-file` must be valid — otherwise the CLI reports the error and exits. @@ -121,24 +121,27 @@ Custom agents delegated as sub-agents run without the built-in sub-agent framing ### Selecting the Main Agent -Two CLI flags select which agent drives the session. **Both are currently available only under `kimi -p` with `KIMI_CODE_EXPERIMENTAL_FLAG=1`**; the interactive TUI rejects them with a clear error for now: +Two CLI flags select which agent drives a new session, in both print mode (`kimi -p`) and the interactive TUI: - **`--agent `**: Start the session with the named agent as the main Agent. The name can refer to a built-in agent or to any discovered file; an unknown name fails with an error listing the available agents. - **`--agent-file `**: Load one agent file at the highest priority for this launch and start with it. The flag accepts exactly one file: it cannot be repeated, and it cannot be combined with `--agent`. -For example, in print mode: +Both flags only apply when starting a new session — neither can be combined with `--session`/`--continue`. The agent is bound at session creation, and resuming restores the bound agent automatically, so no flag is needed (or allowed) on resume. + +For example: ```sh -KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p --agent reviewer "Review the changes on this branch" +kimi --agent reviewer +kimi -p --agent reviewer "Review the changes on this branch" ``` -The bound agent is the session's identity: it is fixed at the session's first bind and cannot be switched later. Re-selecting the already-bound agent (for example resuming with the same `--agent`) is a no-op; selecting a different one fails with an "already bound" error. +The bound agent is the session's identity: it is fixed at the session's first bind and cannot be switched later. In the TUI the flags bind only the startup session; a session created later in the same process (for example via `/new`) starts with the default agent. For main-agent customization, reference `${base_prompt}` in the body so the environment, workspace-instruction, and Skill injections from the default prompt stay in effect; a body without `${base_prompt}` owns the entire prompt, which fits self-contained sub-agents. ### Overriding the main agent's system prompt with SYSTEM.md -To override the main agent's system prompt permanently — without passing `--agent` or `--agent-file` on every launch — write a `$KIMI_CODE_HOME/SYSTEM.md` file (default: `~/.kimi-code/SYSTEM.md`; it moves with `KIMI_CODE_HOME`). While the file exists and is non-empty, it replaces the built-in default main agent's system prompt in full — and only the prompt: the description and tool set are inherited from the built-in defaults. SYSTEM.md currently takes effect only under `kimi web` and under `kimi -p` with `KIMI_CODE_EXPERIMENTAL_FLAG=1`; the interactive TUI ignores the file. +To override the main agent's system prompt permanently — without passing `--agent` or `--agent-file` on every launch — write a `$KIMI_CODE_HOME/SYSTEM.md` file (default: `~/.kimi-code/SYSTEM.md`; it moves with `KIMI_CODE_HOME`). While the file exists and is non-empty, it replaces the built-in default main agent's system prompt in full — and only the prompt: the description, tool set, and sub-agent delegation allowlist are inherited from the built-in defaults. SYSTEM.md takes effect in every launch mode, including interactive TUI sessions. SYSTEM.md is a plain Markdown body — no frontmatter is required or read. A missing or empty file has no effect, and a read failure falls back to the built-in prompt with a warning. Explicit intent still outranks it: a project-scoped same-name agent file declaring `override: true` and any file passed via `--agent-file` take precedence, and selecting another agent with `--agent` bypasses it entirely. Within the user scope itself, SYSTEM.md wins over a same-name file discovered in the `agents/` directories. diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index 0c29c35ba..bc77b4d00 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -24,8 +24,8 @@ All flags are optional — run `kimi` directly to enter an interactive session: | `--auto` | | Start with auto permission mode; tool approvals are handled automatically and the Agent will not ask the user questions | | `--plan` | | Start a new session in Plan mode — the AI will prioritize read-only tools for exploration and planning | | `--skills-dir ` | | Load Skills from the specified directory, replacing the automatically discovered user and project directories. Can be repeated | -| `--agent ` | | Start the session with the specified agent as the main Agent (experimental `kimi -p` only) | -| `--agent-file ` | | Load a custom agent from a Markdown file for this launch and select it (experimental `kimi -p` only). Cannot be repeated or combined with `--agent` | +| `--agent ` | | Start a new session with the specified agent as the main Agent. Cannot be combined with `--session`/`--continue` | +| `--agent-file ` | | Load a custom agent from a Markdown file for the new session and select it. Cannot be repeated or combined with `--agent`, `--session`, or `--continue` | | `--add-dir ` | | Add an extra workspace directory for this session. Relative paths resolve against the current working directory. Can be repeated | `-r` / `--resume` is a hidden alias for `--session`; `--yes` and `--auto-approve` are hidden aliases for `--yolo` and are not shown in help output. @@ -98,13 +98,14 @@ There are two ways to specify Skills directories, with different semantics: ### Custom Agents -`--agent` and `--agent-file` select which agent drives the session. Both are currently available only under `kimi -p` with `KIMI_CODE_EXPERIMENTAL_FLAG=1`; any other launch rejects them with a clear error: +`--agent` and `--agent-file` select which agent drives a new session, in both print mode (`kimi -p`) and the interactive TUI: ```sh -KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p --agent reviewer "Review the changes on this branch" +kimi --agent reviewer +kimi -p --agent reviewer "Review the changes on this branch" ``` -`--agent-file` registers a single agent file at the highest priority for this launch only and selects it; the flag cannot be repeated, and `--agent` and `--agent-file` are mutually exclusive. The selection is fixed at the session's first bind: resuming with the same `--agent` is a no-op, and switching to a different one fails with an "already bound" error. See [Agents and Sub-Agents](../customization/agents.md#custom-agents) for the agent file format and discovery directories. +`--agent-file` registers a single agent file at the highest priority for this launch only and selects it; the flag cannot be repeated, and `--agent` and `--agent-file` are mutually exclusive. Both flags only apply when starting a new session — neither can be combined with `--session`/`--continue`, because the agent is bound at session creation and resuming restores the bound agent automatically. The selection is fixed at the session's first bind and cannot be switched later; in the TUI the flags bind only the startup session, and a session created later in the same process (for example via `/new`) starts with the default agent. See [Agents and Sub-Agents](../customization/agents.md#custom-agents) for the agent file format and discovery directories. ## Non-Interactive Execution diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index d3ab8bf4c..2b2d404ba 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -16,6 +16,7 @@ Some commands are only available in the idle state. Executing these commands whi | `/logout` | — | Clear credentials for the currently selected account | No | | `/provider` | — | Open the interactive provider manager to view, add, and remove configured providers. See [Platforms & Models — `/provider` and provider management](../configuration/providers.md#provider-与供应商管理) | Yes | | `/model` | — | Switch the LLM model used in the current session | Yes | +| `/secondary_model` | — | Configure the secondary model used by subagents (writes the [`[secondary_model]`](../configuration/config-files.md#secondary_model) section and applies to the current session immediately). Requires the `secondary-model` experiment | Yes | | `/settings` | `/config` | Open the settings panel inside the TUI | Yes | | `/experiments` | `/experimental` | Open the experimental feature panel | Yes | | `/permission` | — | Select a permission mode | Yes | diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index 37972f511..c6f028b30 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -89,9 +89,9 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill | `AskUserQuestion` | Auto-allow | Ask the user a question to gather structured input | | `Skill` | Auto-allow | Invoke a registered inline Skill | -**`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), `run_in_background` (defaults to false), and `model` (`"secondary"` for the secondary model configured via `[secondary_model] model`, or `"primary"` for the main model; ignored when resuming; available when the secondary-model experiment is enabled under `kimi web` or experimental `kimi -p`, not in the TUI). An explicit `model` overrides the selected [agent profile's `model_preference`](../customization/agents.md#agent-file-format); without either, the configured secondary model is the default, or the subagent inherits the caller's model when no secondary model is configured. Agent tasks time out after 2 hours by default; the limit is configurable via `[subagent] timeout_ms` in `config.toml` (`0` = no timeout, or the `KIMI_SUBAGENT_TIMEOUT_MS` env var), and defaults to no timeout in print mode (`kimi -p`). In foreground mode the parent Agent waits for the sub-Agent to complete before continuing; in background mode a task ID is returned immediately and the result is automatically delivered back to the main Agent via a synthetic User message when done. When several foreground `Agent` calls run in the same step, the TUI groups them and shows each subagent's running, waiting, completed, or failed status with elapsed time. See [Agent & Sub-Agents](../customization/agents.md) for details. +**`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), `run_in_background` (defaults to false), and `model` (`"secondary"` for the secondary model configured via `[secondary_model] model`, or `"primary"` for the main model; ignored when resuming; available when the secondary-model experiment is enabled). An explicit `model` overrides the selected [agent profile's `model_preference`](../customization/agents.md#agent-file-format); without either, the configured secondary model is the default, or the subagent inherits the caller's model when no secondary model is configured. Agent tasks time out after 2 hours by default; the limit is configurable via `[subagent] timeout_ms` in `config.toml` (`0` = no timeout, or the `KIMI_SUBAGENT_TIMEOUT_MS` env var), and defaults to no timeout in print mode (`kimi -p`). In foreground mode the parent Agent waits for the sub-Agent to complete before continuing; in background mode a task ID is returned immediately and the result is automatically delivered back to the main Agent via a synthetic User message when done. When several foreground `Agent` calls run in the same step, the TUI groups them and shows each subagent's running, waiting, completed, or failed status with elapsed time. See [Agent & Sub-Agents](../customization/agents.md) for details. -**`AgentSwarm`** launches subagents from a shared `prompt_template` and an `items` array, resumes existing subagents through `resume_agent_ids`, or combines both in one call. The template must contain the `{{item}}` placeholder; each item replaces that placeholder and launches one new subagent. Pass `subagent_type` to choose the profile used by every spawned subagent in the swarm, or omit it to use `coder`. Pass `model` (available when the secondary-model experiment is enabled under `kimi web` or experimental `kimi -p`, not in the TUI) to run item-spawned subagents on the secondary model configured via `[secondary_model] model` (`"secondary"`) or the main model (`"primary"`). This explicit choice overrides the selected [agent profile's `model_preference`](../customization/agents.md#agent-file-format); without either, the configured secondary model is the default, or the subagent inherits the caller's model when no secondary model is configured. Resumed subagents keep their own model. Without `resume_agent_ids`, the tool requires at least 2 items; with `resume_agent_ids`, it can resume one or more existing subagents. The tool supports up to 128 total subagents, waits for all subagents to finish, and returns an aggregated report. In the TUI, foreground swarms show a live `Agent swarm` progress panel above the input box. If a model response calls `AgentSwarm`, that call must be the only tool call in the response; to run multiple swarms, call one `AgentSwarm`, wait for its result, then call the next, or combine the work into one swarm when a single template can cover it. In `manual` permission mode, `AgentSwarm` calls outside active swarm mode request approval unless a permission rule allows them; while swarm mode is active, `AgentSwarm` itself is auto-approved. Permission rules match `AgentSwarm` by tool name only — argument patterns such as `AgentSwarm(swarm)` are not supported. By default the tool ramps up concurrency without an upper limit (5 subagents start immediately, then 1 more every 700 ms); set `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` to a positive integer to cap how many subagents run at the same time during that ramp, or leave it unset for no cap. If it is set to a value that is not a positive integer, the AgentSwarm call fails fast. +**`AgentSwarm`** launches subagents from a shared `prompt_template` and an `items` array, resumes existing subagents through `resume_agent_ids`, or combines both in one call. The template must contain the `{{item}}` placeholder; each item replaces that placeholder and launches one new subagent. Pass `subagent_type` to choose the profile used by every spawned subagent in the swarm, or omit it to use `coder`. Pass `model` (available when the secondary-model experiment is enabled) to run item-spawned subagents on the secondary model configured via `[secondary_model] model` (`"secondary"`) or the main model (`"primary"`). This explicit choice overrides the selected [agent profile's `model_preference`](../customization/agents.md#agent-file-format); without either, the configured secondary model is the default, or the subagent inherits the caller's model when no secondary model is configured. Resumed subagents keep their own model. Without `resume_agent_ids`, the tool requires at least 2 items; with `resume_agent_ids`, it can resume one or more existing subagents. The tool supports up to 128 total subagents, waits for all subagents to finish, and returns an aggregated report. In the TUI, foreground swarms show a live `Agent swarm` progress panel above the input box. If a model response calls `AgentSwarm`, that call must be the only tool call in the response; to run multiple swarms, call one `AgentSwarm`, wait for its result, then call the next, or combine the work into one swarm when a single template can cover it. In `manual` permission mode, `AgentSwarm` calls outside active swarm mode request approval unless a permission rule allows them; while swarm mode is active, `AgentSwarm` itself is auto-approved. Permission rules match `AgentSwarm` by tool name only — argument patterns such as `AgentSwarm(swarm)` are not supported. By default the tool ramps up concurrency without an upper limit (5 subagents start immediately, then 1 more every 700 ms); set `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` to a positive integer to cap how many subagents run at the same time during that ramp, or leave it unset for no cap. If it is set to a value that is not a positive integer, the AgentSwarm call fails fast. **`AskUserQuestion`** asks the user a structured multiple-choice question — useful for disambiguation or option selection. The `questions` parameter accepts 1–4 questions; each question requires `question` (ending with `?`), `options` (2–4 choices, each with a `label` and `description`), and optional `header` (max 12 characters) and `multi_select` (defaults to false). An "Other" option is appended automatically. Setting `background` to true starts a background question task and returns a task ID immediately. When the host does not support interactive questioning, a failure message is returned and the Agent should ask the user directly in a text reply instead. diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index f9c59a2d3..a53ecb081 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -192,7 +192,9 @@ display_name = "Kimi for Coding (custom)" 次主力模型是主模型 `default_model` 之外的第二个模型指针——通常是一个更便宜的模型,供不需要主模型的功能绑定使用。目前的消费者是子 Agent 派生:设置后,新派生的子 Agent(`Agent` / `AgentSwarm`)默认绑定该模型,而不再继承主 Agent 的模型;主 Agent 会被告知每次派生可在 `"secondary"`(该模型)与 `"primary"`(主模型)之间选择。未设置时,子 Agent 继承主 Agent 的模型。 -该功能目前是实验功能,默认关闭。在 `kimi web` 下,通过 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1` 启用;在 `kimi -p` 下,选择 v2 引擎本就需要 `KIMI_CODE_EXPERIMENTAL_FLAG=1`,该 master flag 也会启用本功能。交互式 TUI 会忽略该配置。 +该功能目前是实验功能,默认关闭。通过 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1` 启用,或使用 master `KIMI_CODE_EXPERIMENTAL_FLAG=1`。它在包括交互式 TUI 在内的所有启动方式下生效。 + +在交互式 TUI 中,可以使用 [`/secondary_model`](../reference/slash-commands.md) 命令打开模型选择器来设置该配置:选择后会写入本小节配置,并在当前会话立即生效——之后派生的子 Agent 会直接绑定新的第二模型。 | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index e5a654ebe..326004b40 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -128,9 +128,9 @@ kimi | `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | 覆盖 `/plugins` 加载的 plugin marketplace JSON,适合 dev loopback server、测试 CDN 文件或替换 marketplace 目录 | `https://code.kimi.com/kimi-code/plugins/marketplace.json`;也接受 `http://`、`file://` URL 和本地路径 | | `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | 限制 AgentSwarm 初始提升并发阶段可同时运行的子 Agent 数量;不设置表示不限制 | 正整数;非法值会立即失败 | | `KIMI_SUBAGENT_TIMEOUT_MS` | 单个子 Agent(`Agent` / `AgentSwarm`)可运行的最长时间(毫秒);优先级高于 `config.toml` 的 `[subagent] timeout_ms`(默认 `7200000`,即 2 小时) | 正整数;非法值回退到配置或默认值 | -| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | 在 `kimi web` 下启用实验性的次主力模型功能;`kimi -p` 仍需通过 `KIMI_CODE_EXPERIMENTAL_FLAG=1` 选择 v2 引擎,该 master flag 也会启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | -| `KIMI_SECONDARY_MODEL` | 次主力模型;优先级高于 `config.toml` 的 `[secondary_model] model`。次主力模型实验功能启用后,新派生的子 Agent 默认绑定该模型,而不再继承主 Agent 的模型(TUI 不支持) | 已配置 `[models]` 中的模型 id,如 `kimi-code/kimi-k2.5`;空白值被忽略 | -| `KIMI_SECONDARY_EFFORT` | 次主力模型的 thinking effort;优先级高于 `config.toml` 的 `[secondary_model] default_effort`,仅在次主力模型及其实验功能均启用时生效(TUI 不支持) | effort 取值,如 `low`;空白值被忽略 | +| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | 在包括交互式 TUI 在内的所有启动方式下启用实验性的次主力模型功能;master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | +| `KIMI_SECONDARY_MODEL` | 次主力模型;优先级高于 `config.toml` 的 `[secondary_model] model`。次主力模型实验功能启用后,新派生的子 Agent 默认绑定该模型,而不再继承主 Agent 的模型 | 已配置 `[models]` 中的模型 id,如 `kimi-code/kimi-k2.5`;空白值被忽略 | +| `KIMI_SECONDARY_EFFORT` | 次主力模型的 thinking effort;优先级高于 `config.toml` 的 `[secondary_model] default_effort`,仅在次主力模型及其实验功能均启用时生效 | effort 取值,如 `low`;空白值被忽略 | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | 所有 MCP server 的全局默认连接超时(毫秒);优先级高于 `config.toml` 的 `[mcp] startup_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `startupTimeoutMs`(默认 `30000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | | `KIMI_MCP_TOOL_TIMEOUT_MS` | 所有 MCP server 的全局默认单次工具调用超时(毫秒);优先级高于 `config.toml` 的 `[mcp] tool_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `toolTimeoutMs`(默认 `60000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | | `KIMI_LOOP_MAX_STEPS_PER_TURN` | Agent 单轮最大步数;优先级高于 `config.toml` 的 `[loop_control] max_steps_per_turn`(不设或 `0` 表示无上限) | 非负整数;非法值被忽略 | diff --git a/docs/zh/customization/agents.md b/docs/zh/customization/agents.md index 0dd0a9c8a..0727b260f 100644 --- a/docs/zh/customization/agents.md +++ b/docs/zh/customization/agents.md @@ -109,7 +109,7 @@ disallowedTools: 未知字段会被忽略,新版本写的文件在旧版本上仍可读取。其他 Agent 工具的字段(如 Claude Code 的 `model`、OpenCode 的 `mode`)同样会被忽略;加上 `tools` 的逗号分隔写法和 `name` 缺省回退到文件名,Claude Code 与 OpenCode 风格的 Agent 文件一般可直接加载 —— 只含 `description` 和正文的最小文件可跨工具通用。 -`model_preference` 仅在次主力模型实验功能启用时对新启动的子 Agent 生效。在 `kimi web` 下,设置 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`;在实验性 `kimi -p` 下,必需的 `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用该功能。TUI 目前会忽略此字段。该字段不用于填写具体模型 alias,已恢复的子 Agent 也会保持原模型。主 Agent 会在 profile 描述中看到这项偏好,因此仍可在某项任务需要不同选择时显式传入 `model`。 +`model_preference` 仅在次主力模型实验功能启用时对新启动的子 Agent 生效——设置 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`,或 master `KIMI_CODE_EXPERIMENTAL_FLAG=1`。它在包括交互式 TUI 在内的所有启动方式下生效。该字段不用于填写具体模型 alias,已恢复的子 Agent 也会保持原模型。主 Agent 会在 profile 描述中看到这项偏好,因此仍可在某项任务需要不同选择时显式传入 `model`。 目录中发现的非法文件会被跳过并告警,不影响其他文件。通过 `--agent-file` 显式传入的文件必须合法 —— 否则 CLI 会报错并退出。 @@ -121,24 +121,27 @@ disallowedTools: ### 选择主 Agent -两个 CLI flag 用于选择驱动会话的 Agent。**目前二者仅在 `KIMI_CODE_EXPERIMENTAL_FLAG=1` 时的 `kimi -p` 下可用**;交互式 TUI 会以明确错误拒绝它们: +两个 CLI flag 用于选择驱动新会话的 Agent,在 print 模式(`kimi -p`)和交互式 TUI 中均可使用: - **`--agent `**:以指定 Agent 作为主 Agent 启动会话。名称可以指向内置 Agent 或任何已发现的文件;名称不存在时会报错,并列出可用的 Agent。 - **`--agent-file `**:以最高优先级加载一个 Agent 文件(仅本次启动)并以其启动。该 flag 只接受一个文件:不可重复传入,也不能与 `--agent` 同时使用。 -例如在 print 模式下: +两个 flag 都仅在新建会话时有效——都不能与 `--session`/`--continue` 组合。Agent 在会话创建时绑定,恢复会话时会自动还原已绑定的 Agent,因此恢复时不需要(也不允许)携带这些 flag。 + +例如: ```sh -KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p --agent reviewer "审查这个分支上的改动" +kimi --agent reviewer +kimi -p --agent reviewer "审查这个分支上的改动" ``` -绑定的 Agent 即会话的身份:在会话首次绑定后即固定,之后不可切换。重复选择已绑定的 Agent(例如以相同的 `--agent` 恢复会话)是 no-op;选择不同的 Agent 会报 "already bound" 错误。 +绑定的 Agent 即会话的身份:在会话首次绑定后即固定,之后不可切换。在 TUI 中,这些 flag 只绑定启动时的会话;之后在同一进程内新建的会话(例如通过 `/new`)使用默认 Agent。 定制主 Agent 时,在正文中引用 `${base_prompt}` 可保持默认提示词的环境、工作区指令和 Skill 注入生效;不引用 `${base_prompt}` 的正文则完全拥有自己的提示词,适合自包含的子 Agent。 ### 用 SYSTEM.md 覆盖主 Agent 的系统提示词 -希望永久覆盖主 Agent 的系统提示词、而不必每次启动都传入 `--agent` 或 `--agent-file` 时,可以写一份 `$KIMI_CODE_HOME/SYSTEM.md`(默认:`~/.kimi-code/SYSTEM.md`,随 `KIMI_CODE_HOME` 移动)。文件存在且非空期间,它整体替换内置默认主 Agent 的系统提示词——但只替换提示词,描述与工具集仍沿用内置默认值。SYSTEM.md 目前仅在 `kimi web`,以及 `KIMI_CODE_EXPERIMENTAL_FLAG=1` 时的 `kimi -p` 下生效;交互式 TUI 会忽略该文件。 +希望永久覆盖主 Agent 的系统提示词、而不必每次启动都传入 `--agent` 或 `--agent-file` 时,可以写一份 `$KIMI_CODE_HOME/SYSTEM.md`(默认:`~/.kimi-code/SYSTEM.md`,随 `KIMI_CODE_HOME` 移动)。文件存在且非空期间,它整体替换内置默认主 Agent 的系统提示词——但只替换提示词,描述、工具集与允许委派的子 Agent 列表仍沿用内置默认值。SYSTEM.md 在包括交互式 TUI 会话在内的所有启动方式下生效。 SYSTEM.md 是纯 Markdown 正文,不需要也不读取 Frontmatter。文件缺失或为空时不生效;读取失败时会告警并回退到内置提示词。优先级上,显式意图仍然胜出:项目作用域中声明了 `override: true` 的同名 Agent 文件、通过 `--agent-file` 传入的文件都排在 SYSTEM.md 之前,用 `--agent` 选择其他 Agent 时 SYSTEM.md 也不会生效;而在用户作用域内部,SYSTEM.md 优先于 `agents/` 目录中扫描到的同名文件。 diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index f68b45123..980811462 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -24,8 +24,8 @@ kimi [options] | `--auto` | | 以 auto 权限模式启动;工具审批自动处理,Agent 不会向用户提问 | | `--plan` | | 以 Plan 模式启动新会话,AI 会优先使用只读工具进行探索和规划 | | `--skills-dir ` | | 从指定目录加载 Skills,替换自动发现的用户和项目目录。可重复传入 | -| `--agent ` | | 以指定 Agent 作为主 Agent 启动会话(仅实验性 `kimi -p`) | -| `--agent-file ` | | 从 Markdown 文件加载自定义 Agent(仅本次启动、仅实验性 `kimi -p`)并选中它。不可重复传入,也不能与 `--agent` 同时使用 | +| `--agent ` | | 以指定 Agent 作为主 Agent 启动新会话。不能与 `--session`/`--continue` 同时使用 | +| `--agent-file ` | | 从 Markdown 文件加载自定义 Agent 并为新会话选中它。不可重复传入,也不能与 `--agent`、`--session` 或 `--continue` 同时使用 | | `--add-dir ` | | 为本次会话添加额外的工作目录。相对路径按当前工作目录解析。可重复传入 | `-r` / `--resume` 是 `--session` 的隐藏别名;`--yes` 和 `--auto-approve` 是 `--yolo` 的隐藏别名,在帮助信息中不显示。 @@ -98,13 +98,14 @@ kimi --plan ### 自定义 Agent -`--agent` 和 `--agent-file` 用于选择驱动会话的 Agent。目前二者仅在 `KIMI_CODE_EXPERIMENTAL_FLAG=1` 时的 `kimi -p` 下可用,其他启动方式会以明确错误拒绝: +`--agent` 和 `--agent-file` 用于选择驱动新会话的 Agent,在 print 模式(`kimi -p`)和交互式 TUI 中均可使用: ```sh -KIMI_CODE_EXPERIMENTAL_FLAG=1 kimi -p --agent reviewer "审查这个分支上的改动" +kimi --agent reviewer +kimi -p --agent reviewer "审查这个分支上的改动" ``` -`--agent-file` 以最高优先级注册单个 Agent 文件(仅本次启动)并选中它;该 flag 不可重复传入,且 `--agent` 与 `--agent-file` 互斥。选择在会话首次绑定后即固定:以相同的 `--agent` 恢复会话是 no-op,换成不同的 Agent 会报 "already bound" 错误。Agent 文件格式与发现目录详见 [Agent 与子 Agent](../customization/agents.md#自定义-agent)。 +`--agent-file` 以最高优先级注册单个 Agent 文件(仅本次启动)并选中它;该 flag 不可重复传入,`--agent` 与 `--agent-file` 互斥。两个 flag 都仅在新建会话时有效——都不能与 `--session`/`--continue` 组合,因为 Agent 在会话创建时绑定,恢复会话时会自动还原已绑定的 Agent。选择在会话首次绑定后即固定,之后不可切换;在 TUI 中,这些 flag 只绑定启动时的会话,之后在同一进程内新建的会话(例如通过 `/new`)使用默认 Agent。Agent 文件格式与发现目录详见 [Agent 与子 Agent](../customization/agents.md#自定义-agent)。 ## 非交互执行 diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index a1882cf87..e49caf2ca 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -16,6 +16,7 @@ | `/logout` | — | 清除当前所选账号的凭据 | 否 | | `/provider` | — | 打开交互式供应商管理器,查看、添加和删除已配置的供应商。详见[平台与模型 — `/provider` 与供应商管理](../configuration/providers.md#provider-与供应商管理) | 是 | | `/model` | — | 切换当前会话使用的 LLM 模型 | 是 | +| `/secondary_model` | — | 配置子 Agent 使用的第二模型(写入 [`[secondary_model]`](../configuration/config-files.md#secondary_model) 配置并在当前会话立即生效)。需开启 `secondary-model` 实验功能 | 是 | | `/settings` | `/config` | 打开 TUI 内的设置面板 | 是 | | `/experiments` | `/experimental` | 打开实验功能面板 | 是 | | `/permission` | — | 选择权限模式 | 是 | diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index f993fa7bb..04e84a6ad 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -89,9 +89,9 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只 | `AskUserQuestion` | 自动放行 | 向用户提问以获取结构化输入 | | `Skill` | 自动放行 | 调用已注册的 inline Skill | -**`Agent`** 将子任务委托给子 Agent 执行。必填参数:`prompt`(完整任务描述)和 `description`(3–5 个词的简短说明)。可选参数:`subagent_type`(默认 `coder`)、`resume`(恢复已有 Agent 的 ID,与 `subagent_type` 互斥)、`run_in_background`(默认 false)和 `model`(`"secondary"` 表示 `[secondary_model] model` 配置的次主力模型,`"primary"` 表示主模型;resume 时无效;在 `kimi web` 或实验性 `kimi -p` 下启用次主力模型实验功能后可用,TUI 下被忽略)。显式 `model` 会覆盖所选 [Agent profile 的 `model_preference`](../customization/agents.md#agent-文件格式);两者均未设置时,已配置的次主力模型为默认值,未配置时则继承调用方模型。Agent 任务默认 2 小时超时,可通过 `config.toml` 的 `[subagent] timeout_ms`(`0` = 无超时,或 `KIMI_SUBAGENT_TIMEOUT_MS` 环境变量)配置,且在 print 模式(`kimi -p`)下默认无超时。前台模式下父 Agent 等待子 Agent 完成再继续;后台模式立即返回任务 ID,完成时通过合成 User 消息自动回到主 Agent。多个前台 `Agent` 调用在同一步运行时,TUI 会合并展示,并为每个子 Agent 显示运行、等待、完成或失败状态以及已耗时长。子 Agent 体系细节见 [Agent 与子 Agent](../customization/agents.md)。 +**`Agent`** 将子任务委托给子 Agent 执行。必填参数:`prompt`(完整任务描述)和 `description`(3–5 个词的简短说明)。可选参数:`subagent_type`(默认 `coder`)、`resume`(恢复已有 Agent 的 ID,与 `subagent_type` 互斥)、`run_in_background`(默认 false)和 `model`(`"secondary"` 表示 `[secondary_model] model` 配置的次主力模型,`"primary"` 表示主模型;resume 时无效;次主力模型实验功能启用后可用)。显式 `model` 会覆盖所选 [Agent profile 的 `model_preference`](../customization/agents.md#agent-文件格式);两者均未设置时,已配置的次主力模型为默认值,未配置时则继承调用方模型。Agent 任务默认 2 小时超时,可通过 `config.toml` 的 `[subagent] timeout_ms`(`0` = 无超时,或 `KIMI_SUBAGENT_TIMEOUT_MS` 环境变量)配置,且在 print 模式(`kimi -p`)下默认无超时。前台模式下父 Agent 等待子 Agent 完成再继续;后台模式立即返回任务 ID,完成时通过合成 User 消息自动回到主 Agent。多个前台 `Agent` 调用在同一步运行时,TUI 会合并展示,并为每个子 Agent 显示运行、等待、完成或失败状态以及已耗时长。子 Agent 体系细节见 [Agent 与子 Agent](../customization/agents.md)。 -**`AgentSwarm`** 可以从共享的 `prompt_template` 和 `items` 数组启动子 Agent,也可以通过 `resume_agent_ids` 恢复已有子 Agent,或在一次调用中同时使用两者。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个新的子 Agent。传入 `subagent_type` 可以指定整个 swarm 中所有新启动的子 Agent 使用的 profile;省略时默认使用 `coder`。传入 `model`(在 `kimi web` 或实验性 `kimi -p` 下启用次主力模型实验功能后可用,TUI 下被忽略)可以让新启动的子 Agent 运行在 `[secondary_model] model` 配置的次主力模型(`"secondary"`)或主模型(`"primary"`)上。这项显式选择会覆盖所选 [Agent profile 的 `model_preference`](../customization/agents.md#agent-文件格式);两者均未设置时,已配置的次主力模型为默认值,未配置时则继承调用方模型。恢复的子 Agent 保持其原有模型。不传 `resume_agent_ids` 时,本工具要求至少 2 个 item;传入 `resume_agent_ids` 时,可以恢复 1 个或多个已有子 Agent。本工具最多支持 128 个子 Agent,会等待全部子 Agent 完成,并返回聚合报告。在 TUI 中,前台 swarm 会在输入框上方显示实时 `Agent swarm` 进度面板。若一次模型响应调用 `AgentSwarm`,该调用必须是该响应中的唯一工具调用;如需运行多个 swarm,应先调用一个 `AgentSwarm` 并等待结果,再调用下一个,若单个模板可以覆盖这些工作,也可以合并为一个 swarm。在 `manual` 权限模式下,未处于 swarm mode 时调用 `AgentSwarm` 会触发审批,除非已有权限规则允许;swarm mode 已开启时,`AgentSwarm` 本身会自动放行。权限规则只能按工具名 `AgentSwarm` 匹配,不支持 `AgentSwarm(swarm)` 这类参数模式。默认情况下,本工具会逐步提升并发且不设上限(立即启动 5 个子 Agent,之后每 700 毫秒再启动 1 个);将 `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` 设为正整数可限制该阶段同时运行的子 Agent 数量,不设置则表示不限制。若设置为非正整数的值,本次 AgentSwarm 调用会立即失败。 +**`AgentSwarm`** 可以从共享的 `prompt_template` 和 `items` 数组启动子 Agent,也可以通过 `resume_agent_ids` 恢复已有子 Agent,或在一次调用中同时使用两者。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个新的子 Agent。传入 `subagent_type` 可以指定整个 swarm 中所有新启动的子 Agent 使用的 profile;省略时默认使用 `coder`。传入 `model`(次主力模型实验功能启用后可用)可以让新启动的子 Agent 运行在 `[secondary_model] model` 配置的次主力模型(`"secondary"`)或主模型(`"primary"`)上。这项显式选择会覆盖所选 [Agent profile 的 `model_preference`](../customization/agents.md#agent-文件格式);两者均未设置时,已配置的次主力模型为默认值,未配置时则继承调用方模型。恢复的子 Agent 保持其原有模型。不传 `resume_agent_ids` 时,本工具要求至少 2 个 item;传入 `resume_agent_ids` 时,可以恢复 1 个或多个已有子 Agent。本工具最多支持 128 个子 Agent,会等待全部子 Agent 完成,并返回聚合报告。在 TUI 中,前台 swarm 会在输入框上方显示实时 `Agent swarm` 进度面板。若一次模型响应调用 `AgentSwarm`,该调用必须是该响应中的唯一工具调用;如需运行多个 swarm,应先调用一个 `AgentSwarm` 并等待结果,再调用下一个,若单个模板可以覆盖这些工作,也可以合并为一个 swarm。在 `manual` 权限模式下,未处于 swarm mode 时调用 `AgentSwarm` 会触发审批,除非已有权限规则允许;swarm mode 已开启时,`AgentSwarm` 本身会自动放行。权限规则只能按工具名 `AgentSwarm` 匹配,不支持 `AgentSwarm(swarm)` 这类参数模式。默认情况下,本工具会逐步提升并发且不设上限(立即启动 5 个子 Agent,之后每 700 毫秒再启动 1 个);将 `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` 设为正整数可限制该阶段同时运行的子 Agent 数量,不设置则表示不限制。若设置为非正整数的值,本次 AgentSwarm 调用会立即失败。 **`AskUserQuestion`** 以结构化多选题的形式向用户提问,适用于需要消歧或选择方案的场景。`questions` 参数接受 1–4 道题,每道题需提供 `question`(以 `?` 结尾)、`options`(2–4 个选项,每项含 `label` 和 `description`)以及可选的 `header`(最多 12 字符)和 `multi_select`(默认 false)。系统自动附加"其他"选项。`background` 为 true 时启动后台问题任务并立即返回任务 ID。宿主未实现交互式提问能力时返回失败提示,Agent 应改为在文本回复中直接提问。 diff --git a/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.md b/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.md index f9b694672..859011424 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.md +++ b/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.md @@ -20,7 +20,7 @@ echo "$HOME/.kimi-code" Use the first line when it is non-empty; otherwise use the second line. In the rest of this skill, `` means that resolved root — **never assume `~/.kimi-code`**. -- **`config.toml`** — agent / runtime settings: `default_model`, `providers`, `models`, `thinking`, `permission`, `hooks`, `loop_control`, etc. +- **`config.toml`** — agent / runtime settings: `default_model`, `secondary_model` (subagent model), `providers`, `models`, `thinking`, `permission`, `hooks`, `loop_control`, etc. - **`tui.toml`** — terminal-UI / client preferences: `theme`, `[editor].command`, `[notifications]`, `[upgrade].auto_install` (auto-update). These can usually also be changed with the interactive commands `/config`, `/theme`, `/editor`, which is easier — prefer pointing the user at those. The "read → copy → Edit → validate → back up → overwrite" flow below applies to both files; only **which reload command applies** differs (see Capability 4). diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index f0799f7f5..725186b09 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -31,6 +31,7 @@ export class ConfigState { private _cwd: string; private _modelAlias: string | undefined; private _profileName: string | undefined; + private _subagentNames: readonly string[] | undefined; // `undefined` until an effort has actually been resolved: a bare modelAlias // update must then fall through to the model's own default instead of // treating the never-chosen initial "off" as an explicit user choice. @@ -99,6 +100,9 @@ export class ConfigState { if (changed.profileName) { this._profileName = changed.profileName; } + if (changed.subagentNames !== undefined) { + this._subagentNames = [...changed.subagentNames]; + } if (unforcedThinkingEffort !== undefined && thinkingEffort !== undefined) { this._unforcedThinkingEffort = unforcedThinkingEffort; this._thinkingEffort = thinkingEffort; @@ -137,6 +141,7 @@ export class ConfigState { modelAlias: this._modelAlias, modelCapabilities: resolved?.modelCapabilities ?? UNKNOWN_CAPABILITY, profileName: this.profileName, + subagentNames: this.subagentNames, thinkingEffort: this.thinkingEffort, systemPrompt: this.systemPrompt, }; @@ -249,6 +254,10 @@ export class ConfigState { return this._profileName; } + get subagentNames(): readonly string[] | undefined { + return this._subagentNames; + } + get systemPrompt(): string { return this._systemPrompt; } diff --git a/packages/agent-core/src/agent/config/types.ts b/packages/agent-core/src/agent/config/types.ts index 7b4d731db..bb29ca244 100644 --- a/packages/agent-core/src/agent/config/types.ts +++ b/packages/agent-core/src/agent/config/types.ts @@ -6,6 +6,7 @@ export interface AgentConfigData { modelAlias?: string; modelCapabilities: ModelCapability; profileName?: string; + subagentNames?: readonly string[]; thinkingEffort: string; systemPrompt: string; } @@ -14,6 +15,7 @@ export type AgentConfigUpdateData = Partial<{ cwd: string; modelAlias: string; profileName: string; + subagentNames: readonly string[]; thinkingEffort: string; systemPrompt: string; }>; diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 7786c5eb0..db769d895 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -118,7 +118,13 @@ export class Agent { return this._kaos; } - readonly kimiConfig?: KimiConfig; + /** + * The session config snapshot this agent reads (loop control, subagent + * binding descriptions, ...). Mutable via {@link updateKimiConfig} so the + * session can push live config updates (e.g. a `/secondary_model` switch) + * to already-instantiated agents. + */ + kimiConfig?: KimiConfig; readonly homedir?: string; readonly mediaOriginalsDir?: string; readonly rpc?: Partial; @@ -435,10 +441,19 @@ export class Agent { profile: ResolvedAgentProfile, context?: PreparedSystemPromptContext, brandHome?: string, + subagentNames?: readonly string[], ): void { this.setActiveProfile(profile, brandHome); - this.updateSystemPromptFromProfile(profile, context); - this.tools.setActiveTools(profile.tools); + this.updateSystemPromptFromProfile(profile, context, subagentNames); + this.tools.setActiveTools(profile.tools, profile.disallowedTools); + } + + /** Push a refreshed session config snapshot and rebuild config-dependent builtin tools. */ + updateKimiConfig(config: KimiConfig | undefined): void { + this.kimiConfig = config; + if (this.config.hasProvider) { + this.tools.refreshBuiltinTools(); + } } setActiveProfile(profile: ResolvedAgentProfile, brandHome?: string): void { @@ -465,6 +480,7 @@ export class Agent { private updateSystemPromptFromProfile( profile: ResolvedAgentProfile, context?: PreparedSystemPromptContext, + subagentNames?: readonly string[], ): void { const systemPrompt = profile.systemPrompt({ osEnv: this.kaos.osEnv, @@ -474,7 +490,7 @@ export class Agent { agentsMd: context?.agentsMd, additionalDirsInfo: context?.additionalDirsInfo, }); - this.config.update({ profileName: profile.name, systemPrompt }); + this.config.update({ profileName: profile.name, systemPrompt, subagentNames }); } async resume(options?: AgentRecordsReplayOptions): Promise<{ warning?: string }> { diff --git a/packages/agent-core/src/agent/records/index.ts b/packages/agent-core/src/agent/records/index.ts index 8a1df676a..29511a738 100644 --- a/packages/agent-core/src/agent/records/index.ts +++ b/packages/agent-core/src/agent/records/index.ts @@ -121,7 +121,7 @@ function restoreAgentRecord(agent: Agent, input: AgentRecord): void { // v2-engine wires may omit `names` (= every tool active); there is no // state to restore in that case, and calling setActiveTools(undefined) // would throw and wedge the whole resume. - if (Array.isArray(input.names)) agent.tools.setActiveTools(input.names); + if (Array.isArray(input.names)) agent.tools.setActiveTools(input.names, input.disallowedNames); return; case 'tools.update_store': agent.tools.updateStore(input.key, input.value); diff --git a/packages/agent-core/src/agent/records/types.ts b/packages/agent-core/src/agent/records/types.ts index 77310021f..e9c1e1b24 100644 --- a/packages/agent-core/src/agent/records/types.ts +++ b/packages/agent-core/src/agent/records/types.ts @@ -81,6 +81,12 @@ export interface AgentRecordEvents { }; 'tools.set_active_tools': { names: readonly string[]; + /** + * Profile denylist applied on top of `names` (agentfile + * `disallowedTools`). Optional for backwards compatibility: wires written + * before deny support (and v2-engine wires) carry no deny state. + */ + disallowedNames?: readonly string[]; }; 'usage.record': { diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index 818571a7a..768b48766 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -14,8 +14,8 @@ import type { McpConnectionManager, McpServerEntry } from '../../mcp'; import { mcpResultToExecutableOutput } from '../../mcp/output'; import { isMcpToolName, qualifyMcpToolName } from '../../mcp/tool-naming'; import type { MCPClient, MCPToolDefinition } from '../../mcp/types'; -import { DEFAULT_AGENT_PROFILES } from '../../profile'; import { resolveSubagentTimeoutMs } from '../../session/subagent-host'; +import { buildSubagentModelDescriptions } from '../../session/subagent-binding'; import { extendWorkspaceWithSkillRoots } from '../../skill'; import { fingerprint } from '../llm-request-logger'; import * as b from '../../tools/builtin'; @@ -56,6 +56,13 @@ export class ToolManager { protected enabledTools: Set = new Set(); /** Glob patterns (e.g. `mcp__*`, `mcp__github__*`) gating which MCP tools the profile exposes. */ private mcpAccessPatterns: string[] = []; + /** + * Exact builtin/user tool names the profile denies, evaluated on top of the + * allowlist result (`enabledTools`). + */ + private disabledTools: Set = new Set(); + /** Glob patterns (`mcp__…`) the profile denies, evaluated on top of `mcpAccessPatterns`. */ + private mcpDenyPatterns: string[] = []; /** * Defer-window lead for the loaded-tools ledger: names marked loaded whose * schema message may still sit in the context's deferred queue (an open tool @@ -522,15 +529,18 @@ export class ToolManager { }); } - setActiveTools(names: readonly string[]): void { + setActiveTools(names: readonly string[], disallowedNames?: readonly string[]): void { this.agent.records.logRecord({ type: 'tools.set_active_tools', names, + disallowedNames, }); // MCP entries are glob patterns gated separately; the rest are exact // builtin/user tool names. The split keeps every caller on one string[]. this.enabledTools = new Set(names.filter((name) => !isMcpToolName(name))); this.mcpAccessPatterns = names.filter((name) => isMcpToolName(name)); + this.disabledTools = new Set((disallowedNames ?? []).filter((name) => !isMcpToolName(name))); + this.mcpDenyPatterns = (disallowedNames ?? []).filter((name) => isMcpToolName(name)); // Builtin construction reads the enabled set (Bash/Agent bake // `allowBackground` from the Task* trio), and the constructor may already // have built the map while the enabled set was still empty. The lazy @@ -547,7 +557,15 @@ export class ToolManager { } private isMcpToolEnabled(name: string): boolean { - return this.mcpAccessPatterns.some((pattern) => picomatch.isMatch(name, pattern)); + return ( + this.mcpAccessPatterns.some((pattern) => picomatch.isMatch(name, pattern)) && + !this.mcpDenyPatterns.some((pattern) => picomatch.isMatch(name, pattern)) + ); + } + + /** An exact builtin/user tool name survives when allowed and not denied. */ + private isExactToolEnabled(name: string): boolean { + return this.enabledTools.has(name) && !this.disabledTools.has(name); } /** @@ -571,7 +589,7 @@ export class ToolManager { [...this.mcpTools.keys()].filter((name) => this.isMcpToolEnabled(name)), ); for (const name of this.deferredUserTools) { - if (this.userTools.has(name) && this.enabledTools.has(name)) names.add(name); + if (this.userTools.has(name) && this.isExactToolEnabled(name)) names.add(name); } return [...names].toSorted((a, b) => a.localeCompare(b)); } @@ -632,7 +650,7 @@ export class ToolManager { return ( this.deferredUserTools.has(name) && this.userTools.has(name) && - this.enabledTools.has(name) + this.isExactToolEnabled(name) ); } @@ -668,7 +686,7 @@ export class ToolManager { */ getDynamicToolSchema(name: string): Tool | undefined { const userTool = - this.deferredUserTools.has(name) && this.enabledTools.has(name) + this.deferredUserTools.has(name) && this.isExactToolEnabled(name) ? this.userTools.get(name) : undefined; const mcpTool = this.isMcpToolEnabled(name) ? this.mcpTools.get(name)?.tool : undefined; @@ -718,10 +736,13 @@ export class ToolManager { name: tool.name, description: tool.description, // select_tools is always registered but only offered while the - // disclosure gate is open (see loopTools); report that live state. + // disclosure gate is open and the denylist does not name it (see + // loopTools); report that live state. active: - this.enabledTools.has(tool.name) || - (tool.name === b.SELECT_TOOLS_TOOL_NAME && this.agent.toolSelectEnabled), + this.isExactToolEnabled(tool.name) || + (tool.name === b.SELECT_TOOLS_TOOL_NAME && + this.agent.toolSelectEnabled && + !this.disabledTools.has(tool.name)), source: 'builtin', }; } @@ -729,7 +750,7 @@ export class ToolManager { yield { name: tool.name, description: tool.description, - active: this.enabledTools.has(tool.name), + active: this.isExactToolEnabled(tool.name), source: 'user', }; } @@ -767,9 +788,9 @@ export class ToolManager { this.agent.skills?.registry.getSkillRoots() ?? [], ); const allowBackground = - this.enabledTools.has('TaskList') && - this.enabledTools.has('TaskOutput') && - this.enabledTools.has('TaskStop'); + this.isExactToolEnabled('TaskList') && + this.isExactToolEnabled('TaskOutput') && + this.isExactToolEnabled('TaskStop'); const goalToolsEnabled = this.agent.type === 'main'; this.builtinTools = new Map( [ @@ -821,11 +842,17 @@ export class ToolManager { new b.AgentTool( this.agent.subagentHost, background, - DEFAULT_AGENT_PROFILES['agent']?.subagents, + this.agent.subagentHost.delegatableSubagents(this.agent.config.profileName), { allowBackground, log: this.agent.log, subagentTimeoutMs: resolveSubagentTimeoutMs(this.agent.kimiConfig?.subagent?.timeoutMs), + showModelPreferences: this.agent.experimentalFlags.enabled('secondary-model'), + subagentModelDescription: buildSubagentModelDescriptions( + this.agent.kimiConfig, + this.agent.experimentalFlags, + this.agent.config.modelAlias, + ), }, ), this.agent.subagentHost && @@ -833,6 +860,11 @@ export class ToolManager { this.agent.subagentHost, this.agent.swarmMode, resolveSubagentTimeoutMs(this.agent.kimiConfig?.subagent?.timeoutMs), + buildSubagentModelDescriptions( + this.agent.kimiConfig, + this.agent.experimentalFlags, + this.agent.config.modelAlias, + ), ), toolServices?.webSearcher && new b.WebSearchTool(toolServices.webSearcher), toolServices?.urlFetcher && new b.FetchURLTool(toolServices.urlFetcher), @@ -951,15 +983,24 @@ export class ToolManager { const loadedSet = disclosure ? this.loadedDynamicToolNames() : undefined; const enabledNames = loadedSet === undefined - ? [...this.enabledTools] + ? [...this.enabledTools].filter((name) => !this.disabledTools.has(name)) : [...this.enabledTools].filter( - (name) => !this.deferredUserTools.has(name) || loadedSet.has(name), + (name) => + !this.disabledTools.has(name) && + (!this.deferredUserTools.has(name) || loadedSet.has(name)), ); const mcpNames = loadedSet === undefined ? enabledMcpNames : enabledMcpNames.filter((name) => loadedSet.has(name)); - const selectToolsName = disclosure ? [b.SELECT_TOOLS_TOOL_NAME] : []; + // The disclosure gate decides exposure, but the denylist still wins: a + // profile disallowedTools entry naming select_tools keeps it out of the + // table (mirrors agent-core-v2 isToolActiveForDisclosure, which applies + // the deny layers but not the allowlist to select_tools). + const selectToolsName = + disclosure && !this.disabledTools.has(b.SELECT_TOOLS_TOOL_NAME) + ? [b.SELECT_TOOLS_TOOL_NAME] + : []; return uniq([...enabledNames, ...selectToolsName, ...mcpNames]) .toSorted((a, b) => a.localeCompare(b)) // select_tools is exposed exclusively through the disclosure gate — a diff --git a/packages/agent-core/src/config/index.ts b/packages/agent-core/src/config/index.ts index 7b05879bb..5542accbc 100644 --- a/packages/agent-core/src/config/index.ts +++ b/packages/agent-core/src/config/index.ts @@ -7,4 +7,5 @@ export * from './resolve'; export * from './schema'; export * from './toml'; export * from './env-model'; +export * from './secondary-model'; export * from './workspace-local'; diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 8b7baea6c..0e4b7bcaa 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -93,6 +93,19 @@ export const ModelAliasSchema = ModelAliasBaseSchema.extend({ export type ModelAlias = z.infer; +/** + * The secondary-model recipe (`[secondary_model]` on disk): `model` points at + * a `[models]` entry and every remaining field is a subagent-only patch, + * materialized into a synthesized derived model entry at runtime (see + * `config/secondary-model.ts`). `default_effort` doubles as the subagent + * thinking effort. + */ +export const SecondaryModelConfigSchema = ModelAliasOverrideSchema.extend({ + model: z.string().min(1).optional(), +}); + +export type SecondaryModelConfig = z.infer; + export const ThinkingConfigSchema = z.object({ enabled: z.boolean().optional(), effort: z.string().optional(), @@ -338,9 +351,11 @@ export const KimiConfigSchema = z.object({ services: ServicesConfigSchema.optional(), mergeAllAvailableSkills: z.boolean().optional(), extraSkillDirs: z.array(z.string()).optional(), + extraAgentDirs: z.array(z.string()).optional(), loopControl: LoopControlSchema.optional(), background: BackgroundConfigSchema.optional(), subagent: SubagentConfigSchema.optional(), + secondaryModel: SecondaryModelConfigSchema.optional(), mcp: McpConfigSchema.optional(), image: ImageConfigSchema.optional(), modelCatalog: ModelCatalogConfigSchema.optional(), @@ -358,6 +373,7 @@ const PermissionConfigPatchSchema = PermissionConfigSchema.partial(); const LoopControlPatchSchema = LoopControlSchema.partial(); const BackgroundConfigPatchSchema = BackgroundConfigSchema.partial(); const SubagentConfigPatchSchema = SubagentConfigSchema.partial(); +const SecondaryModelConfigPatchSchema = SecondaryModelConfigSchema.partial(); const McpConfigPatchSchema = McpConfigSchema.partial(); const ImageConfigPatchSchema = ImageConfigSchema.partial(); const ModelCatalogConfigPatchSchema = ModelCatalogConfigSchema.partial(); @@ -384,9 +400,11 @@ export const KimiConfigPatchSchema = z services: ServicesConfigPatchSchema.optional(), mergeAllAvailableSkills: z.boolean().optional(), extraSkillDirs: z.array(z.string()).optional(), + extraAgentDirs: z.array(z.string()).optional(), loopControl: LoopControlPatchSchema.optional(), background: BackgroundConfigPatchSchema.optional(), subagent: SubagentConfigPatchSchema.optional(), + secondaryModel: SecondaryModelConfigPatchSchema.optional(), mcp: McpConfigPatchSchema.optional(), image: ImageConfigPatchSchema.optional(), modelCatalog: ModelCatalogConfigPatchSchema.optional(), diff --git a/packages/agent-core/src/config/secondary-model.ts b/packages/agent-core/src/config/secondary-model.ts new file mode 100644 index 000000000..507e36b71 --- /dev/null +++ b/packages/agent-core/src/config/secondary-model.ts @@ -0,0 +1,154 @@ +import type { + KimiConfig, + ModelAlias, + ModelAliasOverrides, + SecondaryModelConfig, +} from './schema'; + +/** + * Secondary-model runtime overlay. + * + * `[secondary_model]` is a recipe: `model` points at a `[models]` entry and + * every remaining field is a subagent-only patch. When patch fields exist, + * {@link applySecondaryModelConfig} synthesizes the derived model entry + * ({@link SECONDARY_DERIVED_MODEL_ALIAS}) into the in-memory `models` view — a + * copy of the pointed entry with the patch merged into its `overrides` block + * (patch wins conflicts) — so subagent binding resolves it through the + * standard alias path, riding the same `effectiveModelAlias` merge as any + * `models.*.overrides`. With no patch fields, subagents bind the pointed + * entry directly and nothing is synthesized. + * + * The `KIMI_SECONDARY_MODEL` / `KIMI_SECONDARY_EFFORT` env vars override + * `model` / `default_effort` in memory only: like the `KIMI_MODEL_*` + * synthesized entries, the derived entry and the env-injected fields must + * never reach `config.toml`. {@link stripSecondaryModelConfig} is the write + * path mirror, wired next to `stripEnvModelConfig`. + */ + +export const SECONDARY_DERIVED_MODEL_ALIAS = '__secondary__'; +export const SECONDARY_MODEL_ENV = 'KIMI_SECONDARY_MODEL'; +export const SECONDARY_MODEL_EFFORT_ENV = 'KIMI_SECONDARY_EFFORT'; + +type Env = Readonly>; + +function trimmed(value: string | undefined): string | undefined { + const t = value?.trim(); + return t === undefined || t.length === 0 ? undefined : t; +} + +/** + * The patch half of the recipe: every field except `model`. Returns + * `undefined` when no patch field is set — the signal that subagents bind the + * pointed entry directly and no derived entry is synthesized. + */ +export function secondaryModelPatch( + secondary: SecondaryModelConfig | undefined, +): ModelAliasOverrides | undefined { + if (secondary === undefined) return undefined; + const { model: _model, ...rawPatch } = secondary; + const patch = Object.fromEntries( + Object.entries(rawPatch).filter(([, value]) => value !== undefined), + ) as ModelAliasOverrides; + return Object.keys(patch).length > 0 ? patch : undefined; +} + +/** + * Apply the secondary-model runtime view: env overrides for the recipe, then + * the derived-entry synthesis. Returns the config unchanged when neither + * applies. Nothing is synthesized when `secondary.model` is unset or the + * pointed entry does not exist (the session warning reports the dangling + * pointer; spawn fails with the wrapped error). + */ +export function applySecondaryModelConfig(config: KimiConfig, env: Env = process.env): KimiConfig { + let secondary = config.secondaryModel; + const envModel = trimmed(env[SECONDARY_MODEL_ENV]); + const envEffort = trimmed(env[SECONDARY_MODEL_EFFORT_ENV]); + if (envModel !== undefined || envEffort !== undefined) { + secondary = { + ...secondary, + model: envModel ?? secondary?.model, + defaultEffort: envEffort ?? secondary?.defaultEffort, + }; + } + + let next = secondary === config.secondaryModel ? config : { ...config, secondaryModel: secondary }; + + const patch = secondaryModelPatch(secondary); + const baseId = secondary?.model; + if (patch === undefined || baseId === undefined || baseId === SECONDARY_DERIVED_MODEL_ALIAS) { + return next; + } + const base = next.models?.[baseId]; + if (base === undefined) return next; + + const { overrides: baseOverrides, ...baseFields } = base; + const derived: ModelAlias = { + ...baseFields, + overrides: { ...baseOverrides, ...patch }, + }; + return { + ...next, + models: { ...next.models, [SECONDARY_DERIVED_MODEL_ALIAS]: derived }, + }; +} + +/** + * Remove the runtime-only secondary-model state before a config is persisted + * to disk: the synthesized derived entry (never a legitimate on-disk entry, + * mirroring the v2 overlay which strips a user-configured entry under the + * reserved id all the same), a `default_model` pointer at the derived entry + * (restored from raw so it cannot dangle after the recipe is removed), and + * the env-injected recipe fields (restored from raw when the value being + * written still equals the env value, so a `getConfig` -> `setConfig` + * round-trip cannot persist shell overrides, while a genuinely new selection + * — e.g. a `/secondary_model` pick made under `KIMI_SECONDARY_MODEL` — does + * reach the disk, mirroring the pointer check in `stripEnvModelConfig`). + */ +export function stripSecondaryModelConfig( + config: KimiConfig, + env: Env = process.env, +): KimiConfig { + let next = config; + + if (next.models !== undefined && SECONDARY_DERIVED_MODEL_ALIAS in next.models) { + const models = { ...next.models }; + delete models[SECONDARY_DERIVED_MODEL_ALIAS]; + next = { ...next, models }; + } + + if (next.defaultModel === SECONDARY_DERIVED_MODEL_ALIAS) { + const rawDefault = config.raw?.['default_model']; + next = { + ...next, + defaultModel: typeof rawDefault === 'string' ? rawDefault : undefined, + }; + } + + const envModel = trimmed(env[SECONDARY_MODEL_ENV]); + const envEffort = trimmed(env[SECONDARY_MODEL_EFFORT_ENV]); + if ((envModel !== undefined || envEffort !== undefined) && next.secondaryModel !== undefined) { + const raw = isPlainObject(config.raw?.['secondary_model']) ? config.raw['secondary_model'] : {}; + const restored = { ...next.secondaryModel }; + // Restore from raw only when the value being written still IS the env + // value (a round-trip of the overlaid runtime view). A different value is + // a deliberate new selection and must persist. + if (envModel !== undefined && restored.model === envModel) { + if (typeof raw['model'] === 'string') restored.model = raw['model']; + else delete restored.model; + } + if (envEffort !== undefined && restored.defaultEffort === envEffort) { + if (typeof raw['default_effort'] === 'string') restored.defaultEffort = raw['default_effort']; + else delete restored.defaultEffort; + } + next = { + ...next, + secondaryModel: Object.keys(restored).length > 0 ? restored : undefined, + }; + } + + return next; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index ff1285a6d..2aa143b09 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -4,6 +4,7 @@ import { dirname } from 'pathe'; import { ErrorCodes, KimiError } from '#/errors'; import { applyEnvModelConfig, stripEnvModelConfig } from './env-model'; +import { applySecondaryModelConfig, stripSecondaryModelConfig } from './secondary-model'; import { KimiConfigSchema, formatConfigValidationError, @@ -20,6 +21,7 @@ import { type OAuthRef, type PermissionConfig, type ProviderConfig, + type SecondaryModelConfig, type ServicesConfig, type SubagentConfig, type ThinkingConfig, @@ -104,7 +106,7 @@ export function loadRuntimeConfig( filePath: string, env: Readonly> = process.env, ): KimiConfig { - return applyEnvModelConfig(readConfigFile(filePath), env); + return applySecondaryModelConfig(applyEnvModelConfig(readConfigFile(filePath), env), env); } export interface RuntimeConfigLoadResult { @@ -199,6 +201,8 @@ export function loadRuntimeConfigSafe( `Ignoring KIMI_MODEL_* environment overrides: ${describeUnknownError(error)}`, ); } + // Never throws: the secondary overlay only copies already-validated entries. + config = applySecondaryModelConfig(config, env); return { config, fileWarnings, envWarnings, fileError }; } @@ -321,6 +325,8 @@ export function transformTomlData(data: Record): Record): Record { - // Final guard: never persist the env-synthesized model/provider to disk, - // even if a caller passes back the runtime config as a patch (see - // stripEnvModelConfig / the getConfig -> setConfig round-trip). - const validated = validateConfig(stripEnvModelConfig(config)); + // Final guard: never persist the env-synthesized model/provider or the + // secondary-model runtime view to disk, even if a caller passes back the + // runtime config as a patch (see stripEnvModelConfig / + // stripSecondaryModelConfig / the getConfig -> setConfig round-trip). + const validated = validateConfig(stripSecondaryModelConfig(stripEnvModelConfig(config))); await mkdir(dirname(filePath), { recursive: true, mode: 0o700 }); await atomicWrite(filePath, `${stringifyToml(configToTomlData(validated))}\n`); } @@ -486,6 +493,7 @@ export function configToTomlData(config: KimiConfig): Record { 'defaultPlanMode', 'mergeAllAvailableSkills', 'extraSkillDirs', + 'extraAgentDirs', 'telemetry', ]; for (const key of scalarFields) { @@ -499,6 +507,7 @@ export function configToTomlData(config: KimiConfig): Record { setSection(out, 'loop_control', config.loopControl, loopControlToToml); setSection(out, 'background', config.background, backgroundToToml); setSection(out, 'subagent', config.subagent, subagentToToml); + setSection(out, 'secondary_model', config.secondaryModel, secondaryModelToToml); setSection(out, 'mcp', config.mcp, mcpToToml); setSection(out, 'image', config.image, imageToToml); setSection(out, 'experimental', config.experimental, experimentalToToml); @@ -690,6 +699,21 @@ function subagentToToml(subagent: SubagentConfig, rawSubagent: unknown): Record< return out; } +function secondaryModelToToml( + secondaryModel: SecondaryModelConfig, + rawSecondaryModel: unknown, +): Record { + const out = cloneRecord(rawSecondaryModel); + for (const [key, value] of Object.entries(secondaryModel)) { + if (key === 'capabilities' && Array.isArray(value)) { + out[camelToSnake(key)] = [...value]; + } else { + setDefined(out, camelToSnake(key), value); + } + } + return out; +} + function mcpToToml(mcp: McpConfig, rawMcp: unknown): Record { const out = cloneRecord(rawMcp); for (const [key, value] of Object.entries(mcp)) { diff --git a/packages/agent-core/src/flags/registry.ts b/packages/agent-core/src/flags/registry.ts index dbec75b80..55903c6d0 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: 'secondary-model', + title: 'Secondary model for subagents', + description: + 'Let newly spawned subagents use a separately configured secondary model by default, with an explicit primary-model override for quality-sensitive tasks.', + env: 'KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', + default: false, + surface: 'core', + }, ] as const satisfies readonly FlagDefinitionInput[]; /** Literal union of registered flag ids. */ diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 46b97172a..52a5c66da 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -32,6 +32,7 @@ export type { SessionLogHandle, } from './logging/types'; export { USER_PROMPT_ORIGIN } from './agent/context'; +export { parseAgentFileText, resolveAgentPath } from './profile/agentfile'; export { renderToolResultForModel } from './agent/context/tool-result-render'; export type { RenderableToolResult } from './agent/context/tool-result-render'; export type { diff --git a/packages/agent-core/src/profile/agentfile/catalog.ts b/packages/agent-core/src/profile/agentfile/catalog.ts new file mode 100644 index 000000000..9b78c214e --- /dev/null +++ b/packages/agent-core/src/profile/agentfile/catalog.ts @@ -0,0 +1,403 @@ +/** + * Session-level agent profile catalog. + * + * Merges the builtin (code-embedded) profiles with the file-backed sources + * (user / extra / project / explicit) by priority, requiring an explicit + * opt-in (`override: true`) before a file replaces a same-name builtin. The + * merged view always contains the builtin profiles (seeded at construction); + * file profiles appear once `ready` resolves. A failing `explicit` source (an + * invalid `--agent-file`) rejects `ready` so session creation surfaces the + * error; a failing directory source degrades to warnings, so directory + * problems never poison the session. + * + * After merging, the catalog links the delegation graph: a file profile's + * `subagents` allowlist resolves against the merged set (an omitted allowlist + * means "any type"), and the builtin default profile's subagent set extends + * with every file-defined profile so the main agent can delegate to custom + * agents. + * + * Semantics mirror the v2 engine's agentFileCatalog domain + * (`packages/agent-core-v2/src/app/agentFileCatalog/`, e.g. `agentProfileSource.ts` + * and `userFileAgentSource.ts`) — keep merge/override/delegation behavior in + * sync across both engines. + */ + +import { DEFAULT_AGENT_PROFILES } from '../default'; +import type { ResolvedAgentProfile } from '../types'; + +import { discoverAgentFiles } from './discovery'; +import { agentProfileFromFile } from './from-file'; +import { resolveAgentPath } from './paths'; +import { configuredAgentRoots, projectAgentRoots, userAgentRoots } from './roots'; +import { loadSystemMdDefinition, systemMdProfile } from './system-file'; +import { describeInactiveToolPattern, findInactiveToolPatterns } from './validate'; +import { + AgentProfileCatalogSnapshotSchema, + type AgentFileDefinition, + type AgentFileSource, + type AgentProfileCatalogSnapshot, +} from './types'; +import { promises as fs } from 'node:fs'; +import { parseAgentFileText } from './parser'; + +export interface SessionAgentCatalogOptions { + readonly workDir: string; + /** Brand data dir (`KIMI_CODE_HOME`, default `~/.kimi-code`). */ + readonly brandHomeDir: string; + /** OS home dir, for `~/.agents/agents` and `~` expansion. */ + readonly osHomeDir: string; + readonly extraDirs?: readonly string[]; + readonly explicitFiles?: readonly string[]; + readonly warn?: (message: string, error?: unknown) => void; +} + +const SOURCE_PRIORITY: Readonly> = { + user: 10, + extra: 20, + project: 30, + explicit: 40, +}; + +export const DEFAULT_AGENT_PROFILE_NAME = 'agent'; + +/** Exact tool names known to the builtin profiles (MCP glob entries excluded). */ +const KNOWN_BUILTIN_TOOL_NAMES: ReadonlySet = new Set( + Object.values(DEFAULT_AGENT_PROFILES).flatMap((profile) => + profile.tools.filter((tool) => !tool.startsWith('mcp__')), + ), +); + +function isKnownBuiltinToolName(name: string): boolean { + return KNOWN_BUILTIN_TOOL_NAMES.has(name); +} + +interface FileProfileEntry { + readonly kind: 'file' | 'system-prompt'; + readonly definition: AgentFileDefinition; + readonly profile: ResolvedAgentProfile; + readonly priority: number; + readonly override: boolean; +} + +export class SessionAgentProfileCatalog { + private merged: Map; + private readonly readyPromise: Promise; + private snapshotValue: AgentProfileCatalogSnapshot | undefined; + + constructor(private readonly options: SessionAgentCatalogOptions) { + this.merged = new Map(Object.entries(DEFAULT_AGENT_PROFILES)); + this.readyPromise = this.load(); + // Keep an un-awaited rejection from crashing the process; createMain / + // spawn awaiters see the error through `ready`. + void this.readyPromise.catch(() => undefined); + } + + get ready(): Promise { + return this.readyPromise; + } + + get(name: string): ResolvedAgentProfile | undefined { + return this.merged.get(name); + } + + getDefault(): ResolvedAgentProfile { + const profile = this.get(DEFAULT_AGENT_PROFILE_NAME); + if (profile === undefined) { + throw new Error( + `Default agent profile "${DEFAULT_AGENT_PROFILE_NAME}" is not registered`, + ); + } + return profile; + } + + list(): readonly ResolvedAgentProfile[] { + return [...this.merged.values()]; + } + + snapshot(): AgentProfileCatalogSnapshot | undefined { + return this.snapshotValue === undefined + ? undefined + : AgentProfileCatalogSnapshotSchema.parse(this.snapshotValue); + } + + /** Replace live discovery with the file-backed catalog bound at creation. */ + restoreSnapshot(snapshot: AgentProfileCatalogSnapshot): void { + const restored = AgentProfileCatalogSnapshotSchema.parse(snapshot); + this.merged = new Map(Object.entries(DEFAULT_AGENT_PROFILES)); + + const builtinDefault = this.getDefault(); + const systemMd = + restored.systemPromptTemplate === undefined + ? undefined + : this.snapshotSystemDefinition(restored.systemPromptTemplate); + const effectiveDefault = + systemMd === undefined ? builtinDefault : systemMdProfile(systemMd, builtinDefault); + const entries: FileProfileEntry[] = []; + if (systemMd !== undefined) { + entries.push(this.systemMdEntry(systemMd, effectiveDefault)); + } + for (const profile of restored.profiles) { + const definition: AgentFileDefinition = { + name: profile.name, + description: profile.description, + whenToUse: profile.whenToUse, + override: true, + tools: profile.tools, + disallowedTools: profile.disallowedTools, + subagents: profile.subagents, + modelPreference: profile.modelPreference, + prompt: profile.prompt, + path: ``, + source: 'explicit', + }; + entries.push(this.entryFromDefinition(definition, effectiveDefault)); + } + + this.applyFileEntries(entries); + this.snapshotValue = restored; + } + + /** + * The subagent types `callerProfileName` may delegate to: the caller's own + * linked set, falling back to the default profile's set when the caller + * declares none (mirroring the historical lookup against the builtin + * `agent` profile). + */ + delegatableSubagents(callerProfileName?: string): Record { + const caller = callerProfileName === undefined ? undefined : this.merged.get(callerProfileName); + const record = caller?.subagents ?? this.getDefault().subagents; + return record ?? {}; + } + + private async load(): Promise { + const warn = this.warn; + const entries: FileProfileEntry[] = []; + + // ── Directory sources (non-fatal) ──────────────────────────────── + // Each source scans its own roots: a same-named file in a higher- + // priority source must shadow, not swallow, the lower-priority one. + const [userRoots, projectRoots, extraRoots] = await Promise.all([ + userAgentRoots(this.options.brandHomeDir, this.options.osHomeDir, warn), + projectAgentRoots(this.options.workDir, warn), + configuredAgentRoots( + this.options.extraDirs ?? [], + this.options.workDir, + this.options.osHomeDir, + 'extra', + warn, + ), + ]); + + // SYSTEM.md is pushed first: within the user source it wins the `agent` + // name over directory files (first candidate per priority wins). + const systemMd = await loadSystemMdDefinition(this.options.brandHomeDir, (message) => + warn?.(message), + ); + + // The base every file profile's `${base_prompt}` renders against — the + // "effective default": the SYSTEM.md override when present, else the + // builtin default. This slot is structurally disjoint from the merge + // (only ever SYSTEM.md or builtin), so a file profile that overrides the + // default can never recurse into itself through `${base_prompt}`. The + // chain is at most: agent file → SYSTEM.md → builtin default. + const builtinDefault = this.merged.get(DEFAULT_AGENT_PROFILE_NAME) ?? this.getDefault(); + const effectiveDefault = + systemMd !== undefined ? systemMdProfile(systemMd, builtinDefault) : builtinDefault; + + if (systemMd !== undefined) { + entries.push(this.systemMdEntry(systemMd, effectiveDefault)); + } + + for (const roots of [userRoots, extraRoots, projectRoots]) { + if (roots.length === 0) continue; + const discovered = await discoverAgentFiles(roots, warn); + for (const definition of discovered.agents) { + this.warnInactivePatterns(definition); + entries.push(this.entryFromDefinition(definition, effectiveDefault)); + } + } + + // ── Explicit source (fatal) ────────────────────────────────────── + // Match v2's per-source merge semantics: when several explicit files + // declare the same profile name, the last file replaces the earlier one. + const explicitEntries = new Map(); + for (const file of this.options.explicitFiles ?? []) { + const path = resolveAgentPath(file, this.options.workDir, this.options.osHomeDir); + const text = await fs.readFile(path, 'utf-8'); + const definition = parseAgentFileText({ path, source: 'explicit', text }); + this.warnInactivePatterns(definition); + explicitEntries.set(definition.name, this.entryFromDefinition(definition, effectiveDefault)); + } + entries.push(...explicitEntries.values()); + + const winners = this.applyFileEntries(entries); + this.snapshotValue = this.snapshotFromEntries(winners, systemMd); + } + + /** + * Surface dead tool patterns (bare wildcards, incomplete `mcp__` literals, + * unknown tool names) at load time, so a typo in a hand-written agent file + * warns instead of silently shrinking the profile's tool set. + */ + private warnInactivePatterns(definition: AgentFileDefinition): void { + const warn = this.warn; + if (warn === undefined) return; + const fields: readonly (readonly [string, readonly string[] | undefined])[] = [ + ['tools', definition.tools], + ['disallowedTools', definition.disallowedTools], + ]; + for (const [field, patterns] of fields) { + if (patterns === undefined) continue; + for (const issue of findInactiveToolPatterns(patterns, isKnownBuiltinToolName)) { + warn(`agent file ${definition.path}: ${field} entry ${describeInactiveToolPattern(issue)}`); + } + } + } + + private systemMdEntry( + definition: AgentFileDefinition, + effectiveDefault: ResolvedAgentProfile, + ): FileProfileEntry { + return { + kind: 'system-prompt', + definition, + profile: effectiveDefault, + priority: SOURCE_PRIORITY['user'], + // SYSTEM.md permanently replaces the builtin default prompt. + override: true, + }; + } + + private entryFromDefinition( + definition: AgentFileDefinition, + effectiveDefault: ResolvedAgentProfile, + ): FileProfileEntry { + return { + kind: 'file', + definition, + profile: agentProfileFromFile(definition, effectiveDefault.tools, (context) => + effectiveDefault.systemPrompt(context), + ), + priority: SOURCE_PRIORITY[definition.source], + override: definition.override || definition.source === 'explicit', + }; + } + + private applyFileEntries(entries: readonly FileProfileEntry[]): readonly FileProfileEntry[] { + const warn = this.warn; + const merged = new Map(this.merged); + const byName = new Map(); + for (const entry of [...entries].toSorted((a, b) => b.priority - a.priority)) { + const candidates = byName.get(entry.definition.name) ?? []; + candidates.push(entry); + byName.set(entry.definition.name, candidates); + } + const winners: FileProfileEntry[] = []; + for (const candidates of byName.values()) { + for (const candidate of candidates) { + if (merged.has(candidate.definition.name) && !candidate.override) { + warn?.( + `agent file profile "${candidate.definition.name}" ignored: a same-name builtin profile exists; set "override: true" in the frontmatter to replace it`, + ); + continue; + } + merged.set(candidate.definition.name, candidate.profile); + winners.push(candidate); + break; + } + } + + // Link regular file profiles' delegation allowlists against the merged + // set. SYSTEM.md is only a prompt overlay: treating its missing + // frontmatter as an unrestricted allowlist would let `agent` delegate to + // itself instead of preserving the builtin delegation policy. + for (const winner of winners) { + if (winner.kind === 'system-prompt') continue; + winner.profile.subagents = this.linkSubagentAllowlist(winner.definition, merged, warn); + } + + // Extend the builtin default — or its SYSTEM.md prompt-overlay variant — + // with every regular file profile. A real agent file that replaced the + // default carries its own allowlist instead. + const defaultWinner = winners.find( + (winner) => winner.definition.name === DEFAULT_AGENT_PROFILE_NAME, + ); + const defaultKeepsBuiltinDelegation = + defaultWinner === undefined || defaultWinner.kind === 'system-prompt'; + const fileWinners = winners.filter((winner) => winner.kind === 'file'); + if (defaultKeepsBuiltinDelegation && fileWinners.length > 0) { + const defaultProfile = merged.get(DEFAULT_AGENT_PROFILE_NAME) ?? this.getDefault(); + const fileRecord: Record = {}; + for (const winner of fileWinners) fileRecord[winner.definition.name] = winner.profile; + merged.set(DEFAULT_AGENT_PROFILE_NAME, { + ...defaultProfile, + subagents: { ...defaultProfile.subagents, ...fileRecord }, + }); + } + + this.merged = merged; + return winners; + } + + private snapshotFromEntries( + winners: readonly FileProfileEntry[], + systemMd: AgentFileDefinition | undefined, + ): AgentProfileCatalogSnapshot | undefined { + const profiles = winners + .filter((winner) => winner.definition !== systemMd) + .map(({ definition, profile }) => ({ + name: profile.name, + description: profile.description ?? definition.description, + whenToUse: profile.whenToUse, + tools: [...profile.tools], + disallowedTools: + profile.disallowedTools === undefined ? undefined : [...profile.disallowedTools], + subagents: Object.keys(profile.subagents ?? {}), + modelPreference: profile.modelPreference, + prompt: definition.prompt, + })); + if (systemMd === undefined && profiles.length === 0) return undefined; + return AgentProfileCatalogSnapshotSchema.parse({ + version: 1, + systemPromptTemplate: systemMd?.prompt, + profiles, + }); + } + + private snapshotSystemDefinition(prompt: string): AgentFileDefinition { + return { + name: DEFAULT_AGENT_PROFILE_NAME, + description: '', + override: true, + prompt, + path: '', + source: 'user', + }; + } + + private linkSubagentAllowlist( + definition: AgentFileDefinition, + merged: ReadonlyMap, + warn: ((message: string, error?: unknown) => void) | undefined, + ): Record { + // An omitted allowlist means "any type"; a lone `*` was already + // normalized away by the parser. + const names = definition.subagents ?? [...merged.keys()]; + const record: Record = {}; + for (const name of names) { + const target = merged.get(name); + if (target === undefined) { + warn?.( + `agent file profile "${definition.name}" declares subagent "${name}" but that agent profile was not found`, + ); + continue; + } + record[name] = target; + } + return record; + } + + private get warn(): ((message: string, error?: unknown) => void) | undefined { + return this.options.warn; + } +} diff --git a/packages/agent-core/src/profile/agentfile/discovery.ts b/packages/agent-core/src/profile/agentfile/discovery.ts new file mode 100644 index 000000000..c539c06fb --- /dev/null +++ b/packages/agent-core/src/profile/agentfile/discovery.ts @@ -0,0 +1,128 @@ +/** + * Filesystem agent-file discovery. + * + * Discovers and parses agent files. Invalid files are isolated from the rest + * of the discovery pass. Failure policy: below a root, ANY readdir failure + * (notably EACCES) skips just that directory — one unreadable subdirectory + * must not zero the whole source, mirroring the skill discovery's + * per-directory tolerance; at a root, a missing directory is simply "no + * agents here", and any other failure skips just that root. Skip warnings are + * capped (`MAX_SKIP_WARNINGS`) so a misconfigured root (e.g. an extra dir + * pointing at a docs-heavy tree) cannot spam one line per non-agent file; + * the returned `skipped` list keeps the full parse-failure detail regardless, + * and the capping summary names a few suppressed paths so the rest stay + * findable. + * + * Ported from the v2 engine (`packages/agent-core-v2/src/app/agentFileCatalog/agentFileDiscovery.ts`) + * — keep the two in sync: discovery behavior changes must land in both engines. + */ + +import { promises as fs } from 'node:fs'; +import { join } from 'pathe'; + +import { AgentFileParseError, parseAgentFileText } from './parser'; +import { isDirectoryPath, isFilePath, isMissingPathError } from './paths'; +import type { + AgentFileDefinition, + AgentFileDiscoveryResult, + AgentFileRoot, + SkippedAgentFile, +} from './types'; + +const MAX_AGENT_SCAN_DEPTH = 8; +const MAX_SKIP_WARNINGS = 5; + +export interface DiscoverAgentFilesWarn { + (message: string, error?: unknown): void; +} + +export async function discoverAgentFiles( + roots: readonly AgentFileRoot[], + warn?: DiscoverAgentFilesWarn, +): Promise { + const byName = new Map(); + const skipped: SkippedAgentFile[] = []; + + let emittedWarnings = 0; + let suppressedWarnings = 0; + const suppressedSubjects: string[] = []; + const warnCapped = (subject: string, message: string, error?: unknown): void => { + if (emittedWarnings < MAX_SKIP_WARNINGS) { + emittedWarnings += 1; + warn?.(message, error); + } else { + suppressedWarnings += 1; + if (suppressedSubjects.length < 3) suppressedSubjects.push(subject); + } + }; + + async function parseAndRegister(filePath: string, root: AgentFileRoot): Promise { + try { + const text = await fs.readFile(filePath, 'utf-8'); + const agent = parseAgentFileText({ path: filePath, source: root.source, text }); + if (!byName.has(agent.name)) { + byName.set(agent.name, agent); + } + } catch (error) { + if (error instanceof AgentFileParseError) { + skipped.push({ path: filePath, reason: error.message }); + warnCapped(filePath, `Skipping invalid agent file at ${filePath}: ${error.message}`, error); + } else { + warnCapped(filePath, `Skipping agent file at ${filePath} due to unexpected error`, error); + } + } + } + + async function walk(dirPath: string, root: AgentFileRoot, depth: number): Promise { + if (depth > MAX_AGENT_SCAN_DEPTH) return; + + let entries: readonly string[]; + try { + entries = (await fs.readdir(dirPath)).toSorted(); + } catch (error) { + if (depth > 0) { + warnCapped(dirPath, `Skipping unreadable directory ${dirPath}: ${errorMessage(error)}`, error); + return; + } + if (isMissingPathError(error)) return; + warnCapped(dirPath, `Skipping unreadable agent root ${dirPath}: ${errorMessage(error)}`, error); + return; + } + + for (const entry of entries) { + if (entry.startsWith('.') || entry === 'node_modules') continue; + const entryPath = join(dirPath, entry); + try { + if (await isDirectoryPath(entryPath)) { + await walk(entryPath, root, depth + 1); + continue; + } + if (!entry.endsWith('.md') || !(await isFilePath(entryPath))) continue; + await parseAndRegister(entryPath, root); + } catch (error) { + warnCapped(entryPath, `Skipping unreadable agent path ${entryPath}: ${errorMessage(error)}`, error); + } + } + } + + for (const root of roots) { + await walk(root.path, root, 0); + } + + if (suppressedWarnings > 0) { + const examples = suppressedSubjects.map((subject) => `"${subject}"`).join(', '); + warn?.( + `Suppressed ${suppressedWarnings} further agent-discovery skip warnings (e.g. ${examples}); fix or remove the offending files/directories to silence them`, + ); + } + + return { + agents: [...byName.values()].toSorted((a, b) => a.name.localeCompare(b.name)), + skipped, + scannedRoots: roots.map((root) => root.path), + }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/agent-core/src/profile/agentfile/from-file.ts b/packages/agent-core/src/profile/agentfile/from-file.ts new file mode 100644 index 000000000..63682bc61 --- /dev/null +++ b/packages/agent-core/src/profile/agentfile/from-file.ts @@ -0,0 +1,131 @@ +/** + * `AgentFileDefinition` → `ResolvedAgentProfile` adapter. + * + * The file body is a prompt template rendered against the agent-file variable + * table: `${var}` placeholders substitute live context, and `${base_prompt}` + * embeds the builtin default profile's prompt so a file can wrap the builtin + * behavior instead of replacing it. The variable names and semantics match + * the v2 engine (and the user docs), so the same agent file works on both + * engines. The renderer is a plain `${var}` substitution — NOT the nunjucks + * renderer the builtin YAML profiles use — so a literal `{{...}}` or an + * unknown `${...}` in a user template can never crash rendering. + * + * `tools` resolves to the effective allowlist here: an omitted `tools` means + * the default profile's tool set (v1 profiles have no "every tool" sentinel). + * `disallowedTools` passes through to the profile verbatim and is evaluated + * by the tool manager on top of the allowlist — exact builtin/user names and + * `mcp__…` glob patterns both work, including partial server denies such as + * `mcp__github__*` under an `mcp__*` allow. `subagents` stays an allowlist + * of names on the definition; the catalog links it into the resolved record + * after merging. + * + * Ported from the v2 engine (`packages/agent-core-v2/src/app/agentFileCatalog/agentProfileFromFile.ts`) + * — keep the two in sync: template variables and profile-mapping semantics + * must land in both engines. + */ + +import type { ResolvedAgentProfile, SystemPromptContext } from '../types'; +import { + ADDITIONAL_DIRS_SECTION_PROSE, + SKILLS_SECTION_PROSE, + WINDOWS_NOTES, +} from '../prompt-sections'; + +import type { AgentFileDefinition } from './types'; + +const PROMPT_VARIABLE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g; + +function renderTemplateVars(template: string, vars: Record): string { + return template.replace(PROMPT_VARIABLE, (match: string, name: string) => { + const value = vars[name]; + return typeof value === 'string' ? value : match; + }); +} + +export function agentFilePromptVars( + context: SystemPromptContext, + options: { readonly skillActive: boolean }, +): Record { + const shellName = context.osEnv.shellName ?? ''; + const shellPath = context.osEnv.shellPath ?? ''; + const skills = options.skillActive + ? typeof context.skills === 'string' + ? context.skills + : (context.skills?.getModelSkillListing() ?? '') + : ''; + const additionalDirsInfo = context.additionalDirsInfo ?? ''; + const now = + context.now instanceof Date + ? context.now.toISOString() + : (context.now ?? new Date().toISOString()); + return { + role_additional: context.roleAdditional ?? '', + os: context.osEnv.osKind ?? '', + windows_notes: context.osEnv.osKind === 'Windows' ? `\n\n${WINDOWS_NOTES}\n\n` : '', + shell: shellName.length > 0 ? `${shellName} (\`${shellPath}\`)` : '', + now, + cwd: context.cwd, + cwd_listing: context.cwdListing ?? '', + agents_md: context.agentsMd ?? '', + additional_dirs_info: additionalDirsInfo, + additional_dirs_section: + additionalDirsInfo.length > 0 + ? `\n\n## Additional Directories\n\n${ADDITIONAL_DIRS_SECTION_PROSE}\n\n${additionalDirsInfo}\n\n` + : '', + skills, + skills_section: + skills.length > 0 ? `\n\n# Skills\n\n${SKILLS_SECTION_PROSE}\n\n${skills}\n\n` : '', + }; +} + +export function renderAgentFileTemplate( + template: string, + context: SystemPromptContext, + options: { readonly skillActive: boolean }, + basePrompt?: (context: SystemPromptContext) => string, +): string { + const vars = agentFilePromptVars(context, options); + if (basePrompt !== undefined && template.includes('${base_prompt}')) { + vars['base_prompt'] = basePrompt(context); + } + return renderTemplateVars(template, vars); +} + +export function skillActiveForAgentFile(definition: AgentFileDefinition): boolean { + return ( + (definition.tools === undefined || definition.tools.includes('Skill')) && + !(definition.disallowedTools ?? []).includes('Skill') + ); +} + +/** + * The effective tool allowlist for a file-defined profile: the file's own + * `tools`, or the default profile's set when unrestricted. The denylist is + * NOT folded in here — it rides the profile's `disallowedTools` so the tool + * manager can evaluate glob patterns against resolved MCP tool names. + */ +export function agentFileTools( + definition: AgentFileDefinition, + defaultTools: readonly string[], +): string[] { + return definition.tools === undefined ? [...defaultTools] : [...definition.tools]; +} + +export function agentProfileFromFile( + definition: AgentFileDefinition, + defaultTools: readonly string[], + basePrompt: (context: SystemPromptContext) => string, +): ResolvedAgentProfile { + const skillActive = skillActiveForAgentFile(definition); + return { + name: definition.name, + description: definition.description, + systemPrompt: (context) => + renderAgentFileTemplate(definition.prompt, context, { skillActive }, basePrompt), + tools: agentFileTools(definition, defaultTools), + disallowedTools: + definition.disallowedTools === undefined ? undefined : [...definition.disallowedTools], + whenToUse: definition.whenToUse, + modelPreference: definition.modelPreference, + }; +} diff --git a/packages/agent-core/src/profile/agentfile/index.ts b/packages/agent-core/src/profile/agentfile/index.ts new file mode 100644 index 000000000..b1f12bd80 --- /dev/null +++ b/packages/agent-core/src/profile/agentfile/index.ts @@ -0,0 +1,9 @@ +export * from './types'; +export * from './parser'; +export * from './paths'; +export * from './roots'; +export * from './discovery'; +export * from './from-file'; +export * from './system-file'; +export * from './validate'; +export * from './catalog'; diff --git a/packages/agent-core/src/profile/agentfile/parser.ts b/packages/agent-core/src/profile/agentfile/parser.ts new file mode 100644 index 000000000..324b8f2e3 --- /dev/null +++ b/packages/agent-core/src/profile/agentfile/parser.ts @@ -0,0 +1,192 @@ +/** + * Agent-file parsing primitives. + * + * Parses a single agent Markdown file (frontmatter + body) into an + * `AgentFileDefinition`. Pure functions with no IO: callers read bytes however + * they like and pass the decoded text in, mirroring the skill parser. Unknown + * frontmatter fields are ignored so later format extensions stay + * forward-compatible. Compatibility conventions match other agent CLIs: a + * missing `name` falls back to the file name (OpenCode), a lone `*` in + * `tools` / `subagents` means unrestricted like an omitted field, and list + * fields accept either a bare comma-separated string or the YAML list form + * (Claude Code). + * + * Ported from the v2 engine (`packages/agent-core-v2/src/app/agentFileCatalog/agentFile.ts`) + * — keep the two in sync: agent-file format changes must land in both engines. + */ + +import { FrontmatterError, parseFrontmatter } from '../../skill/parser'; + +import type { AgentFileDefinition, AgentFileSource } from './types'; + +export class AgentFileParseError extends Error { + readonly reason?: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'AgentFileParseError'; + if (cause !== undefined) this.reason = cause; + } +} + +export interface ParseAgentFileOptions { + readonly path: string; + readonly source: AgentFileSource; + readonly text: string; +} + +const AGENT_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export function parseAgentFileText(options: ParseAgentFileOptions): AgentFileDefinition { + let parsed; + try { + parsed = parseFrontmatter(options.text); + } catch (error) { + if (error instanceof FrontmatterError) { + throw new AgentFileParseError( + `Invalid frontmatter in ${options.path}: ${error.message}`, + error, + ); + } + throw error; + } + + const frontmatter = parsed.data; + if (frontmatter === null) { + throw new AgentFileParseError(`Missing frontmatter in ${options.path}`); + } + if (!isRecord(frontmatter)) { + throw new AgentFileParseError( + `Frontmatter in ${options.path} must be a mapping at the top level`, + ); + } + + const nameField = frontmatter['name']; + if (nameField !== undefined && nameField !== null && typeof nameField !== 'string') { + throw new AgentFileParseError( + `Frontmatter field "name" in ${options.path} must be a non-empty string`, + ); + } + const name = nonEmptyString(nameField) ?? deriveNameFromPath(options.path); + if (name === undefined) { + throw new AgentFileParseError(`Missing required frontmatter field "name" in ${options.path}`); + } + if (!AGENT_NAME_PATTERN.test(name)) { + throw new AgentFileParseError( + `Invalid agent name "${name}" in ${options.path}: expected kebab-case (e.g. "code-reviewer")`, + ); + } + + const description = requiredNonEmptyString( + frontmatter['description'], + 'description', + options.path, + ); + + const override = parseBoolean(frontmatter['override'], 'override', options.path); + const rawTools = parseStringList(frontmatter['tools'], 'tools', options.path); + const tools = rawTools?.length === 1 && rawTools[0] === '*' ? undefined : rawTools; + const disallowedTools = parseStringList( + frontmatter['disallowedTools'], + 'disallowedTools', + options.path, + ); + const rawSubagents = parseStringList(frontmatter['subagents'], 'subagents', options.path); + const subagents = + rawSubagents?.length === 1 && rawSubagents[0] === '*' ? undefined : rawSubagents; + const modelPreference = parseModelPreference(frontmatter['model_preference'], options.path); + + const prompt = parsed.body.trim(); + if (prompt.length === 0) { + throw new AgentFileParseError(`Missing prompt body in ${options.path}`); + } + + return { + name, + description, + whenToUse: nonEmptyString(frontmatter['whenToUse']), + override, + tools, + disallowedTools, + subagents, + modelPreference, + prompt, + path: options.path, + source: options.source, + }; +} + +function parseModelPreference( + value: unknown, + filePath: string, +): AgentFileDefinition['modelPreference'] { + if (value === undefined || value === null) return undefined; + if (value === 'primary' || value === 'secondary') return value; + throw new AgentFileParseError( + `Frontmatter field "model_preference" in ${filePath} must be "primary" or "secondary"`, + ); +} + +function parseBoolean(value: unknown, field: string, filePath: string): boolean { + if (value === undefined || value === null) return false; + if (typeof value === 'boolean') return value; + throw new AgentFileParseError( + `Frontmatter field "${field}" in ${filePath} must be a boolean`, + ); +} + +function parseStringList( + value: unknown, + field: string, + filePath: string, +): readonly string[] | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value === 'string') { + return value + .split(',') + .map((item) => item.trim()) + .filter((item) => item !== ''); + } + if (!Array.isArray(value)) { + throw new AgentFileParseError( + `Frontmatter field "${field}" in ${filePath} must be a comma-separated string or a list of strings`, + ); + } + const out: string[] = []; + for (const item of value) { + if (typeof item !== 'string' || item.trim() === '') { + throw new AgentFileParseError( + `Frontmatter field "${field}" in ${filePath} must be a list of non-empty strings`, + ); + } + out.push(item.trim()); + } + return out; +} + +function requiredNonEmptyString(value: unknown, field: string, filePath: string): string { + if (value !== undefined && value !== null && typeof value !== 'string') { + throw new AgentFileParseError( + `Frontmatter field "${field}" in ${filePath} must be a non-empty string`, + ); + } + const parsed = nonEmptyString(value); + if (parsed === undefined) { + throw new AgentFileParseError(`Missing required frontmatter field "${field}" in ${filePath}`); + } + return parsed; +} + +function deriveNameFromPath(filePath: string): string | undefined { + const base = filePath.split(/[\\/]/).pop() ?? ''; + const name = base.replace(/\.[^.]*$/, ''); + return name !== '' ? name : undefined; +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/agent-core/src/profile/agentfile/paths.ts b/packages/agent-core/src/profile/agentfile/paths.ts new file mode 100644 index 000000000..99d86e57c --- /dev/null +++ b/packages/agent-core/src/profile/agentfile/paths.ts @@ -0,0 +1,59 @@ +/** + * Shared path primitives for agent-file discovery: `~` expansion, + * base-relative resolution, and fs type probes used by the root resolvers, + * the directory walker, and the explicit-file source. Callers pick the + * resolution base: discovery roots resolve against the project root, + * explicit files against the session workDir. + * + * Ported from the v2 engine (`packages/agent-core-v2/src/app/agentFileCatalog/paths.ts`) + * — keep the two in sync. + */ + +import { promises as fs } from 'node:fs'; +import { isAbsolute, join, resolve } from 'pathe'; + +export function resolveAgentPath(path: string, baseDir: string, osHomeDir: string): string { + if (path === '~') return osHomeDir; + if (path.startsWith('~/')) return join(osHomeDir, path.slice(2)); + if (isAbsolute(path)) return path; + return resolve(baseDir, path); +} + +export async function isDirectoryPath(p: string): Promise { + try { + const resolved = await fs.realpath(p); + return (await fs.stat(resolved)).isDirectory(); + } catch (error) { + if (isMissingPathError(error)) return false; + throw error; + } +} + +export async function isFilePath(p: string): Promise { + try { + const resolved = await fs.realpath(p); + return (await fs.stat(resolved)).isFile(); + } catch (error) { + if (isMissingPathError(error)) return false; + throw error; + } +} + +export async function pathExists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch (error) { + if (isMissingPathError(error)) return false; + throw error; + } +} + +export function isMissingPathError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error.code === 'ENOENT' || error.code === 'ENOTDIR') + ); +} diff --git a/packages/agent-core/src/profile/agentfile/roots.ts b/packages/agent-core/src/profile/agentfile/roots.ts new file mode 100644 index 000000000..7dcecd808 --- /dev/null +++ b/packages/agent-core/src/profile/agentfile/roots.ts @@ -0,0 +1,111 @@ +/** + * Agent-root resolution primitives: user, project, and configured discovery + * roots, mirroring the skill scanner's directory conventions + * (`skills` ↔ `agents`, `.kimi-code` ↔ `.agents`). + * + * Ported from the v2 engine (`packages/agent-core-v2/src/app/agentFileCatalog/agentRoots.ts`) + * — keep the two in sync: discovery-root conventions must land in both engines. + */ + +import { promises as fs } from 'node:fs'; +import { dirname, join, resolve } from 'pathe'; + +import { isDirectoryPath, pathExists, resolveAgentPath } from './paths'; +import type { AgentFileRoot, AgentFileSource } from './types'; + +export interface AgentRootWarn { + (message: string, error?: unknown): void; +} + +// Relative to brandHomeDir, which already IS the brand data dir (~/.kimi-code +// or $KIMI_CODE_HOME) — no '.kimi-code' segment here, or it would nest twice. +const USER_BRAND_DIRS = ['agents'] as const; +const USER_GENERIC_DIRS = ['.agents/agents'] as const; +const PROJECT_BRAND_DIRS = ['.kimi-code/agents'] as const; +const PROJECT_GENERIC_DIRS = ['.agents/agents'] as const; + +export async function userAgentRoots( + brandHomeDir: string, + osHomeDir: string, + warn?: AgentRootWarn, +): Promise { + const roots: AgentFileRoot[] = []; + await pushFirstExisting(roots, USER_BRAND_DIRS, brandHomeDir, 'user', warn); + await pushFirstExisting(roots, USER_GENERIC_DIRS, osHomeDir, 'user', warn); + return roots; +} + +export async function projectAgentRoots( + workDir: string, + warn?: AgentRootWarn, +): Promise { + const projectRoot = await findProjectRoot(workDir, warn); + const roots: AgentFileRoot[] = []; + await pushFirstExisting(roots, PROJECT_BRAND_DIRS, projectRoot, 'project', warn); + await pushFirstExisting(roots, PROJECT_GENERIC_DIRS, projectRoot, 'project', warn); + return roots; +} + +export async function configuredAgentRoots( + dirs: readonly string[], + workDir: string, + osHomeDir: string, + source: AgentFileSource, + warn?: AgentRootWarn, +): Promise { + const projectRoot = await findProjectRoot(workDir, warn); + const roots: AgentFileRoot[] = []; + for (const dir of dirs) { + await pushExistingRoot(roots, resolveAgentPath(dir, projectRoot, osHomeDir), source, warn); + } + return roots; +} + +async function findProjectRoot(workDir: string, warn?: AgentRootWarn): Promise { + const start = resolve(workDir); + let current = start; + while (true) { + const marker = join(current, '.git'); + try { + if (await pathExists(marker)) return current; + } catch (error) { + warn?.(`Skipping unreadable project marker ${marker}: ${errorMessage(error)}`, error); + } + const parent = dirname(current); + if (parent === current) return start; + current = parent; + } +} + +async function pushFirstExisting( + out: AgentFileRoot[], + dirs: readonly string[], + base: string, + source: AgentFileSource, + warn?: AgentRootWarn, +): Promise { + for (const dir of dirs) { + if (await pushExistingRoot(out, join(base, dir), source, warn)) return; + } +} + +async function pushExistingRoot( + out: AgentFileRoot[], + dir: string, + source: AgentFileSource, + warn?: AgentRootWarn, +): Promise { + try { + if (!(await isDirectoryPath(dir))) return false; + const resolved = (await fs.realpath(dir)).replaceAll('\\', '/'); + if (!out.some((root) => root.path === resolved)) out.push({ path: resolved, source }); + return true; + } catch (error) { + warn?.(`Skipping unreadable agent root ${dir}: ${errorMessage(error)}`, error); + return false; + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/agent-core/src/profile/agentfile/system-file.ts b/packages/agent-core/src/profile/agentfile/system-file.ts new file mode 100644 index 000000000..bf2e7466b --- /dev/null +++ b/packages/agent-core/src/profile/agentfile/system-file.ts @@ -0,0 +1,87 @@ +/** + * `SYSTEM.md` global main-agent prompt override. + * + * `/SYSTEM.md` (default `~/.kimi-code/SYSTEM.md`, moves with + * `KIMI_CODE_HOME`) permanently replaces the builtin default profile's system + * prompt while the file exists and is non-empty. Only the prompt is replaced + * — every other profile capability comes from the builtin default — and + * explicit intent still wins: higher-priority sources (project `agent.md`, + * `--agent-file`) override it, and binding a different profile ignores it. + * The body is a prompt template rendered against the agent-file variable + * table: `${var}` placeholders substitute live context, and `${base_prompt}` + * embeds the builtin default prompt. A missing or empty file yields no + * definition; a read failure degrades to `warn` instead of rejecting, + * matching the directory-source policy that a transient fs error must never + * poison a session. + * + * Ported from the v2 engine (`packages/agent-core-v2/src/app/agentFileCatalog/systemFile.ts`) + * — keep the two in sync: SYSTEM.md semantics must land in both engines. + */ + +import { promises as fs } from 'node:fs'; +import { join } from 'pathe'; + +import type { ResolvedAgentProfile } from '../types'; + +import { renderAgentFileTemplate } from './from-file'; +import { isFilePath } from './paths'; +import type { AgentFileDefinition } from './types'; + +export const SYSTEM_MD_FILENAME = 'SYSTEM.md'; + +/** + * Loads `/SYSTEM.md` as a synthetic agent-file definition for the + * default profile, or `undefined` when the file is absent or empty. + */ +export async function loadSystemMdDefinition( + brandHome: string, + warn: (message: string) => void, +): Promise { + const path = join(brandHome, SYSTEM_MD_FILENAME); + let text: string; + try { + if (!(await isFilePath(path))) return undefined; + text = await fs.readFile(path, 'utf-8'); + } catch (error) { + warn(`agent SYSTEM.md load failed: ${String(error)} [${path}]`); + return undefined; + } + if (text.trim().length === 0) return undefined; + return { + name: 'agent', + description: '', + override: true, + prompt: text.trim(), + path, + source: 'user', + }; +} + +/** + * Builds the SYSTEM.md profile variant: the builtin default with its system + * prompt replaced by the file body. Every other capability comes from the + * builtin default. + */ +export function systemMdProfile( + definition: AgentFileDefinition, + builtinDefault: ResolvedAgentProfile, +): ResolvedAgentProfile { + const skillActive = builtinDefault.tools.includes('Skill'); + return { + name: builtinDefault.name, + description: builtinDefault.description, + systemPrompt: (context) => + renderAgentFileTemplate(definition.prompt, context, { skillActive }, (ctx) => + builtinDefault.systemPrompt(ctx), + ), + tools: [...builtinDefault.tools], + disallowedTools: + builtinDefault.disallowedTools === undefined + ? undefined + : [...builtinDefault.disallowedTools], + whenToUse: builtinDefault.whenToUse, + subagents: + builtinDefault.subagents === undefined ? undefined : { ...builtinDefault.subagents }, + modelPreference: builtinDefault.modelPreference, + }; +} diff --git a/packages/agent-core/src/profile/agentfile/types.ts b/packages/agent-core/src/profile/agentfile/types.ts new file mode 100644 index 000000000..8e50b20dc --- /dev/null +++ b/packages/agent-core/src/profile/agentfile/types.ts @@ -0,0 +1,69 @@ +/** + * Agent-file model types: the parsed single-file definition + * (`AgentFileDefinition`), scan roots (`AgentFileRoot`) tagged with their + * source, and the discovery result carrying per-file skip diagnostics. + * Pure data. + * + * Ported from the v2 engine (`packages/agent-core-v2/src/app/agentFileCatalog/types.ts`) + * — keep the two in sync. + */ + +import type { AgentModelPreference } from '../types'; +import { z } from 'zod'; + +export type AgentFileSource = 'project' | 'user' | 'extra' | 'explicit'; + +export interface AgentFileRoot { + readonly path: string; + readonly source: AgentFileSource; +} + +export interface AgentFileDefinition { + readonly name: string; + readonly description: string; + readonly whenToUse?: string; + readonly override: boolean; + readonly tools?: readonly string[]; + readonly disallowedTools?: readonly string[]; + readonly subagents?: readonly string[]; + readonly modelPreference?: AgentModelPreference; + readonly prompt: string; + readonly path: string; + readonly source: AgentFileSource; +} + +export interface SkippedAgentFile { + readonly path: string; + readonly reason: string; +} + +export interface AgentFileDiscoveryResult { + readonly agents: readonly AgentFileDefinition[]; + readonly skipped: readonly SkippedAgentFile[]; + readonly scannedRoots: readonly string[]; +} + +const AgentProfileSnapshotSchema = z.object({ + name: z.string().min(1), + description: z.string(), + whenToUse: z.string().optional(), + tools: z.array(z.string()), + disallowedTools: z.array(z.string()).optional(), + subagents: z.array(z.string()), + modelPreference: z.enum(['primary', 'secondary']).optional(), + prompt: z.string(), +}); + +/** + * Normalized file-backed catalog state bound to a session. Unlike source + * paths, this is sufficient to rebuild custom profiles after their files are + * removed or changed: tool defaults and wildcard subagent sets are already + * resolved to concrete lists. + */ +export const AgentProfileCatalogSnapshotSchema = z.object({ + version: z.literal(1), + systemPromptTemplate: z.string().optional(), + profiles: z.array(AgentProfileSnapshotSchema), +}); + +export type AgentProfileCatalogSnapshot = z.infer; diff --git a/packages/agent-core/src/profile/agentfile/validate.ts b/packages/agent-core/src/profile/agentfile/validate.ts new file mode 100644 index 000000000..a10b3fcb8 --- /dev/null +++ b/packages/agent-core/src/profile/agentfile/validate.ts @@ -0,0 +1,62 @@ +/** + * Static inspection of agent-file tool patterns. + * + * Three entry shapes are dead on arrival under the tool manager's matching + * semantics, so they surface as warnings instead of silently shrinking the + * active tool set: `wildcard-not-mcp` (non-MCP entries match builtin/user + * tools by exact name only, so a wildcard outside an `mcp__…` pattern can + * never match — a bare `*` in a denylist is a no-op), `incomplete-mcp-name` + * (an `mcp__…` literal without glob magic must be a full + * `mcp____` name; `mcp__github__*` is the working form for a + * whole server), and `unknown-tool` (a literal naming no registered or + * built-in tool, almost always a typo such as `read` instead of `Read`). + * + * Ported from the v2 engine (`findInactiveToolPatterns` in + * `packages/agent-core-v2/src/agent/toolPolicy/evaluate.ts`) — keep the two + * in sync: warning kinds and matching rules must land in both engines. + */ + +import { isMcpToolName } from '../../mcp/tool-naming'; + +export type InactiveToolPatternKind = 'wildcard-not-mcp' | 'incomplete-mcp-name' | 'unknown-tool'; + +export interface InactiveToolPattern { + readonly pattern: string; + readonly kind: InactiveToolPatternKind; +} + +const GLOB_MAGIC = /[*?[\]{}]/; + +export function findInactiveToolPatterns( + patterns: readonly string[], + isKnownToolName?: (name: string) => boolean, +): InactiveToolPattern[] { + const issues: InactiveToolPattern[] = []; + for (const pattern of patterns) { + if (isMcpToolName(pattern)) { + if (!GLOB_MAGIC.test(pattern) && !pattern.slice('mcp__'.length).includes('__')) { + issues.push({ pattern, kind: 'incomplete-mcp-name' }); + } + continue; + } + if (GLOB_MAGIC.test(pattern)) { + issues.push({ pattern, kind: 'wildcard-not-mcp' }); + continue; + } + if (isKnownToolName !== undefined && !isKnownToolName(pattern)) { + issues.push({ pattern, kind: 'unknown-tool' }); + } + } + return issues; +} + +export function describeInactiveToolPattern(issue: InactiveToolPattern): string { + switch (issue.kind) { + case 'wildcard-not-mcp': + return `"${issue.pattern}" never matches: wildcards only work inside mcp__… patterns (a bare * disables nothing)`; + case 'incomplete-mcp-name': + return `"${issue.pattern}" never matches: an mcp__ literal must be a full mcp____ name, or use a glob like mcp__github__* for a whole server`; + case 'unknown-tool': + return `"${issue.pattern}" matches no registered or built-in tool (a typo?)`; + } +} diff --git a/packages/agent-core/src/profile/default/system.md b/packages/agent-core/src/profile/default/system.md index 0671cd108..997c11d0e 100644 --- a/packages/agent-core/src/profile/default/system.md +++ b/packages/agent-core/src/profile/default/system.md @@ -79,7 +79,7 @@ If the summary is genuinely missing something you need to proceed, ask the user You are running on **{{ KIMI_OS }}**. The Bash tool executes commands using **{{ KIMI_SHELL }}**. {% if KIMI_OS == "Windows" %} -IMPORTANT: You are on Windows. The Bash tool runs through Git Bash, so use Unix shell syntax inside Bash commands — `/dev/null` not `NUL`, and forward slashes in paths. For file operations, always prefer the built-in tools (Read, Write, Edit, Glob, Grep) over Bash commands — they work reliably across all platforms. +{{ KIMI_WINDOWS_NOTES }} {% endif %} The operating environment is not in a sandbox. Any actions you do will immediately affect the user's system. So you MUST be extremely cautious. Unless being explicitly instructed to do so, you should never access (read/write/execute) files outside of the working directory. @@ -105,7 +105,7 @@ The directory listing of current working directory is: ## Additional Directories -The following directories have been added to the workspace. You can read, write, search, and glob files in these directories as part of your workspace scope. +{{ KIMI_ADDITIONAL_DIRS_SECTION_PROSE }} {{ KIMI_ADDITIONAL_DIRS_INFO }} {% endif %} @@ -125,13 +125,7 @@ The applicable `AGENTS.md` instructions are: {% if KIMI_SKILLS %} # Skills -Skills are reusable, composable capabilities that enhance your abilities. Each skill is either a self-contained directory with a `SKILL.md` file or a standalone `.md` file that contains instructions, examples, and/or reference material. - -Identify the skills relevant to your current task and read the skill file for its instructions; only read further skill details when needed, to conserve the context window. - -## Available skills - -Skills are grouped by scope (`Project`, `User`, `Extra`, `Built-in`) so you can tell where each came from. When the user refers to "the skill in this project" or "the user-scope skill", use the scope heading to disambiguate. When multiple scopes define a skill with the same name, the more specific scope takes precedence: **Project overrides User overrides Extra overrides Built-in**. +{{ KIMI_SKILLS_SECTION_PROSE }} {{ KIMI_SKILLS }} {% endif %} diff --git a/packages/agent-core/src/profile/index.ts b/packages/agent-core/src/profile/index.ts index 80e303814..6e490f779 100644 --- a/packages/agent-core/src/profile/index.ts +++ b/packages/agent-core/src/profile/index.ts @@ -3,3 +3,4 @@ export * from './context'; export * from './load'; export * from './resolve'; export * from './default'; +export * from './agentfile/index'; diff --git a/packages/agent-core/src/profile/prompt-sections.ts b/packages/agent-core/src/profile/prompt-sections.ts new file mode 100644 index 000000000..16cf0da90 --- /dev/null +++ b/packages/agent-core/src/profile/prompt-sections.ts @@ -0,0 +1,26 @@ +/** + * Shared prompt-section prose — the single source for the three + * environment-dependent prose blocks that appear in BOTH the builtin default + * prompt and agent-file prompts: + * + * - `profile/default/system.md` renders them through the `KIMI_WINDOWS_NOTES` + * / `KIMI_ADDITIONAL_DIRS_SECTION_PROSE` / `KIMI_SKILLS_SECTION_PROSE` + * template variables injected by `resolve.ts#buildTemplateVars`; + * - `profile/agentfile/from-file.ts` composes them into the agent-file + * `${windows_notes}` / `${additional_dirs_section}` / `${skills_section}` + * variables. + * + * Edit the text HERE only — the two render paths must never drift apart. + */ + +export const WINDOWS_NOTES = + 'IMPORTANT: You are on Windows. The Bash tool runs through Git Bash, so use Unix shell syntax inside Bash commands — `/dev/null` not `NUL`, and forward slashes in paths. For file operations, always prefer the built-in tools (Read, Write, Edit, Glob, Grep) over Bash commands — they work reliably across all platforms.'; + +export const ADDITIONAL_DIRS_SECTION_PROSE = + 'The following directories have been added to the workspace. You can read, write, search, and glob files in these directories as part of your workspace scope.'; + +export const SKILLS_SECTION_PROSE = + 'Skills are reusable, composable capabilities that enhance your abilities. Each skill is either a self-contained directory with a `SKILL.md` file or a standalone `.md` file that contains instructions, examples, and/or reference material.\n\n' + + 'Identify the skills relevant to your current task and read the skill file for its instructions; only read further skill details when needed, to conserve the context window.\n\n' + + '## Available skills\n\n' + + 'Skills are grouped by scope (`Project`, `User`, `Extra`, `Built-in`) so you can tell where each came from. When the user refers to "the skill in this project" or "the user-scope skill", use the scope heading to disambiguate. When multiple scopes define a skill with the same name, the more specific scope takes precedence: **Project overrides User overrides Extra overrides Built-in**.'; diff --git a/packages/agent-core/src/profile/resolve.ts b/packages/agent-core/src/profile/resolve.ts index e73b7b4fc..1e2356cda 100644 --- a/packages/agent-core/src/profile/resolve.ts +++ b/packages/agent-core/src/profile/resolve.ts @@ -1,5 +1,11 @@ import { renderPrompt } from '../utils/render-prompt'; +import { + ADDITIONAL_DIRS_SECTION_PROSE, + SKILLS_SECTION_PROSE, + WINDOWS_NOTES, +} from './prompt-sections'; import type { + AgentModelPreference, RawAgentProfile, RawSubagentProfile, ResolvedAgentProfile, @@ -15,6 +21,7 @@ interface MergedAgentProfile { readonly tools: string[]; readonly whenToUse?: string | undefined; readonly subagents?: Record | undefined; + readonly modelPreference?: AgentModelPreference; } /** @@ -100,6 +107,7 @@ function resolveMergedProfile( tools: profile.tools !== undefined ? [...profile.tools] : [...(parent?.tools ?? [])], whenToUse: profile.whenToUse ?? parent?.whenToUse, subagents: cloneSubagents(profile.subagents), + modelPreference: profile.modelPreference ?? parent?.modelPreference, }; cache.set(profile.name, merged); @@ -113,6 +121,7 @@ function toResolvedProfile(merged: MergedAgentProfile): ResolvedAgentProfile { systemPrompt: createSystemPromptRenderer(merged), tools: [...merged.tools], whenToUse: merged.whenToUse, + modelPreference: merged.modelPreference, }; } @@ -161,6 +170,11 @@ function buildTemplateVars( KIMI_AGENTS_MD: context.agentsMd ?? '', KIMI_SKILLS: tools.includes('Skill') ? skills : '', KIMI_ADDITIONAL_DIRS_INFO: context.additionalDirsInfo ?? '', + // Shared prose sections (single source: profile/prompt-sections.ts) so the + // builtin template and the agent-file renderer can never drift apart. + KIMI_WINDOWS_NOTES: WINDOWS_NOTES, + KIMI_ADDITIONAL_DIRS_SECTION_PROSE: ADDITIONAL_DIRS_SECTION_PROSE, + KIMI_SKILLS_SECTION_PROSE: SKILLS_SECTION_PROSE, ROLE_ADDITIONAL: context.roleAdditional ?? promptVars['ROLE_ADDITIONAL'] ?? promptVars['roleAdditional'] ?? '', }; diff --git a/packages/agent-core/src/profile/types.ts b/packages/agent-core/src/profile/types.ts index 27d407c3b..642bebac5 100644 --- a/packages/agent-core/src/profile/types.ts +++ b/packages/agent-core/src/profile/types.ts @@ -9,6 +9,15 @@ export const RawSubagentProfileSchema = z.object({ export type RawSubagentProfile = z.infer; +/** + * Symbolic model preference a profile declares for subagent spawning: the + * `Agent` / `AgentSwarm` tools use it as the default for their `model` + * parameter when the call does not pass one explicitly. + */ +export const AgentModelPreferenceSchema = z.enum(['primary', 'secondary']); + +export type AgentModelPreference = z.infer; + export const RawAgentProfileSchema = z.object({ extends: z.string().optional(), name: z.string().min(1), @@ -21,6 +30,7 @@ export const RawAgentProfileSchema = z.object({ tools: z.array(z.string()).optional(), whenToUse: z.string().optional(), subagents: z.record(z.string(), RawSubagentProfileSchema).optional(), + modelPreference: AgentModelPreferenceSchema.optional(), }); export type RawAgentProfile = z.infer; @@ -51,6 +61,13 @@ export interface ResolvedAgentProfile { description?: string; systemPrompt: SystemPromptRenderer; tools: string[]; + /** + * Denylist with the same matching rules as `tools` (exact builtin/user + * names plus `mcp__…` glob patterns), applied on top of the `tools` + * allowlist when the profile takes effect. + */ + disallowedTools?: string[]; whenToUse?: string; subagents?: Record; + modelPreference?: AgentModelPreference; } diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 69849e1a9..ddc257f7d 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -63,6 +63,10 @@ export interface CreateSessionPayload { readonly additionalDirs?: readonly string[]; readonly client?: ClientTelemetryInfo | undefined; readonly drainAgentTasksOnStop?: boolean; + /** Main-agent profile name (`--agent`): a builtin or agentfile-defined profile. */ + readonly agentProfile?: string; + /** Explicit agentfiles (`--agent-file`); an invalid file fails session creation. */ + readonly agentFiles?: readonly string[]; } export interface CloseSessionPayload { @@ -81,6 +85,8 @@ export interface ResumeSessionPayload { readonly sessionId: string; readonly mcpServers?: Readonly>; readonly additionalDirs?: readonly string[]; + /** Re-select the session's already-bound main profile; a different name fails. */ + readonly agentProfile?: string; /** Include persisted subagent states in the returned replay snapshot. */ readonly includeSubagents?: boolean; /** @@ -521,6 +527,7 @@ export interface SessionAPI extends AgentAPIWithId { type SessionAPIWithId = WithSessionId; export interface CoreAPI extends SessionAPIWithId { + applyPersistedSecondaryModel: (payload: EmptyPayload & { readonly sessionId: string }) => void; getCoreInfo: (payload: EmptyPayload) => CoreInfo; getExperimentalFeatures: (payload: EmptyPayload) => readonly ExperimentalFeatureState[]; getKimiConfig: (payload: GetKimiConfigPayload) => KimiConfig; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 06e97a5e4..c83eaad3a 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -49,8 +49,10 @@ import { type BeginAuthorizationResult, type SessionMcpConfig, } from '../mcp'; +import { SessionAgentProfileCatalog } from '../profile'; import { Session, type SessionMeta, type SessionSkillConfig } from '../session'; import { exportSessionDirectory } from '../session/export'; +import { resolveMainAgentProfile } from '../session/main-agent-profile'; import { registerBuiltinSkills, SessionSkillRegistry, @@ -335,6 +337,32 @@ export class KimiCore implements PromisableMethods { ...localWorkspaceDirs.additionalDirs, ...callerAdditionalDirs, ]); + const agentCatalogWarnings: Array<{ readonly message: string; readonly error?: unknown }> = []; + const reportAgentCatalogWarnings = (logger: Logger): void => { + for (const warning of agentCatalogWarnings) { + logger.warn( + warning.message, + warning.error === undefined ? undefined : { error: warning.error }, + ); + } + }; + const agentCatalog = new SessionAgentProfileCatalog({ + workDir, + brandHomeDir: this.homeDir, + osHomeDir: this.userHomeDir, + extraDirs: config.extraAgentDirs, + explicitFiles: options.agentFiles, + warn: (message, error) => { + agentCatalogWarnings.push({ message, error }); + }, + }); + try { + await agentCatalog.ready; + resolveMainAgentProfile(agentCatalog, options.agentProfile); + } catch (error) { + reportAgentCatalogWarnings(log.createChild({ sessionId: id })); + throw error; + } const summary = await this.sessionStore.create({ id, workDir, @@ -378,6 +406,10 @@ export class KimiCore implements PromisableMethods { hooks: [...(config.hooks ?? []), ...this.plugins.enabledHooks()], permissionRules: config.permission?.rules, skills: this.resolveSessionSkillConfig(config), + agents: { + catalog: agentCatalog, + profileName: options.agentProfile, + }, mcpConfig, experimentalFlags: this.experimentalFlags, imageLimits: this.imageLimits, @@ -389,6 +421,7 @@ export class KimiCore implements PromisableMethods { drainAgentTasksOnStop: options.drainAgentTasksOnStop, }); try { + reportAgentCatalogWarnings(session.log); session.metadata = { ...session.metadata, createdAt: new Date(summary.createdAt).toISOString(), @@ -485,6 +518,7 @@ export class KimiCore implements PromisableMethods { ]); const active = this.sessions.get(summary.id); if (active !== undefined) { + await active.assertMainProfileSelection(input.agentProfile); if (overrides.kaos !== undefined) { active.setToolKaos(overrides.kaos.withCwd(summary.workDir)); } @@ -529,6 +563,10 @@ export class KimiCore implements PromisableMethods { hooks: [...(config.hooks ?? []), ...this.plugins.enabledHooks()], permissionRules: config.permission?.rules, skills: this.resolveSessionSkillConfig(config), + agents: { + userHomeDir: this.userHomeDir, + extraDirs: config.extraAgentDirs, + }, mcpConfig, experimentalFlags: this.experimentalFlags, imageLimits: this.imageLimits, @@ -543,6 +581,7 @@ export class KimiCore implements PromisableMethods { try { const resumeResult = await session.resume(); warning = resumeResult.warning; + await session.assertMainProfileSelection(input.agentProfile); await this.refreshSessionRuntimeConfig(session, config); } catch (error) { await session.close().catch(() => {}); @@ -1037,6 +1076,14 @@ export class KimiCore implements PromisableMethods { return this.sessionApi(sessionId).getSessionWarnings(payload); } + applyPersistedSecondaryModel({ sessionId }: SessionScopedPayload): void { + // Apply the same fully resolved snapshot the provider manager reads. In + // particular, keep every persisted recipe patch and the synthesized + // `__secondary__` entry instead of exposing an incomplete setter payload. + const config = this.withPrintModeDefaults(this.reloadProviderManager()); + this.requireSession(sessionId).setSecondaryModelConfig(config); + } + waitForBackgroundTasksOnPrint({ sessionId, ...payload }: SessionScopedPayload): Promise { return this.sessionApi(sessionId).waitForBackgroundTasksOnPrint(payload); } diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 92f9cd291..f848fbb8a 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -36,13 +36,24 @@ import { } from '../mcp'; import type { EnabledPluginSessionStart, PluginCommandDef } from '../plugin'; import { - DEFAULT_AGENT_PROFILES, + AgentProfileCatalogSnapshotSchema, + DEFAULT_AGENT_PROFILE_NAME, DEFAULT_INIT_PROMPT, + SessionAgentProfileCatalog, loadAgentsMd, prepareSystemPromptContext, + type AgentProfileCatalogSnapshot, type ResolvedAgentProfile, } from '../profile'; import type { ProviderManager } from './provider-manager'; +import { + resolveSecondaryModel, + wrapSubagentModelError, +} from './subagent-binding'; +import { + SECONDARY_DERIVED_MODEL_ALIAS, + secondaryModelPatch, +} from '../config/secondary-model'; import { registerBuiltinSkills, SessionSkillRegistry, @@ -58,6 +69,7 @@ import type { ToolServices } from '../tools/support/services'; import { FlagResolver, type ExperimentalFlagResolver } from '../flags'; import { ImageLimits } from '../tools/support/image-limits'; import { abortError } from '../utils/abort'; +import { resolveMainAgentProfile } from './main-agent-profile'; export interface SessionOptions { readonly kaos: Kaos; @@ -74,6 +86,7 @@ export interface SessionOptions { readonly hooks?: readonly HookDef[]; readonly permissionRules?: readonly PermissionRule[]; readonly skills?: SessionSkillConfig; + readonly agents?: SessionAgentCatalogConfig; readonly mcpConfig?: SessionMcpConfig; readonly telemetry?: TelemetryClient | undefined; readonly pluginSessionStarts?: readonly EnabledPluginSessionStart[]; @@ -102,6 +115,22 @@ export interface SessionSkillConfig { readonly builtinDir?: string; } +/** + * File-defined agent (agentfile) discovery for a session. Mirrors the skill + * discovery layout: user brand dir `/agents` and + * `~/.agents/agents`, project `.kimi-code/agents` and `.agents/agents`, plus + * configured extra dirs and explicit single files (`--agent-file`, fatal + * when invalid). `profileName` selects the main agent's profile (`--agent`). + */ +export interface SessionAgentCatalogConfig { + readonly userHomeDir?: string; + readonly explicitFiles?: readonly string[]; + readonly extraDirs?: readonly string[]; + readonly profileName?: string; + /** Already-loaded catalog prepared before a persistent session is created. */ + readonly catalog?: SessionAgentProfileCatalog; +} + export interface AgentMeta { readonly homedir?: string; readonly type: AgentType; @@ -142,6 +171,11 @@ export interface SessionMeta { custom: Record; } +interface PersistedSessionState extends SessionMeta { + /** Internal catalog binding; deliberately excluded from public SessionMeta. */ + readonly agentProfileCatalog?: unknown; +} + const BACKGROUND_KEEP_ALIVE_ON_EXIT_ENV = 'KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT'; const ACTIVE_TURN_CLOSE_TIMEOUT_MS = 8_000; @@ -181,6 +215,7 @@ export class Session { readonly hookEngine: HookEngine; readonly experimentalFlags: ExperimentalFlagResolver; readonly imageLimits: ImageLimits; + readonly agentCatalog: SessionAgentProfileCatalog; private toolKaos: Kaos; private persistenceKaos: Kaos; private additionalDirs: readonly string[]; @@ -197,11 +232,25 @@ export class Session { custom: {}, }; private writeMetadataPromise = Promise.resolve(); + private agentProfileSnapshot: AgentProfileCatalogSnapshot | undefined; private agentsMdWarning: string | undefined; private printSteerDeadline: number | undefined; private printSteerTurns = 0; + /** + * The session's live config snapshot. Initialized from `options.config`; + * updated in place by {@link setSecondaryModelConfig} so mid-session secondary-model + * switches reach the spawn-binding and tool-description readers without + * recreating the session. + */ + private runtimeConfig: KimiConfig | undefined; + + /** The session's current config snapshot (see {@link Session.runtimeConfig}). */ + get kimiConfig(): KimiConfig | undefined { + return this.runtimeConfig; + } constructor(public readonly options: SessionOptions) { + this.runtimeConfig = options.config; // Attach the per-session log sink up front so the constructor's // fire-and-forget `loadSkills` / `loadMcpServers` failures (and // anything else that races) land in the session log, not just global. @@ -240,10 +289,27 @@ export class Session { this.mcp.onStatusChange((entry) => { this.onMcpServerStatusChange(entry); }); + this.agentCatalog = + options.agents?.catalog ?? + new SessionAgentProfileCatalog({ + workDir: options.kaos.getcwd(), + brandHomeDir: options.kimiHomeDir ?? join(homedir(), '.kimi-code'), + osHomeDir: options.agents?.userHomeDir ?? homedir(), + extraDirs: options.agents?.extraDirs ?? options.config?.extraAgentDirs, + explicitFiles: options.agents?.explicitFiles, + warn: (message, error) => { + this.log.warn(message, error === undefined ? undefined : { error }); + }, + }); this.skillsReady = this.loadSkills() .catch((error: unknown) => { this.log.error('skills load failed', error); }) + // Agentfile discovery rides the same readiness gate: every createAgent + // caller already awaits it, so profile binding and the Agent tool's + // subagent list always see the fully merged catalog. A fatal source + // (an invalid --agent-file) rejects here and fails session creation. + .then(() => this.agentCatalog.ready) .then(() => { this.refreshAgentBuiltinTools(); }); @@ -338,12 +404,28 @@ export class Session { } async createMain() { + // Await the catalog (chained into skillsReady) before resolving the + // profile so a fatal agentfile source surfaces here, and so `--agent` + // sees file-defined profiles. + await this.skillsReady; + this.agentProfileSnapshot = this.agentCatalog.snapshot(); + const profile = resolveMainAgentProfile( + this.agentCatalog, + this.options.agents?.profileName, + ); const { agent } = await this.createAgent({ type: 'main' }, { - profile: DEFAULT_AGENT_PROFILES['agent'], + profile, }); if (this.options.drainAgentTasksOnStop) { agent.printDrainAgentTasksOnStop = true; } + for (const warning of this.computeSecondaryModelWarnings()) { + agent.emitEvent({ + type: 'warning', + message: warning.message, + code: warning.code, + }); + } await this.triggerSessionStart('startup'); return agent; } @@ -370,14 +452,25 @@ export class Session { // default profile so the resumed session is usable. Native sessions always // replay a non-empty system prompt and never enter this branch. const main = this.getReadyAgent('main'); - const profile = DEFAULT_AGENT_PROFILES['agent']; - if (main !== undefined && profile !== undefined && main.config.systemPrompt === '') { + const profile = this.agentCatalog.getDefault(); + if (main !== undefined && main.config.systemPrompt === '') { await this.bootstrapAgentProfile(main, profile); } await this.triggerSessionStart('resume'); return { warning }; } + async assertMainProfileSelection(requestedProfileName: string | undefined): Promise { + if (requestedProfileName === undefined) return; + const main = await this.ensureAgentResumed('main'); + const currentProfileName = main.config.profileName ?? DEFAULT_AGENT_PROFILE_NAME; + if (currentProfileName === requestedProfileName) return; + throw new KimiError( + ErrorCodes.REQUEST_INVALID, + `agent is already bound to profile "${currentProfileName}"; cannot switch to "${requestedProfileName}" in this session`, + ); + } + async close(): Promise { try { await Promise.allSettled( @@ -675,7 +768,8 @@ export class Session { this.options.kimiHomeDir, { additionalDirs: this.additionalDirs }, ); - agent.useProfile(profile, context, this.options.kimiHomeDir); + const subagentNames = Object.keys(this.agentCatalog.delegatableSubagents(profile.name)); + agent.useProfile(profile, context, this.options.kimiHomeDir, subagentNames); const { agentsMdWarning } = context; if (agentsMdWarning !== undefined) { this.agentsMdWarning = agentsMdWarning; @@ -698,9 +792,108 @@ export class Session { severity: 'warning', }); } + warnings.push(...this.computeSecondaryModelWarnings()); return warnings; } + /** + * Live-apply the core's fully resolved secondary-model config after a + * `[secondary_model]` change: the spawn + * binding (`subagent-host`), the startup-warning computation, and every live + * agent's `kimiConfig` (tool descriptions, loop control) all read the + * session snapshot, so a mid-session `/secondary_model` switch takes effect + * for the next subagent spawn without recreating the session. The core owns + * config reload, environment overlays, and derived-model synthesis. Copying + * that complete recipe and its model entries keeps spawn binding and provider + * resolution aligned without live-applying unrelated session settings. + */ + setSecondaryModelConfig(config: KimiConfig): void { + const base = this.runtimeConfig; + if (base === undefined) { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + 'Cannot set the secondary model: the session has no config.', + ); + } + const secondary = config.secondaryModel; + if (secondary?.model === undefined) { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + 'Cannot set the secondary model: persist its recipe before applying it to a session.', + ); + } + try { + this.options.providerManager?.resolveProviderConfig(secondary.model); + } catch (error) { + throw wrapSubagentModelError(error, secondary.model, undefined); + } + const models = { ...base.models }; + delete models[SECONDARY_DERIVED_MODEL_ALIAS]; + const pointedModel = config.models?.[secondary.model]; + if (pointedModel !== undefined) models[secondary.model] = pointedModel; + const derivedModel = config.models?.[SECONDARY_DERIVED_MODEL_ALIAS]; + if (derivedModel !== undefined) models[SECONDARY_DERIVED_MODEL_ALIAS] = derivedModel; + const next = { ...base, models, secondaryModel: secondary }; + this.runtimeConfig = next; + this.secondaryModelWarnings = undefined; + for (const [, entry] of this.agents) { + if (entry instanceof Agent) { + entry.updateKimiConfig(next); + } else { + // Resume in flight: push the update once the agent materializes (the + // rejection is owned by the resume caller, not by this tap). + void entry.then(({ agent }) => agent.updateKimiConfig(next)).catch(() => {}); + } + } + } + + private secondaryModelWarnings: SessionWarning[] | undefined; + + /** + * Upfront validation of the `[secondary_model]` recipe, mirroring the v2 + * warning service: the pointer is otherwise only validated lazily at spawn + * time, where a typo becomes a mid-conversation tool failure dumped on the + * parent model. Advisory only — spawn-time resolution (with the wrapped + * error) remains the backstop. Computed once per session. + */ + private computeSecondaryModelWarnings(): SessionWarning[] { + if (this.secondaryModelWarnings !== undefined) return [...this.secondaryModelWarnings]; + const warnings: SessionWarning[] = []; + const secondary = resolveSecondaryModel(this.kimiConfig, this.experimentalFlags); + if (secondary?.model !== undefined) { + const boundAlias = + secondaryModelPatch(secondary) === undefined + ? secondary.model + : SECONDARY_DERIVED_MODEL_ALIAS; + try { + const resolved = this.options.providerManager?.resolveProviderConfig(boundAlias); + const supported = resolved?.supportEfforts ?? []; + if ( + secondary.defaultEffort !== undefined && + supported.length > 0 && + !supported.includes(secondary.defaultEffort) + ) { + warnings.push({ + code: 'secondary-model-effort-not-listed', + message: + `Secondary model default_effort "${secondary.defaultEffort}" is not in the resolved model's ` + + `support_efforts (${supported.join(', ')}). Subagents will resolve thinking without it.`, + severity: 'warning', + }); + } + } catch (error) { + const wrapped = wrapSubagentModelError(error, boundAlias, undefined); + warnings.push({ + code: 'secondary-model-invalid', + message: `${wrapped instanceof Error ? wrapped.message : String(wrapped)} Subagent spawns will fail until this is fixed.`, + severity: 'warning', + }); + } + } + this.secondaryModelWarnings = warnings; + return [...warnings]; + } + private async computeAgentsMdWarning(): Promise { if (this.agentsMdWarning !== undefined) { return this.agentsMdWarning; @@ -813,7 +1006,14 @@ export class Session { } writeMetadata() { - const text = JSON.stringify(this.metadata, null, 2); + const text = JSON.stringify( + { + ...this.metadata, + agentProfileCatalog: this.agentProfileSnapshot, + }, + null, + 2, + ); const write = async () => { await this.persistenceKaos.mkdir(this.options.homedir, { parents: true, existOk: true }); await this.persistenceKaos.writeText(this.metadataPath, text); @@ -824,7 +1024,20 @@ export class Session { async readMetadata() { const text = await this.persistenceKaos.readText(this.metadataPath); - this.metadata = JSON.parse(text); + const persisted = JSON.parse(text) as PersistedSessionState; + const { agentProfileCatalog, ...metadata } = persisted; + this.metadata = metadata; + if (agentProfileCatalog !== undefined) { + const parsed = AgentProfileCatalogSnapshotSchema.safeParse(agentProfileCatalog); + if (parsed.success) { + this.agentProfileSnapshot = parsed.data; + this.agentCatalog.restoreSnapshot(parsed.data); + } else { + this.log.warn('stored agent profile catalog is invalid; using discovered profiles', { + error: parsed.error.message, + }); + } + } return this.metadata; } @@ -928,12 +1141,14 @@ export class Session { const parentAgent = parentAgentId !== null ? this.getReadyAgent(parentAgentId) : undefined; const cwd = parentAgent?.config.cwd ?? this.toolKaos.getcwd(); let agent!: Agent; + const subagentHost = + config.subagentHost ?? new SessionSubagentHost(this, id, () => agent); agent = new Agent({ ...config, type, kaos: this.toolKaos.withCwd(cwd), toolServices: this.options.toolServices, - config: this.options.config, + config: this.kimiConfig, homedir, // Session-level, shared across agents: originals persisted for // compression captions live with the session, not the agent. @@ -942,7 +1157,7 @@ export class Session { rpc: proxyWithExtraPayload(this.rpc, { agentId: id }), modelProvider: this.options.providerManager, hookEngine: config.hookEngine ?? this.hookEngine, - subagentHost: config.subagentHost ?? new SessionSubagentHost(this, id), + subagentHost, mcp: this.mcp, permission: this.permissionOptions(parentAgentId, config.permission), telemetry: withTelemetryProperties(this.telemetry, { agent_id: id }), @@ -1069,13 +1284,11 @@ export class Session { const profileName = agent.config.profileName; if (profileName === undefined) return undefined; if (meta.type === 'sub') { - const parentProfileName = parentAgent?.config.profileName; - return ( - DEFAULT_AGENT_PROFILES[parentProfileName ?? 'agent']?.subagents?.[profileName] ?? - DEFAULT_AGENT_PROFILES['agent']?.subagents?.[profileName] - ); + return this.agentCatalog.delegatableSubagents(parentAgent?.config.profileName ?? 'agent')[ + profileName + ]; } - return DEFAULT_AGENT_PROFILES[profileName]; + return this.agentCatalog.get(profileName); } private nextGeneratedAgentId(): string { @@ -1111,6 +1324,7 @@ export class Session { } export * from './subagent-host'; +export * from './subagent-binding'; export * from './store'; function initCompletionReminder(agentsMd: string): string { diff --git a/packages/agent-core/src/session/main-agent-profile.ts b/packages/agent-core/src/session/main-agent-profile.ts new file mode 100644 index 000000000..1171493b7 --- /dev/null +++ b/packages/agent-core/src/session/main-agent-profile.ts @@ -0,0 +1,19 @@ +import { ErrorCodes, KimiError } from '#/errors'; +import type { ResolvedAgentProfile, SessionAgentProfileCatalog } from '../profile'; + +export function resolveMainAgentProfile( + catalog: SessionAgentProfileCatalog, + profileName?: string, +): ResolvedAgentProfile { + const profile = profileName === undefined ? catalog.getDefault() : catalog.get(profileName); + if (profile !== undefined) return profile; + + const available = catalog + .list() + .map((candidate) => candidate.name) + .join(', '); + throw new KimiError( + ErrorCodes.AGENT_NOT_FOUND, + `Agent profile "${profileName}" was not found. Available profiles: ${available}`, + ); +} diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index 2bdb29a42..7fb313b46 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -99,6 +99,7 @@ export class ProviderManager implements ModelProvider { throw new KimiError( ErrorCodes.CONFIG_INVALID, `Model "${model}" is not configured in config.toml. Add a [models."${model}"] entry with max_context_size.`, + { details: { model } }, ); } diff --git a/packages/agent-core/src/session/subagent-batch.ts b/packages/agent-core/src/session/subagent-batch.ts index 7646de387..492ae8f66 100644 --- a/packages/agent-core/src/session/subagent-batch.ts +++ b/packages/agent-core/src/session/subagent-batch.ts @@ -6,6 +6,7 @@ import type { SpawnSubagentOptions, SubagentHandle, } from './subagent-host'; +import type { SubagentModelChoice } from './subagent-binding'; import { isUserCancellation } from '../utils/abort'; /* @@ -51,6 +52,7 @@ type BaseQueuedSubagentTask = { readonly runInBackground: boolean; readonly timeout?: number; readonly signal?: AbortSignal; + readonly modelChoice?: SubagentModelChoice; }; export type SpawnQueuedSubagentTask = BaseQueuedSubagentTask & { @@ -324,6 +326,7 @@ export class SubagentBatch { const spawnOptions: SpawnSubagentOptions = { profileName: task.profileName, swarmItem: task.swarmItem, + modelChoice: task.modelChoice, ...runOptions, }; handle = await this.launcher.spawn(spawnOptions); diff --git a/packages/agent-core/src/session/subagent-binding.ts b/packages/agent-core/src/session/subagent-binding.ts new file mode 100644 index 000000000..430f36c52 --- /dev/null +++ b/packages/agent-core/src/session/subagent-binding.ts @@ -0,0 +1,118 @@ +import { + SECONDARY_DERIVED_MODEL_ALIAS, + SECONDARY_MODEL_ENV, + secondaryModelPatch, + type KimiConfig, + type SecondaryModelConfig, +} from '../config'; +import { ErrorCodes, KimiError } from '../errors'; +import type { ExperimentalFlagResolver } from '../flags'; +import type { AgentModelPreference } from '../profile'; + +/** + * Subagent model binding — the secondary-model half of the spawn decision. + * + * When the `secondary-model` experiment is enabled and `[secondary_model]` is + * configured, newly spawned subagents bind to it by default instead of + * inheriting the caller's model. The caller (the parent model, through the + * `Agent` / `AgentSwarm` tool `model` parameter) or the spawned profile (via + * `model_preference`) can force `primary`. A recipe with patch fields binds + * the synthesized derived entry ({@link SECONDARY_DERIVED_MODEL_ALIAS}, + * materialized by `applySecondaryModelConfig`); a pointer-only recipe binds + * the pointed entry directly. `default_effort` is passed as the explicit + * subagent thinking effort; without it the child resolves thinking naturally + * (global thinking config → the bound model's default effort) rather than + * inheriting the caller's level. When unset, spawning behavior is unchanged: + * subagents inherit the caller's model and effort. + */ + +export type SubagentModelChoice = AgentModelPreference; + +export interface SubagentModelBinding { + readonly modelAlias: string | undefined; + readonly thinkingEffort?: string; +} + +export function resolveSecondaryModel( + config: KimiConfig | undefined, + flags: ExperimentalFlagResolver, +): SecondaryModelConfig | undefined { + if (!flags.enabled('secondary-model')) return undefined; + return config?.secondaryModel; +} + +/** + * Resolve which model a newly spawned subagent binds to. `requested` is the + * explicit per-spawn choice (tool argument or profile preference); `own` is + * the caller's current model state, used when inheriting. + */ +export function resolveSubagentBinding( + config: KimiConfig | undefined, + flags: ExperimentalFlagResolver, + own: { readonly modelAlias: string | undefined; readonly thinkingEffort: string }, + requested?: SubagentModelChoice, +): SubagentModelBinding { + const secondary = resolveSecondaryModel(config, flags); + if (requested !== 'primary' && secondary?.model !== undefined) { + return { + modelAlias: + secondaryModelPatch(secondary) === undefined + ? secondary.model + : SECONDARY_DERIVED_MODEL_ALIAS, + thinkingEffort: secondary.defaultEffort, + }; + } + return { modelAlias: own.modelAlias, thinkingEffort: own.thinkingEffort }; +} + +/** + * The "Available models" block appended to the `Agent` / `AgentSwarm` tool + * descriptions so the parent model knows it can pick. `undefined` when the + * secondary model is not configured or the caller's model is not bound yet. + */ +export function buildSubagentModelDescriptions( + config: KimiConfig | undefined, + flags: ExperimentalFlagResolver, + callerModelAlias: string | undefined, +): string | undefined { + const secondaryModel = resolveSecondaryModel(config, flags)?.model; + if (secondaryModel === undefined || callerModelAlias === undefined) return undefined; + return [ + 'Available models (pass via model):', + `- secondary: ${secondaryModel} (default) — the configured secondary model; prefer it for routine subagent tasks`, + `- primary: ${callerModelAlias} — the main model you are running on; use it for hard, quality-sensitive subagent tasks`, + ].join('\n'); +} + +/** + * Point a spawn-time model resolution failure at the secondary-model + * configuration when the bound model is not the caller's own — otherwise the + * parent model sees a bare "model not configured" error with no hint that it + * comes from `[secondary_model]`. + */ +export function wrapSubagentModelError( + error: unknown, + boundModel: string, + callerModelAlias: string | undefined, +): unknown { + if (boundModel === callerModelAlias) return error; + if (!(error instanceof KimiError) || error.code !== ErrorCodes.CONFIG_INVALID) return error; + // ProviderManager tags only the missing-alias failure with details.model; + // malformed aliases and providers must keep their own actionable errors. + if (error.details?.['model'] !== boundModel) return error; + const displayModel = + boundModel === SECONDARY_DERIVED_MODEL_ALIAS + ? `the derived entry "${SECONDARY_DERIVED_MODEL_ALIAS}"` + : `"${boundModel}"`; + return new KimiError( + error.code, + `${error.message} (secondary model ${displayModel} comes from [secondary_model].model / ${SECONDARY_MODEL_ENV} — check that it names a valid [models] entry)`, + { + cause: error, + details: { + ...error.details, + secondaryModel: boundModel, + }, + }, + ); +} diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index bb80a1820..790e1b4d0 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -11,7 +11,6 @@ import { DenyAllPermissionPolicy } from '../agent/permission/policies/deny-all'; import { InMemoryAgentRecordPersistence } from '../agent/records'; import { isAbortError } from '../loop/errors'; import { - DEFAULT_AGENT_PROFILES, prepareSystemPromptContext, type ResolvedAgentProfile, } from '../profile'; @@ -21,6 +20,12 @@ import { } from '../utils/abort'; import { collectGitContext } from './git-context'; import type { Session } from './index'; +import { + resolveSubagentBinding, + wrapSubagentModelError, + type SubagentModelBinding, + type SubagentModelChoice, +} from './subagent-binding'; import { SubagentBatch, resolveSwarmMaxConcurrency, @@ -121,6 +126,12 @@ export interface RunSubagentOptions { export interface SpawnSubagentOptions extends RunSubagentOptions { readonly profileName: string; readonly swarmItem?: string; + /** + * Explicit per-spawn model choice from the tool call. The profile's own + * `modelPreference` applies when this is omitted; both only take effect + * with the `secondary-model` experiment enabled. + */ + readonly modelChoice?: SubagentModelChoice; } type SubagentCompletion = { @@ -128,6 +139,8 @@ type SubagentCompletion = { readonly usage?: TokenUsage; }; +type OwnerAgentResolver = () => Agent; + export type SubagentHandle = { readonly agentId: string; readonly profileName: string; @@ -147,6 +160,7 @@ export class SessionSubagentHost { constructor( private readonly session: Session, private readonly ownerAgentId: string, + private readonly getOwnerAgent?: OwnerAgentResolver, ) {} async spawn(options: SpawnSubagentOptions): Promise { @@ -161,7 +175,7 @@ export class SessionSubagentHost { const completion = this.runWithActiveChild(id, options, async (runOptions) => { this.emitSubagentSpawned(parent, id, profile.name, runOptions); try { - await this.configureChild(parent, agent, profile); + await this.configureChild(parent, agent, profile, options.modelChoice); return await this.runPromptTurn(parent, id, agent, profile.name, runOptions); } catch (error) { this.emitSubagentFailed(parent, id, runOptions, error); @@ -182,7 +196,7 @@ export class SessionSubagentHost { const completion = this.runWithActiveChild(agentId, options, async (runOptions) => { this.emitSubagentSpawned(parent, agentId, profileName, runOptions); try { - child.config.update({ modelAlias: parent.config.modelAlias }); + this.reInheritParentModel(parent, child); return await this.runPromptTurn(parent, agentId, child, profileName, runOptions); } catch (error) { this.emitSubagentFailed(parent, agentId, runOptions, error); @@ -198,7 +212,7 @@ export class SessionSubagentHost { const completion = this.runWithActiveChild(agentId, options, async (runOptions) => { try { runOptions.signal.throwIfAborted(); - child.config.update({ modelAlias: parent.config.modelAlias }); + this.reInheritParentModel(parent, child); this.emitSubagentStarted(parent, agentId); const turnId = child.turn.retry('agent-host'); if (turnId === null) { @@ -308,15 +322,41 @@ export class SessionSubagentHost { } private resolveProfile(parent: Agent, profileName: string): ResolvedAgentProfile { - const profile = - DEFAULT_AGENT_PROFILES[parent.config.profileName ?? 'agent']?.subagents?.[profileName] ?? - DEFAULT_AGENT_PROFILES['agent']?.subagents?.[profileName]; + const profile = this.resolveDelegatableSubagents( + parent.config.profileName, + parent.config.subagentNames, + )[profileName]; if (profile === undefined) { throw new Error(`Subagent profile "${profileName}" was not found`); } return profile; } + /** + * The subagent types the given profile may delegate to (its own linked set, + * or the default profile's when it declares none). Backs the `Agent` tool's + * "Available agent types" description. + */ + delegatableSubagents(callerProfileName?: string): Record { + const owner = this.getOwnerAgent?.() ?? this.session.getReadyAgent(this.ownerAgentId); + return this.resolveDelegatableSubagents(callerProfileName, owner?.config.subagentNames); + } + + private resolveDelegatableSubagents( + callerProfileName: string | undefined, + persistedNames: readonly string[] | undefined, + ): Record { + const catalogProfiles = this.session.agentCatalog.delegatableSubagents(callerProfileName); + if (persistedNames === undefined) return catalogProfiles; + + return Object.fromEntries( + persistedNames.flatMap((name) => { + const profile = catalogProfiles[name]; + return profile === undefined ? [] : [[name, profile]]; + }), + ); + } + private runWithActiveChild( childId: string, options: RunSubagentOptions, @@ -400,12 +440,13 @@ export class SessionSubagentHost { parent: Agent, child: Agent, profile: ResolvedAgentProfile, + modelChoice?: SubagentModelChoice, ): Promise { - // A subagent always inherits the parent agent's model. + const binding = this.resolveSpawnBinding(parent, profile, modelChoice); child.config.update({ cwd: parent.config.cwd, - modelAlias: parent.config.modelAlias, - thinkingEffort: parent.config.thinkingEffort, + modelAlias: binding.modelAlias, + thinkingEffort: binding.thinkingEffort, }); const context = await prepareSystemPromptContext( @@ -413,10 +454,54 @@ export class SessionSubagentHost { this.session.options.kimiHomeDir, { additionalDirs: child.getAdditionalDirs() }, ); - child.useProfile(profile, context, this.session.options.kimiHomeDir); + const subagentNames = Object.keys( + this.session.agentCatalog.delegatableSubagents(profile.name), + ); + child.useProfile(profile, context, this.session.options.kimiHomeDir, subagentNames); child.tools.inheritUserTools(parent.tools); } + /** + * The model a newly spawned subagent binds to: the configured secondary + * model by default (when the experiment is on), otherwise the parent's + * model and effort, inherited as before. The bound alias is validated up + * front so a dangling `[secondary_model]` pointer fails the spawn with a + * wrapped, actionable error instead of a mid-turn provider failure. + */ + private resolveSpawnBinding( + parent: Agent, + profile: ResolvedAgentProfile, + modelChoice?: SubagentModelChoice, + ): SubagentModelBinding { + const binding = resolveSubagentBinding( + this.session.kimiConfig, + this.session.experimentalFlags, + { modelAlias: parent.config.modelAlias, thinkingEffort: parent.config.thinkingEffort }, + modelChoice ?? profile.modelPreference, + ); + if (binding.modelAlias !== undefined) { + const providerManager = this.session.options.providerManager; + try { + providerManager?.resolveProviderConfig(binding.modelAlias); + } catch (error) { + throw wrapSubagentModelError(error, binding.modelAlias, parent.config.modelAlias); + } + } + return binding; + } + + /** + * Resume/retry historically re-synced the child to the parent's current + * model so subagents follow mid-session `/model` switches. With the + * `secondary-model` experiment on, a resumed subagent instead keeps the + * model it was bound to at spawn (v2 semantics: no child-follows-parent + * invariant). + */ + private reInheritParentModel(parent: Agent, child: Agent): void { + if (this.session.experimentalFlags.enabled('secondary-model')) return; + child.config.update({ modelAlias: parent.config.modelAlias }); + } + /** * Hold the run open until the child agent's background tasks (background * Bash, nested background agents) settle — the print-mode (`kimi -p`) diff --git a/packages/agent-core/src/skill/builtin/update-config.md b/packages/agent-core/src/skill/builtin/update-config.md index f9b694672..859011424 100644 --- a/packages/agent-core/src/skill/builtin/update-config.md +++ b/packages/agent-core/src/skill/builtin/update-config.md @@ -20,7 +20,7 @@ echo "$HOME/.kimi-code" Use the first line when it is non-empty; otherwise use the second line. In the rest of this skill, `` means that resolved root — **never assume `~/.kimi-code`**. -- **`config.toml`** — agent / runtime settings: `default_model`, `providers`, `models`, `thinking`, `permission`, `hooks`, `loop_control`, etc. +- **`config.toml`** — agent / runtime settings: `default_model`, `secondary_model` (subagent model), `providers`, `models`, `thinking`, `permission`, `hooks`, `loop_control`, etc. - **`tui.toml`** — terminal-UI / client preferences: `theme`, `[editor].command`, `[notifications]`, `[upgrade].auto_install` (auto-update). These can usually also be changed with the interactive commands `/config`, `/theme`, `/editor`, which is easier — prefer pointing the user at those. The "read → copy → Edit → validate → back up → overwrite" flow below applies to both files; only **which reload command applies** differs (see Capability 4). 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 7bcaa599a..aac81bc6e 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts @@ -31,6 +31,12 @@ export const AgentSwarmToolInputSchema = z .describe( 'Subagent type used for every new subagent spawned from items; defaults to coder when omitted. Resumed subagents always keep their original type, so passing subagent_type together with resume_agent_ids is allowed — it only affects the item-based spawns.', ), + model: z + .enum(['primary', 'secondary']) + .optional() + .describe( + 'Model for every new subagent spawned from items: "secondary" uses the configured secondary model (the default when one is set), "primary" uses the model you are running on. Resumed subagents keep their bound model.', + ), prompt_template: z .string() .trim() @@ -85,7 +91,7 @@ interface SwarmRunResult { export class AgentSwarmTool implements BuiltinTool { readonly name = 'AgentSwarm' as const; - readonly description = AGENT_SWARM_DESCRIPTION; + readonly description: string; readonly parameters: Record = toInputJsonSchema(AgentSwarmToolInputSchema); constructor( @@ -94,7 +100,13 @@ 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, - ) {} + subagentModelDescription?: string, + ) { + this.description = + subagentModelDescription === undefined + ? AGENT_SWARM_DESCRIPTION + : `${AGENT_SWARM_DESCRIPTION}\n\n${subagentModelDescription}`; + } resolveExecution(args: AgentSwarmToolInput): ToolExecution { const agentCount = (args.items?.length ?? 0) + Object.keys(args.resume_agent_ids ?? {}).length; @@ -149,6 +161,7 @@ export class AgentSwarmTool implements BuiltinTool { swarmItem: spec.item, signal, timeout: this.subagentTimeoutMs ?? DEFAULT_SUBAGENT_TIMEOUT_MS, + modelChoice: args.model, }; if (spec.kind === 'resume') { return { diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.ts b/packages/agent-core/src/tools/builtin/collaboration/agent.ts index e12a8c16d..60e7ac571 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.ts @@ -66,6 +66,12 @@ export const AgentToolInputSchema = z.preprocess( .describe( 'One of the available agent types (see "Available agent types" in this tool description). Defaults to "coder" when omitted.', ), + model: z + .enum(['primary', 'secondary']) + .optional() + .describe( + 'Model for the new subagent: "secondary" uses the configured secondary model (the default when one is set), "primary" uses the model you are running on. Only applies when spawning a new agent — a resumed agent keeps its bound model.', + ), resume: z .string() .optional() @@ -116,6 +122,8 @@ export class AgentTool implements BuiltinTool { log?: Logger; allowBackground?: boolean | undefined; subagentTimeoutMs?: number | undefined; + subagentModelDescription?: string; + showModelPreferences?: boolean; }, ) { const log = options?.log; @@ -123,13 +131,21 @@ 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 typeLines = buildSubagentDescriptions(subagents); + const typeLines = buildSubagentDescriptions( + subagents, + options?.showModelPreferences ?? false, + ); const baseDescription = `${AGENT_DESCRIPTION_BASE}\n\n${ this.allowBackground ? AGENT_BACKGROUND_DESCRIPTION : AGENT_BACKGROUND_DISABLED_DESCRIPTION }`; - this.description = typeLines - ? `${baseDescription}\n\nAvailable agent types (pass via subagent_type):\n${typeLines}` - : baseDescription; + const sections = [baseDescription]; + if (typeLines) { + sections.push(`Available agent types (pass via subagent_type):\n${typeLines}`); + } + if (options?.subagentModelDescription !== undefined) { + sections.push(options.subagentModelDescription); + } + this.description = sections.join('\n\n'); this.log = log; } @@ -212,6 +228,7 @@ export class AgentTool implements BuiltinTool { ? await this.subagentHost.resume(resumeAgentId!, runOptions) : await this.subagentHost.spawn({ profileName: requestedProfileName ?? 'coder', + modelChoice: args.model, ...runOptions, }); } catch (error) { @@ -373,7 +390,10 @@ function launchErrorMessage(error: unknown, signal: AbortSignal): string { return error instanceof Error ? error.message : String(error); } -function buildSubagentDescriptions(subagents: ResolvedAgentProfile['subagents']): string { +function buildSubagentDescriptions( + subagents: ResolvedAgentProfile['subagents'], + showModelPreferences: boolean, +): string { if (subagents === undefined) return ''; return Object.entries(subagents) .map(([name, subagent]) => { @@ -381,8 +401,19 @@ function buildSubagentDescriptions(subagents: ResolvedAgentProfile['subagents']) (part): part is string => part !== undefined && part.length > 0, ); const header = details.length === 0 ? `- ${name}` : `- ${name}: ${details.join(' ')}`; - if (subagent.tools.length === 0) return header; - return `${header}\n Tools: ${subagent.tools.join(', ')}`; + const deniedExact = new Set( + (subagent.disallowedTools ?? []).filter((tool) => !tool.startsWith('mcp__')), + ); + const shownTools = subagent.tools.filter((tool) => !deniedExact.has(tool)); + const lines = [header]; + if (showModelPreferences && subagent.modelPreference !== undefined) { + lines.push(` Model preference: ${subagent.modelPreference}`); + } + if (shownTools.length > 0) lines.push(` Tools: ${shownTools.join(', ')}`); + if (subagent.disallowedTools !== undefined && subagent.disallowedTools.length > 0) { + lines.push(` Disabled: ${subagent.disallowedTools.join(', ')}`); + } + return lines.join('\n'); }) .join('\n'); } diff --git a/packages/agent-core/test/agent/records/index.test.ts b/packages/agent-core/test/agent/records/index.test.ts index 91b4aed47..6bfc1a771 100644 --- a/packages/agent-core/test/agent/records/index.test.ts +++ b/packages/agent-core/test/agent/records/index.test.ts @@ -265,6 +265,26 @@ describe('AgentRecords persistence metadata', () => { expect(agent.goal.getGoal().goal?.goalId).toBe('g1'); }); + it('replays the deny list of a tools.set_active_tools record', async () => { + const persistence = new InMemoryAgentRecordPersistence([ + { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 }, + { + type: 'tools.set_active_tools', + names: ['Read', 'Write', 'Bash'], + disallowedNames: ['Write'], + } as AgentRecord, + ]); + const { agent } = testAgent({ persistence }); + agent.config.update({ modelAlias: 'mock-model' }); + + await agent.records.replay(); + + const names = agent.tools.loopTools.map((tool) => tool.name); + expect(names).toContain('Read'); + expect(names).toContain('Bash'); + expect(names).not.toContain('Write'); + }); + it('restores goal.* records during replay', async () => { const persistence = new InMemoryAgentRecordPersistence([ { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 }, diff --git a/packages/agent-core/test/agent/tool-select.e2e.test.ts b/packages/agent-core/test/agent/tool-select.e2e.test.ts index f5e584f1e..2531149ef 100644 --- a/packages/agent-core/test/agent/tool-select.e2e.test.ts +++ b/packages/agent-core/test/agent/tool-select.e2e.test.ts @@ -277,6 +277,32 @@ describe('disclosure mode — top-level convergence and announcements', () => { expect(ctx.agent.context.history.filter(isLoadableToolsAnnouncement)).toHaveLength(1); }); + it('honors a disallowedTools deny of select_tools itself', async () => { + const ctx = await disclosureAgent(); + ctx.agent.tools.setActiveTools(['Read', 'mcp__*'], ['select_tools']); + + // The denylist wins over the disclosure gate: select_tools leaves the + // executable table and reports inactive (mirrors agent-core-v2 + // isToolActiveForDisclosure). + const loopNames = ctx.agent.tools.loopTools.map((t) => t.name); + expect(loopNames).not.toContain('select_tools'); + expect(loopNames).toContain('Read'); + expect(ctx.agent.tools.data().find((info) => info.name === 'select_tools')?.active).toBe( + false, + ); + + // The wire view drops it too, and a call lands on the missing-tool path. + ctx.mockNextResponse({ type: 'text', text: 'try' }, selectCall('call-1', [GRAFANA_TOOL])); + ctx.mockNextResponse({ type: 'text', text: 'ok' }); + await runTurn(ctx, 'load it'); + + expect(ctx.llmCalls[0]!.tools.map((t) => t.name)).not.toContain('select_tools'); + expect( + ctx.agent.context.history.some((m) => m.role === 'tool' && m.isError === true), + ).toBe(true); + expect(schemaMessages(ctx)).toHaveLength(0); + }); + it('announces tools_removed at the next boundary after a server disconnects', async () => { const ctx = await disclosureAgent(); ctx.mockNextResponse({ type: 'text', text: 'one' }); diff --git a/packages/agent-core/test/agent/tool.test.ts b/packages/agent-core/test/agent/tool.test.ts index 13ad93cb7..95e2c00ae 100644 --- a/packages/agent-core/test/agent/tool.test.ts +++ b/packages/agent-core/test/agent/tool.test.ts @@ -1,3 +1,9 @@ +/** + * Scenario: Agent builtin-tool behavior and the tool contract exposed to the LLM. + * Responsibilities: active-tool policy, tool execution, and observable descriptions/schemas. + * Wiring: real Agent and ToolManager with only process/model/subagent boundaries stubbed. + * Run: cd packages/agent-core && ../../node_modules/.bin/vitest run test/agent/tool.test.ts + */ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -125,6 +131,7 @@ describe('Agent tools', () => { completion, }), resume: vi.fn(), + delegatableSubagents: vi.fn(() => ({})), } as unknown as SessionSubagentHost; const ctx = testAgent({ subagentHost }); ctx.configure({ tools: ['Agent'] }); @@ -259,8 +266,99 @@ describe('Agent tools', () => { expect(managedBash!.description).toContain('run_in_background=true'); }); + it('disables Bash background mode when an active task management tool is denied', async () => { + const ctx = testAgent(); + ctx.configure(); + ctx.agent.tools.setActiveTools( + ['Bash', 'TaskList', 'TaskOutput', 'TaskStop'], + ['TaskOutput'], + ); + + const bash = ctx.agent.tools.loopTools.find((tool) => tool.name === 'Bash'); + expect(bash).toBeDefined(); + expect(bash!.description).toContain('Background execution is disabled for this agent.'); + await expect( + executeTool(bash!, { + turnId: '0', + toolCallId: 'call_bash', + args: { command: 'sleep 10', run_in_background: true, description: 'watch' }, + signal, + }), + ).resolves.toMatchObject({ + isError: true, + output: + 'Background execution is not available for this agent because TaskOutput and TaskStop are not enabled.', + }); + }); + + it('disables Agent background mode when an active task management tool is denied', async () => { + const subagentHost = { + delegatableSubagents: vi.fn(() => ({})), + spawn: vi.fn(), + } as unknown as SessionSubagentHost; + const ctx = testAgent({ subagentHost }); + ctx.configure(); + ctx.agent.tools.setActiveTools( + ['Agent', 'TaskList', 'TaskOutput', 'TaskStop'], + ['TaskStop'], + ); + + const agent = ctx.agent.tools.loopTools.find((tool) => tool.name === 'Agent'); + expect(agent).toBeDefined(); + expect(agent!.description).toContain('Background agent execution is disabled for this agent.'); + await expect( + executeTool(agent!, { + turnId: '0', + toolCallId: 'call_agent', + args: { + prompt: 'Investigate deeply', + description: 'Investigate deeply', + subagent_type: 'coder', + run_in_background: true, + }, + signal, + }), + ).resolves.toMatchObject({ + isError: true, + output: + 'Background agent execution is not available for this agent because TaskList, TaskOutput, and TaskStop are not enabled.', + }); + expect(subagentHost.spawn).not.toHaveBeenCalled(); + }); + + it('removes denied exact tool names from the active set', () => { + const ctx = testAgent(); + ctx.configure(); + ctx.agent.tools.setActiveTools(['Read', 'Bash', 'Grep'], ['Bash']); + + const names = ctx.agent.tools.loopTools.map((tool) => tool.name); + expect(names).not.toContain('Bash'); + expect(names).toContain('Read'); + expect(names).toContain('Grep'); + expect(ctx.agent.tools.data().find((info) => info.name === 'Bash')?.active).toBe(false); + }); + + it('applies a profile disallowedTools denylist through useProfile', () => { + const ctx = testAgent(); + ctx.configure(); + ctx.agent.useProfile({ + name: 'restricted', + systemPrompt: () => 'sys', + tools: ['Read', 'Write', 'Edit', 'Bash', 'Grep'], + disallowedTools: ['Write', 'Edit'], + }); + + const names = ctx.agent.tools.loopTools.map((tool) => tool.name); + expect(names).toContain('Read'); + expect(names).toContain('Bash'); + expect(names).not.toContain('Write'); + expect(names).not.toContain('Edit'); + }); + it('exposes AgentSwarm when a subagent host is available', () => { - const subagentHost = {} as unknown as SessionSubagentHost; + const subagentHost = { + delegatableSubagents: vi.fn(() => ({})), + } as unknown as SessionSubagentHost; const ctx = testAgent({ subagentHost, @@ -271,6 +369,52 @@ describe('Agent tools', () => { expect(ctx.agent.tools.loopTools.some((tool) => tool.name === 'AgentSwarm')).toBe(true); }); + it('shows the model preference for a subagent type when the experiment is enabled', () => { + const subagentHost = { + delegatableSubagents: vi.fn(() => ({ + coder: { + name: 'coder', + description: 'General coding.', + systemPrompt: () => 'coder prompt', + tools: ['Read'], + modelPreference: 'primary' as const, + }, + })), + } as unknown as SessionSubagentHost; + const ctx = testAgent({ + subagentHost, + experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS, { 'secondary-model': true }), + }); + ctx.configure({ tools: ['Agent'] }); + + const description = ctx.agent.tools.loopTools.find((tool) => tool.name === 'Agent')?.description; + + expect(description).toContain('- coder: General coding.\n Model preference: primary'); + }); + + it('hides model preferences when the experiment is disabled', () => { + const subagentHost = { + delegatableSubagents: vi.fn(() => ({ + coder: { + name: 'coder', + description: 'General coding.', + systemPrompt: () => 'coder prompt', + tools: ['Read'], + modelPreference: 'primary' as const, + }, + })), + } as unknown as SessionSubagentHost; + const ctx = testAgent({ + subagentHost, + experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS), + }); + ctx.configure({ tools: ['Agent'] }); + + const description = ctx.agent.tools.loopTools.find((tool) => tool.name === 'Agent')?.description; + + expect(description).not.toContain('Model preference:'); + }); + it('self-heals the builtin tool table when the provider becomes resolvable after construction', () => { // The ProviderManager reads this live config; it starts with no model or // provider, so hasProvider is false at Agent construction and diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index 255b9da54..08b29fc19 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -2889,8 +2889,13 @@ function agentSwarmCall(): ToolCall { function mockSubagentHost>( host: T, ): T & SessionSubagentHost { - return { spawn: vi.fn(), resume: vi.fn(), runQueued: vi.fn(), ...host } as unknown as T & - SessionSubagentHost; + return { + spawn: vi.fn(), + resume: vi.fn(), + runQueued: vi.fn(), + delegatableSubagents: vi.fn(() => ({})), + ...host, + } as unknown as T & SessionSubagentHost; } interface ApiErrorTelemetryCase { diff --git a/packages/agent-core/test/config/secondary-model.test.ts b/packages/agent-core/test/config/secondary-model.test.ts new file mode 100644 index 000000000..723cc1d9d --- /dev/null +++ b/packages/agent-core/test/config/secondary-model.test.ts @@ -0,0 +1,247 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { afterEach, describe, expect, it } from 'vitest'; +import { join } from 'pathe'; + +import { getDefaultConfig, loadRuntimeConfig, writeConfigFile } from '../../src/config'; +import { + applySecondaryModelConfig, + SECONDARY_DERIVED_MODEL_ALIAS, + secondaryModelPatch, + stripSecondaryModelConfig, +} from '../../src/config/secondary-model'; +import { parseConfigString } from '../../src/config/toml'; +import type { KimiConfig, ModelAlias } from '../../src/config/schema'; + +const baseAlias: ModelAlias = { + provider: 'p1', + model: 'cheap-chat', + maxContextSize: 131072, + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + defaultEffort: 'low', +}; + +function configWithSecondary(): KimiConfig { + return { + providers: { + p1: { type: 'kimi', apiKey: 'sk-test' }, + }, + defaultModel: 'main', + models: { + main: { provider: 'p1', model: 'flagship', maxContextSize: 262144 }, + cheap: baseAlias, + }, + secondaryModel: { + model: 'cheap', + maxContextSize: 65536, + defaultEffort: 'high', + }, + }; +} + +describe('secondaryModelPatch', () => { + it('returns undefined when no patch field is set', () => { + expect(secondaryModelPatch(undefined)).toBeUndefined(); + expect(secondaryModelPatch({})).toBeUndefined(); + expect(secondaryModelPatch({ model: 'cheap' })).toBeUndefined(); + expect(secondaryModelPatch({ model: 'cheap', defaultEffort: undefined })).toBeUndefined(); + }); + + it('returns every field except model as the patch', () => { + expect( + secondaryModelPatch({ model: 'cheap', maxContextSize: 1024, defaultEffort: 'low' }), + ).toEqual({ maxContextSize: 1024, defaultEffort: 'low' }); + }); +}); + +describe('applySecondaryModelConfig', () => { + it('returns the config unchanged when nothing is configured', () => { + const base = getDefaultConfig(); + expect(applySecondaryModelConfig(base, {})).toBe(base); + }); + + it('synthesizes the derived entry from the pointed model and the patch', () => { + const config = applySecondaryModelConfig(configWithSecondary(), {}); + const derived = config.models?.[SECONDARY_DERIVED_MODEL_ALIAS]; + expect(derived).toEqual({ + ...baseAlias, + overrides: { + maxContextSize: 65536, + defaultEffort: 'high', + }, + }); + // The pointed entry and the rest of the registry stay untouched. + expect(config.models?.['cheap']).toEqual(baseAlias); + expect(config.models?.['main']).toEqual({ + provider: 'p1', + model: 'flagship', + maxContextSize: 262144, + }); + }); + + it('merges the patch over the base entry overrides', () => { + const base = configWithSecondary(); + base.models = { + cheap: { ...baseAlias, overrides: { maxContextSize: 32768, displayName: 'base-name' } }, + }; + const config = applySecondaryModelConfig(base, {}); + expect(config.models?.[SECONDARY_DERIVED_MODEL_ALIAS]?.overrides).toEqual({ + maxContextSize: 65536, + displayName: 'base-name', + defaultEffort: 'high', + }); + }); + + it('synthesizes nothing for a pointer-only recipe', () => { + const base = configWithSecondary(); + base.secondaryModel = { model: 'cheap' }; + const config = applySecondaryModelConfig(base, {}); + expect(config.models?.[SECONDARY_DERIVED_MODEL_ALIAS]).toBeUndefined(); + }); + + it('synthesizes nothing when the pointed entry does not exist', () => { + const base = configWithSecondary(); + base.secondaryModel = { model: 'missing', maxContextSize: 1024 }; + const config = applySecondaryModelConfig(base, {}); + expect(config.models?.[SECONDARY_DERIVED_MODEL_ALIAS]).toBeUndefined(); + }); + + it('applies the env overrides without touching the on-disk section shape', () => { + const config = applySecondaryModelConfig(getDefaultConfig(), { + KIMI_SECONDARY_MODEL: 'cheap', + KIMI_SECONDARY_EFFORT: 'low', + }); + expect(config.secondaryModel).toEqual({ model: 'cheap', defaultEffort: 'low' }); + }); + + it('lets the env override the configured recipe fields', () => { + const config = applySecondaryModelConfig(configWithSecondary(), { + KIMI_SECONDARY_MODEL: 'main', + }); + expect(config.secondaryModel?.model).toBe('main'); + // Untouched recipe fields survive the env overlay. + expect(config.secondaryModel?.defaultEffort).toBe('high'); + }); +}); + +describe('stripSecondaryModelConfig', () => { + it('removes the derived entry and rolls back a default_model pointer at it', () => { + const config = applySecondaryModelConfig(configWithSecondary(), {}); + config.defaultModel = SECONDARY_DERIVED_MODEL_ALIAS; + const stripped = stripSecondaryModelConfig(config, {}); + expect(stripped.models?.[SECONDARY_DERIVED_MODEL_ALIAS]).toBeUndefined(); + expect(stripped.defaultModel).toBeUndefined(); + }); + + it('restores env-injected recipe fields from raw on write', () => { + const onDisk = parseConfigString( + [ + '[secondary_model]', + 'model = "cheap"', + 'default_effort = "low"', + ].join('\n'), + ); + const runtime = applySecondaryModelConfig(onDisk, { + KIMI_SECONDARY_MODEL: 'main', + KIMI_SECONDARY_EFFORT: 'high', + }); + expect(runtime.secondaryModel).toEqual({ model: 'main', defaultEffort: 'high' }); + const stripped = stripSecondaryModelConfig(runtime, { + KIMI_SECONDARY_MODEL: 'main', + KIMI_SECONDARY_EFFORT: 'high', + }); + expect(stripped.secondaryModel).toEqual({ model: 'cheap', defaultEffort: 'low' }); + }); + + it('keeps a genuinely new selection that differs from the env values', () => { + // `/secondary_model` under KIMI_SECONDARY_MODEL: the picked recipe must + // reach the disk; only overlay round-trips are restored from raw. + const onDisk = parseConfigString( + ['[secondary_model]', 'model = "cheap"', 'default_effort = "low"'].join('\n'), + ); + const picked: KimiConfig = { + ...onDisk, + secondaryModel: { model: 'main', defaultEffort: 'high' }, + }; + const stripped = stripSecondaryModelConfig(picked, { + KIMI_SECONDARY_MODEL: 'cheap', + KIMI_SECONDARY_EFFORT: 'low', + }); + expect(stripped.secondaryModel).toEqual({ model: 'main', defaultEffort: 'high' }); + }); + + it('restores only the fields still carrying the env values', () => { + const onDisk = parseConfigString( + ['[secondary_model]', 'model = "cheap"', 'default_effort = "low"'].join('\n'), + ); + // The model still carries the env value (restored from raw); the effort + // is a new pick (persists). + const mixed: KimiConfig = { + ...onDisk, + secondaryModel: { model: 'main', defaultEffort: 'high' }, + }; + const stripped = stripSecondaryModelConfig(mixed, { + KIMI_SECONDARY_MODEL: 'main', + KIMI_SECONDARY_EFFORT: 'low', + }); + expect(stripped.secondaryModel).toEqual({ model: 'cheap', defaultEffort: 'high' }); + }); + + it('keeps file-sourced recipe fields when no env override is active', () => { + const runtime = configWithSecondary(); + const stripped = stripSecondaryModelConfig(runtime, {}); + expect(stripped.secondaryModel).toEqual(runtime.secondaryModel); + }); +}); + +describe('[secondary_model] TOML wiring', () => { + it('parses the snake_case section into camelCase config', () => { + const config = parseConfigString( + [ + '[secondary_model]', + 'model = "cheap"', + 'max_context_size = 65536', + 'default_effort = "high"', + ].join('\n'), + ); + expect(config.secondaryModel).toEqual({ + model: 'cheap', + maxContextSize: 65536, + defaultEffort: 'high', + }); + }); + + it('round-trips the section through writeConfigFile without the derived entry', async () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-secondary-model-')); + try { + const filePath = join(dir, 'config.toml'); + writeFileSync( + filePath, + [ + '[providers.p1]', + 'type = "kimi"', + 'api_key = "sk-test"', + '', + '[models.cheap]', + 'provider = "p1"', + 'model = "cheap-chat"', + 'max_context_size = 131072', + '', + '[secondary_model]', + 'model = "cheap"', + 'max_context_size = 65536', + ].join('\n'), + ); + const runtime = loadRuntimeConfig(filePath, {}); + expect(runtime.models?.[SECONDARY_DERIVED_MODEL_ALIAS]).toBeDefined(); + await writeConfigFile(filePath, runtime); + const persisted = readFileSync(filePath, 'utf-8'); + expect(persisted).toContain('[secondary_model]'); + expect(persisted).toContain('max_context_size = 65536'); + expect(persisted).not.toContain(SECONDARY_DERIVED_MODEL_ALIAS); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts index 0d7dc1f85..a2847a12b 100644 --- a/packages/agent-core/test/harness/runtime.test.ts +++ b/packages/agent-core/test/harness/runtime.test.ts @@ -228,6 +228,45 @@ micro_compaction = false expect(reloadedMainAgent?.tools.data().some((tool) => tool.name === 'CreateGoal')).toBe(true); }); + it('live-applies the complete persisted secondary recipe', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', '1'); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + const created = await rpc.createSession({ + id: 'ses_runtime_secondary_refresh', + workDir, + model: 'default-mock', + }); + + await rpc.setKimiConfig({ + secondaryModel: { + model: 'default-mock', + maxContextSize: 65_536, + }, + }); + await rpc.applyPersistedSecondaryModel({ sessionId: created.id }); + + const config = core.sessions.get(created.id)?.getReadyAgent('main')?.kimiConfig; + expect(config?.secondaryModel).toEqual({ + model: 'default-mock', + maxContextSize: 65_536, + }); + expect(config?.models?.['__secondary__']?.overrides?.maxContextSize).toBe(65_536); + }); + // Regression for https://github.com/MoonshotAI/kimi-code/issues/988: during // ACP `session/new` the tool kaos is the reverse-RPC bridge and the client // does not know the session yet, so reading `.kimi-code/local.toml` through diff --git a/packages/agent-core/test/mcp/tool-manager-mcp.test.ts b/packages/agent-core/test/mcp/tool-manager-mcp.test.ts index cdd3bde50..f7f14bbe3 100644 --- a/packages/agent-core/test/mcp/tool-manager-mcp.test.ts +++ b/packages/agent-core/test/mcp/tool-manager-mcp.test.ts @@ -610,4 +610,54 @@ describe('ToolManager MCP integration', () => { expect(tm.loopTools.map((t) => t.name)).toEqual(['mcp__s__echo']); }); + + it('a server-scoped MCP deny glob hides that server under a broad allow', async () => { + const tm = new ToolManager(fakeAgent()); + const githubClient = fakeClient(); + const slackClient = fakeClient(); + tm.registerMcpServer('github', githubClient, await discoverTools(githubClient)); + tm.registerMcpServer('slack', slackClient, await discoverTools(slackClient)); + tm.setActiveTools(['mcp__*'], ['mcp__github__*']); + + expect(tm.loopTools.map((t) => t.name).toSorted()).toEqual([ + 'mcp__slack__echo', + 'mcp__slack__noop', + ]); + expect([...tm.toolInfos()].filter((i) => i.source === 'mcp')).toEqual([ + { name: 'mcp__github__echo', description: 'Echoes back', active: false, source: 'mcp' }, + { name: 'mcp__github__noop', description: 'Does nothing', active: false, source: 'mcp' }, + { name: 'mcp__slack__echo', description: 'Echoes back', active: true, source: 'mcp' }, + { name: 'mcp__slack__noop', description: 'Does nothing', active: true, source: 'mcp' }, + ]); + }); + + it('an exact MCP deny hides one tool while the server glob allows the rest', async () => { + const tm = new ToolManager(fakeAgent()); + const client = fakeClient(); + tm.registerMcpServer('s', client, await discoverTools(client)); + tm.setActiveTools(['mcp__s__*'], ['mcp__s__echo']); + + expect(tm.loopTools.map((t) => t.name)).toEqual(['mcp__s__noop']); + }); + + it('a full mcp__* deny hides every MCP tool', async () => { + const tm = new ToolManager(fakeAgent()); + const client = fakeClient(); + tm.registerMcpServer('s', client, await discoverTools(client)); + tm.setActiveTools(['mcp__*'], ['mcp__*']); + + expect(tm.loopTools.some((t) => t.name.startsWith('mcp__'))).toBe(false); + }); + + it('records the deny list alongside the active tools', async () => { + const calls: unknown[] = []; + const tm = new ToolManager(fakeAgent(calls)); + tm.setActiveTools(['Read', 'Bash', 'Grep'], ['Bash']); + + expect(calls[0]).toMatchObject({ + type: 'tools.set_active_tools', + names: ['Read', 'Bash', 'Grep'], + disallowedNames: ['Bash'], + }); + }); }); diff --git a/packages/agent-core/test/profile/agentfile.test.ts b/packages/agent-core/test/profile/agentfile.test.ts new file mode 100644 index 000000000..ff1440f33 --- /dev/null +++ b/packages/agent-core/test/profile/agentfile.test.ts @@ -0,0 +1,755 @@ +/** + * Scenario: Markdown agent parsing, discovery, binding, and session resume. + * Responsibilities: file-defined profile identity and policies remain observable across lifecycle changes. + * Wiring: the real catalog/session/record store are used with isolated local filesystem fixtures. + * Run: pnpm -C packages/agent-core exec vitest run test/profile/agentfile.test.ts + */ + +import { mkdir, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { testKaos } from '../fixtures/test-kaos'; +import type { ProviderConfig } from '@moonshot-ai/kosong'; + +import type { SDKSessionRPC } from '../../src/rpc'; +import { Session } from '../../src/session'; +import { ProviderManager } from '../../src/session/provider-manager'; +import { + DEFAULT_AGENT_PROFILES, + SessionAgentProfileCatalog, + agentProfileFromFile, + parseAgentFileText, + type SystemPromptContext, +} from '../../src/profile'; +import { AgentFileParseError } from '../../src/profile/agentfile/parser'; + +const MOCK_PROVIDER = { + type: 'kimi', + apiKey: 'test-key', + model: 'mock-model', +} as const satisfies ProviderConfig; + +const tempDirs: string[] = []; + +afterEach(async () => { + for (const dir of tempDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } +}); + +async function makeTempDir(prefix = 'kimi-agentfile-'): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +const promptContext: SystemPromptContext = { + osEnv: { + osKind: 'Linux', + osArch: 'x86_64', + osVersion: 'test', + shellName: 'bash', + shellPath: '/bin/bash', + }, + cwd: '/workspace', + now: '2026-05-09T00:00:00.000Z', + cwdListing: 'README.md', + agentsMd: 'Project instructions.', + skills: 'Available test skills.', +}; + +function agentFileText(frontmatter: Record, body = 'You are a reviewer.'): string { + const lines = Object.entries(frontmatter).map(([key, value]) => { + if (Array.isArray(value)) { + // Quote every item: a bare `*` (or `*`-leading) entry would otherwise + // parse as a YAML alias reference. + return `${key}:\n${value.map((item) => ` - ${JSON.stringify(item)}`).join('\n')}`; + } + return `${key}: ${String(value)}`; + }); + return `---\n${lines.join('\n')}\n---\n\n${body}\n`; +} + +describe('parseAgentFileText', () => { + const parse = (text: string, path = '/agents/reviewer.md') => + parseAgentFileText({ path, source: 'project', text }); + + it('parses a minimal agent file', () => { + const definition = parse(agentFileText({ name: 'reviewer', description: 'Reviews code.' })); + expect(definition).toMatchObject({ + name: 'reviewer', + description: 'Reviews code.', + override: false, + prompt: 'You are a reviewer.', + source: 'project', + }); + }); + + it('derives the name from the file name when omitted', () => { + const definition = parse(agentFileText({ description: 'Reviews code.' }), '/agents/code-reviewer.md'); + expect(definition.name).toBe('code-reviewer'); + }); + + it('rejects non-kebab-case names', () => { + expect(() => parse(agentFileText({ name: 'Code Reviewer', description: 'd' }))).toThrow( + AgentFileParseError, + ); + }); + + it('rejects a missing description', () => { + expect(() => parse(agentFileText({ name: 'reviewer' }))).toThrow(/description/); + }); + + it('rejects missing frontmatter and an empty prompt body', () => { + expect(() => parse('You are a reviewer.')).toThrow(/Missing frontmatter/); + expect(() => parse(agentFileText({ description: 'd' }, ' '))).toThrow(/prompt body/); + }); + + it('accepts tools as a YAML list and as a comma-separated string', () => { + const asList = parse( + agentFileText({ description: 'd', tools: ['Read', 'Grep'] }), + ); + expect(asList.tools).toEqual(['Read', 'Grep']); + const asString = parse( + `---\ndescription: d\ntools: Read, Grep\n---\n\nbody\n`, + ); + expect(asString.tools).toEqual(['Read', 'Grep']); + }); + + it('normalizes a lone * in tools and subagents to unrestricted', () => { + const definition = parse(`---\ndescription: d\ntools: '*'\nsubagents: '*'\n---\n\nbody\n`); + expect(definition.tools).toBeUndefined(); + expect(definition.subagents).toBeUndefined(); + }); + + it('parses disallowedTools, subagents, override and model_preference', () => { + const definition = parse( + agentFileText({ + description: 'd', + override: true, + disallowedTools: ['Bash'], + subagents: ['explore'], + model_preference: 'secondary', + }), + ); + expect(definition).toMatchObject({ + override: true, + disallowedTools: ['Bash'], + subagents: ['explore'], + modelPreference: 'secondary', + }); + }); + + it('rejects an invalid model_preference', () => { + expect(() => parse(agentFileText({ description: 'd', model_preference: 'cheapest' }))).toThrow( + /model_preference/, + ); + }); + + it('ignores unknown frontmatter fields', () => { + const definition = parse(agentFileText({ description: 'd', future_field: 'x' })); + expect(definition.name).toBe('reviewer'); + }); +}); + +describe('agentProfileFromFile', () => { + const defaultTools = DEFAULT_AGENT_PROFILES['agent']!.tools; + const basePrompt = () => 'BASE PROMPT'; + + it('renders ${var} placeholders from the prompt context', () => { + const definition = parseAgentFileText({ + path: '/agents/reviewer.md', + source: 'project', + text: agentFileText( + { description: 'd' }, + 'Work in ${cwd}. OS: ${os}. Instructions: ${agents_md}', + ), + }); + const profile = agentProfileFromFile(definition, defaultTools, basePrompt); + expect(profile.systemPrompt(promptContext)).toBe( + 'Work in /workspace. OS: Linux. Instructions: Project instructions.', + ); + }); + + it('leaves unknown placeholders and nunjucks syntax verbatim', () => { + const definition = parseAgentFileText({ + path: '/agents/reviewer.md', + source: 'project', + text: agentFileText({ description: 'd' }, 'Keep ${unknown_var} and {{ not_a_var }} literal.'), + }); + const profile = agentProfileFromFile(definition, defaultTools, basePrompt); + expect(profile.systemPrompt(promptContext)).toBe('Keep ${unknown_var} and {{ not_a_var }} literal.'); + }); + + it('expands ${base_prompt} with the builtin default prompt', () => { + const definition = parseAgentFileText({ + path: '/agents/reviewer.md', + source: 'project', + text: agentFileText({ description: 'd' }, 'Wrapper.\n${base_prompt}\nEnd.'), + }); + const profile = agentProfileFromFile(definition, defaultTools, basePrompt); + expect(profile.systemPrompt(promptContext)).toBe('Wrapper.\nBASE PROMPT\nEnd.'); + }); + + it('injects skills only when the Skill tool survives the tool list', () => { + const withSkill = agentProfileFromFile( + parseAgentFileText({ + path: '/agents/a.md', + source: 'project', + text: agentFileText({ description: 'd' }, '${skills_section}done'), + }), + defaultTools, + basePrompt, + ); + expect(withSkill.systemPrompt(promptContext)).toContain('Available test skills.'); + + const withoutSkill = agentProfileFromFile( + parseAgentFileText({ + path: '/agents/b.md', + source: 'project', + text: agentFileText({ description: 'd', tools: ['Read'] }, '${skills_section}done'), + }), + defaultTools, + basePrompt, + ); + expect(withoutSkill.systemPrompt(promptContext)).toBe('done'); + }); + + it('defaults tools to the default set and passes disallowedTools through', () => { + const unrestricted = agentProfileFromFile( + parseAgentFileText({ + path: '/agents/a.md', + source: 'project', + text: agentFileText({ description: 'd' }), + }), + defaultTools, + basePrompt, + ); + expect(unrestricted.tools).toEqual(defaultTools); + expect(unrestricted.disallowedTools).toBeUndefined(); + + // The denylist rides the profile (evaluated by the tool manager against + // resolved tool names) instead of being subtracted here. + const restricted = agentProfileFromFile( + parseAgentFileText({ + path: '/agents/b.md', + source: 'project', + text: agentFileText({ description: 'd', disallowedTools: ['Bash', 'mcp__github__*'] }), + }), + defaultTools, + basePrompt, + ); + expect(restricted.tools).toEqual(defaultTools); + expect(restricted.disallowedTools).toEqual(['Bash', 'mcp__github__*']); + }); +}); + +describe('SessionAgentProfileCatalog', () => { + async function makeLayout() { + const workDir = await makeTempDir(); + await mkdir(join(workDir, '.git')); + const brandHome = await makeTempDir(); + const osHome = await makeTempDir(); + return { workDir, brandHome, osHome }; + } + + function catalog(options: { + workDir: string; + brandHomeDir: string; + osHomeDir: string; + extraDirs?: readonly string[]; + explicitFiles?: readonly string[]; + warnings?: string[]; + }): SessionAgentProfileCatalog { + return new SessionAgentProfileCatalog({ + workDir: options.workDir, + brandHomeDir: options.brandHomeDir, + osHomeDir: options.osHomeDir, + extraDirs: options.extraDirs, + explicitFiles: options.explicitFiles, + warn: (message) => options.warnings?.push(message), + }); + } + + async function writeAgent(dir: string, fileName: string, text: string): Promise { + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, fileName), text, 'utf-8'); + } + + it('discovers agents from the user brand dir and the project dir', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeAgent(join(brandHome, 'agents'), 'user-agent.md', agentFileText({ description: 'User agent.' })); + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'project-agent.md', + agentFileText({ description: 'Project agent.' }), + ); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await c.ready; + expect(c.get('user-agent')?.description).toBe('User agent.'); + expect(c.get('project-agent')?.description).toBe('Project agent.'); + // The builtins are always present. + expect(c.get('coder')).toBeDefined(); + expect(c.getDefault().name).toBe('agent'); + }); + + it('lets the project source shadow the user source on the same name', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeAgent(join(brandHome, 'agents'), 'shared.md', agentFileText({ description: 'From user.' })); + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'shared.md', + agentFileText({ description: 'From project.' }), + ); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await c.ready; + expect(c.get('shared')?.description).toBe('From project.'); + }); + + it('requires override: true before a file replaces a same-name builtin', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + const warnings: string[] = []; + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'coder.md', + agentFileText({ description: 'Rogue coder.' }), + ); + + const rejected = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome, warnings }); + await rejected.ready; + expect(rejected.get('coder')?.description).toBe(DEFAULT_AGENT_PROFILES['coder']!.description); + expect(warnings.some((w) => w.includes('override: true'))).toBe(true); + + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'coder.md', + agentFileText({ description: 'Rogue coder.', override: true }, 'Rogue prompt.'), + ); + const accepted = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await accepted.ready; + expect(accepted.get('coder')?.description).toBe('Rogue coder.'); + expect(accepted.get('coder')?.systemPrompt(promptContext)).toBe('Rogue prompt.'); + }); + + it('replaces the default profile prompt with SYSTEM.md', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeFile(join(brandHome, 'SYSTEM.md'), 'Custom system for ${os}.', 'utf-8'); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await c.ready; + expect(c.getDefault().systemPrompt(promptContext)).toBe('Custom system for Linux.'); + // Only the prompt changed; capabilities and description stay builtin. + expect(c.getDefault().tools).toEqual(DEFAULT_AGENT_PROFILES['agent']!.tools); + expect(Object.keys(c.delegatableSubagents('agent'))).toEqual( + Object.keys(DEFAULT_AGENT_PROFILES['agent']!.subagents ?? {}), + ); + expect(c.delegatableSubagents('agent')).not.toHaveProperty('agent'); + }); + + it('extends SYSTEM.md delegation with custom agents without allowing self-delegation', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeFile(join(brandHome, 'SYSTEM.md'), 'Custom system.', 'utf-8'); + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'reviewer.md', + agentFileText({ description: 'Reviews code.' }), + ); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await c.ready; + const expectedNames = [ + ...Object.keys(DEFAULT_AGENT_PROFILES['agent']!.subagents ?? {}), + 'reviewer', + ]; + expect(Object.keys(c.delegatableSubagents('agent'))).toEqual(expectedNames); + expect(c.delegatableSubagents('agent')).not.toHaveProperty('agent'); + + const snapshot = c.snapshot(); + expect(snapshot).toBeDefined(); + const restoredLayout = await makeLayout(); + const restored = catalog({ + workDir: restoredLayout.workDir, + brandHomeDir: restoredLayout.brandHome, + osHomeDir: restoredLayout.osHome, + }); + await restored.ready; + restored.restoreSnapshot(snapshot!); + expect(Object.keys(restored.delegatableSubagents('agent'))).toEqual(expectedNames); + expect(restored.delegatableSubagents('agent')).not.toHaveProperty('agent'); + }); + + it('lets a project agent.md win over SYSTEM.md', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeFile(join(brandHome, 'SYSTEM.md'), 'From SYSTEM.md', 'utf-8'); + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'agent.md', + agentFileText({ description: 'Project default.', override: true }, 'From project agent.md'), + ); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await c.ready; + expect(c.getDefault().systemPrompt(promptContext)).toBe('From project agent.md'); + }); + + it('fails ready for an invalid explicit agent file', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + const c = catalog({ + workDir, + brandHomeDir: brandHome, + osHomeDir: osHome, + explicitFiles: ['missing.md'], + }); + await expect(c.ready).rejects.toThrow(); + }); + + it('treats an explicit file as an implicit builtin override', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + const explicitPath = join(workDir, 'explicit-coder.md'); + await writeFile( + explicitPath, + agentFileText({ name: 'coder', description: 'Explicit coder.' }, 'Explicit prompt.'), + 'utf-8', + ); + + const c = catalog({ + workDir, + brandHomeDir: brandHome, + osHomeDir: osHome, + explicitFiles: ['explicit-coder.md'], + }); + await c.ready; + expect(c.get('coder')?.description).toBe('Explicit coder.'); + }); + + it('uses the last explicit file when files declare the same profile name', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeFile( + join(workDir, 'first-reviewer.md'), + agentFileText({ name: 'reviewer', description: 'First reviewer.' }), + 'utf-8', + ); + await writeFile( + join(workDir, 'last-reviewer.md'), + agentFileText({ name: 'reviewer', description: 'Last reviewer.' }), + 'utf-8', + ); + + const c = catalog({ + workDir, + brandHomeDir: brandHome, + osHomeDir: osHome, + explicitFiles: ['first-reviewer.md', 'last-reviewer.md'], + }); + await c.ready; + + expect(c.get('reviewer')?.description).toBe('Last reviewer.'); + }); + + it('extends the default delegation set with file-defined agents', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'reviewer.md', + agentFileText({ description: 'Reviews code.' }), + ); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await c.ready; + const delegatable = c.delegatableSubagents('agent'); + expect(Object.keys(delegatable)).toContain('reviewer'); + expect(Object.keys(delegatable)).toContain('coder'); + // The builtin profile objects are shared constants; the extension must + // not leak into them. + expect(Object.keys(DEFAULT_AGENT_PROFILES['agent']!.subagents ?? {})).not.toContain('reviewer'); + }); + + it('links a file profile subagents allowlist, unrestricted when omitted', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'leader.md', + agentFileText({ description: 'Leads.', subagents: ['explore'] }), + ); + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'worker.md', + agentFileText({ description: 'Works.' }), + ); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await c.ready; + expect(Object.keys(c.delegatableSubagents('leader'))).toEqual(['explore']); + const workerDelegatable = c.delegatableSubagents('worker'); + expect(Object.keys(workerDelegatable)).toContain('leader'); + expect(Object.keys(workerDelegatable)).toContain('coder'); + }); + + it('renders a file profile prompt through the builtin base prompt', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'reviewer.md', + agentFileText({ description: 'd' }, 'Custom.\n${base_prompt}'), + ); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await c.ready; + const rendered = c.get('reviewer')!.systemPrompt(promptContext); + expect(rendered).toContain('Custom.'); + expect(rendered).toContain('Project instructions.'); + expect(rendered.length).toBeGreaterThan('Custom.\n'.length); + }); + + it('backs ${base_prompt} with the SYSTEM.md override when present', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeFile(join(brandHome, 'SYSTEM.md'), 'Org default on ${os}.', 'utf-8'); + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'reviewer.md', + agentFileText({ description: 'd' }, '[${base_prompt}]'), + ); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await c.ready; + // The agent file's ${base_prompt} expands to the effective default — + // the SYSTEM.md override — not the builtin default. + expect(c.get('reviewer')!.systemPrompt(promptContext)).toBe('[Org default on Linux.]'); + }); + + it('chains ${base_prompt} through SYSTEM.md down to the builtin default', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeFile(join(brandHome, 'SYSTEM.md'), 'Org layer.\n${base_prompt}', 'utf-8'); + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'reviewer.md', + agentFileText({ description: 'd' }, 'File layer.\n${base_prompt}'), + ); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await c.ready; + const rendered = c.get('reviewer')!.systemPrompt(promptContext); + // Two hops: file → SYSTEM.md → builtin default (which renders agents_md). + expect(rendered).toContain('File layer.'); + expect(rendered).toContain('Org layer.'); + expect(rendered).toContain('Project instructions.'); + }); + + it('never recurses when a file overrides the default and references ${base_prompt}', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'agent.md', + agentFileText({ description: 'Override default.', override: true }, 'Override.\n${base_prompt}'), + ); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await c.ready; + const rendered = c.getDefault().systemPrompt(promptContext); + // The overriding file's base is the effective default slot (builtin + // here), structurally disjoint from the merge — no self-reference. + expect(rendered).toContain('Override.'); + expect(rendered).toContain('Project instructions.'); + }); + + it('warns about dead tool patterns in agent files', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + const warnings: string[] = []; + await writeAgent( + join(workDir, '.kimi-code', 'agents'), + 'reviewer.md', + agentFileText({ + description: 'd', + tools: ['Read', 'read', '*', 'mcp__github', 'mcp__github__*'], + disallowedTools: ['Bash'], + }), + ); + + const c = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome, warnings }); + await c.ready; + // Typo (unknown tool), bare wildcard, incomplete mcp literal. + expect(warnings.some((w) => w.includes('"read"') && w.includes('no registered or built-in tool'))).toBe(true); + expect(warnings.some((w) => w.includes('"*"') && w.includes('wildcards only work inside mcp__'))).toBe(true); + expect(warnings.some((w) => w.includes('"mcp__github"') && w.includes('mcp____'))).toBe(true); + // Valid entries produce no warnings. + expect(warnings.some((w) => w.includes('"Read"'))).toBe(false); + expect(warnings.some((w) => w.includes('"mcp__github__*"'))).toBe(false); + expect(warnings.some((w) => w.includes('"Bash"'))).toBe(false); + }); +}); + +describe('Session agentfile wiring', () => { + function createSessionRpc(): SDKSessionRPC { + return { + emitEvent: vi.fn(async () => {}), + requestApproval: vi.fn(async () => ({ decision: 'cancelled' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ content: [] })), + } as unknown as SDKSessionRPC; + } + + function testProviderManager(): ProviderManager { + return new ProviderManager({ + config: { + providers: { test: { type: MOCK_PROVIDER.type, apiKey: MOCK_PROVIDER.apiKey } }, + defaultProvider: 'test', + defaultModel: MOCK_PROVIDER.model, + models: { + [MOCK_PROVIDER.model]: { + provider: 'test', + model: MOCK_PROVIDER.model, + maxContextSize: 1_000_000, + }, + }, + }, + }); + } + + it('binds a file-defined main profile selected by profileName', async () => { + const workDir = await makeTempDir(); + const sessionDir = await makeTempDir(); + const brandHome = await makeTempDir(); + const osHome = await makeTempDir(); + await mkdir(join(workDir, '.kimi-code', 'agents'), { recursive: true }); + await writeFile( + join(workDir, '.kimi-code', 'agents', 'reviewer.md'), + agentFileText({ description: 'Reviews code.' }, 'You review code in ${cwd}.'), + 'utf-8', + ); + + const session = new Session({ + id: 'test-agentfile-main', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + kimiHomeDir: brandHome, + rpc: createSessionRpc(), + providerManager: testProviderManager(), + agents: { userHomeDir: osHome, profileName: 'reviewer' }, + }); + try { + const main = await session.createMain(); + expect(main.config.profileName).toBe('reviewer'); + expect(main.config.systemPrompt).toContain(`You review code in ${workDir}`); + // The default delegation set includes the file-defined agent. + expect(Object.keys(session.agentCatalog.delegatableSubagents('agent'))).toContain('reviewer'); + } finally { + await session.close(); + } + }); + + it('fails createMain for an unknown profileName', async () => { + const workDir = await makeTempDir(); + const sessionDir = await makeTempDir(); + const brandHome = await makeTempDir(); + const session = new Session({ + id: 'test-agentfile-unknown', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + kimiHomeDir: brandHome, + rpc: createSessionRpc(), + providerManager: testProviderManager(), + agents: { userHomeDir: brandHome, profileName: 'nope' }, + }); + try { + await expect(session.createMain()).rejects.toThrow(/Agent profile "nope" was not found/); + } finally { + await session.close(); + } + }); + + it('fails createMain for an invalid explicit agent file', async () => { + const workDir = await makeTempDir(); + const sessionDir = await makeTempDir(); + const brandHome = await makeTempDir(); + const session = new Session({ + id: 'test-agentfile-fatal', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + kimiHomeDir: brandHome, + rpc: createSessionRpc(), + providerManager: testProviderManager(), + agents: { userHomeDir: brandHome, explicitFiles: [join(workDir, 'missing.md')] }, + }); + try { + await expect(session.createMain()).rejects.toThrow(); + } finally { + // close() -> flushMetadata() awaits the same readiness gate, which + // carries the fatal agentfile error too; production swallows it via + // core-impl's `session.close().catch(() => {})` cleanup path. + await session.close().catch(() => {}); + } + }); + + it('restores a bound custom subagent when its agent files are removed before resume', async () => { + const workDir = await makeTempDir(); + const sessionDir = await makeTempDir(); + const brandHome = await makeTempDir(); + const osHome = await makeTempDir(); + const agentDir = await makeTempDir(); + const leaderPath = join(agentDir, 'leader.md'); + const workerPath = join(agentDir, 'worker.md'); + await mkdir(agentDir, { recursive: true }); + await writeFile( + leaderPath, + agentFileText( + { name: 'leader', description: 'Delegates focused work.', subagents: ['worker'] }, + 'Delegate repository work only.', + ), + 'utf-8', + ); + await writeFile( + workerPath, + agentFileText( + { name: 'worker', description: 'Performs focused work.', tools: ['Read'] }, + 'Perform the delegated work.', + ), + 'utf-8', + ); + + const options = { + id: 'test-agentfile-delegation-resume', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + kimiHomeDir: brandHome, + rpc: createSessionRpc(), + providerManager: testProviderManager(), + agents: { + userHomeDir: osHome, + profileName: 'leader', + explicitFiles: [leaderPath, workerPath], + }, + }; + const created = new Session(options); + try { + await created.createMain(); + } finally { + await created.closeForReload(); + } + + await Promise.all([unlink(leaderPath), unlink(workerPath)]); + + const resumed = new Session({ + ...options, + agents: { userHomeDir: osHome }, + initializeMainAgent: false, + }); + try { + await resumed.resume(); + const main = await resumed.ensureAgentResumed('main'); + const worker = main.subagentHost!.delegatableSubagents(main.config.profileName)['worker']; + + expect(worker).toMatchObject({ + name: 'worker', + description: 'Performs focused work.', + tools: ['Read'], + }); + } finally { + await resumed.close(); + } + }); +}); diff --git a/packages/agent-core/test/profile/default-agent-profiles.test.ts b/packages/agent-core/test/profile/default-agent-profiles.test.ts index 09b4d1cea..2e798228b 100644 --- a/packages/agent-core/test/profile/default-agent-profiles.test.ts +++ b/packages/agent-core/test/profile/default-agent-profiles.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; import { DEFAULT_AGENT_PROFILES, loadAgentProfilesFromSources } from '../../src/profile'; +import { + ADDITIONAL_DIRS_SECTION_PROSE, + SKILLS_SECTION_PROSE, + WINDOWS_NOTES, +} from '../../src/profile/prompt-sections'; const promptContext = { osEnv: { @@ -40,6 +45,20 @@ describe('default agent profiles', () => { ); }); + it('renders the environment prose sections from the shared prompt-sections source', () => { + // system.md must render the shared constants (never re-inlined copies), so + // the builtin default prompt and the agent-file renderer cannot drift. + const prompt = + DEFAULT_AGENT_PROFILES['agent']?.systemPrompt({ + ...promptContext, + osEnv: { ...promptContext.osEnv, osKind: 'Windows' }, + additionalDirsInfo: 'EXTRA_DIR_1', + }) ?? ''; + expect(prompt).toContain(WINDOWS_NOTES); + expect(prompt).toContain(ADDITIONAL_DIRS_SECTION_PROSE); + expect(prompt).toContain(SKILLS_SECTION_PROSE); + }); + it('lists the goal tools on the agent profile but not on subagent profiles', () => { const agentTools = DEFAULT_AGENT_PROFILES['agent']?.tools ?? []; expect(agentTools).toEqual( diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index 1a5ab4b4d..b0a66e6e1 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -9,6 +9,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Agent, AgentOptions } from '../../src/agent'; import { trimTrailingOpenToolExchange } from '../../src/agent/context/projector'; +import type { KimiConfig } from '../../src/config'; +import { FlagResolver } from '../../src/flags'; import { ProviderManager } from '../../src/session/provider-manager'; import type { ResolvedAgentProfile } from '../../src/profile'; import type { SDKSessionRPC } from '../../src/rpc'; @@ -697,6 +699,171 @@ describe('AgentAPI.startBtw', () => { }); }); +describe('Session secondary-model live config', () => { + const SECONDARY_BASE_CONFIG: KimiConfig = { + providers: { + test: { type: MOCK_PROVIDER.type, apiKey: MOCK_PROVIDER.apiKey }, + }, + models: { + [MOCK_PROVIDER.model]: { + provider: 'test', + model: MOCK_PROVIDER.model, + maxContextSize: 1_000_000, + }, + }, + }; + const SECONDARY_POINTER_CONFIG: KimiConfig = { + ...SECONDARY_BASE_CONFIG, + secondaryModel: { model: MOCK_PROVIDER.model }, + }; + const SECONDARY_PATCHED_CONFIG: KimiConfig = { + ...SECONDARY_BASE_CONFIG, + models: { + ...SECONDARY_BASE_CONFIG.models, + __secondary__: { + ...SECONDARY_BASE_CONFIG.models![MOCK_PROVIDER.model]!, + overrides: { defaultEffort: 'low' }, + }, + }, + secondaryModel: { model: MOCK_PROVIDER.model, defaultEffort: 'low' }, + }; + + async function makeSession(config?: KimiConfig): Promise { + const workDir = await makeTempDir(); + const sessionDir = await makeTempDir(); + return new Session({ + id: 'test-secondary-model', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + rpc: createSessionRpc([]), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + providerManager: testProviderManager(), + experimentalFlags: new FlagResolver({ + KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL: '1', + }), + config, + }); + } + + it('live-applies the recipe to the session snapshot and live agents', async () => { + const session = await makeSession(SECONDARY_BASE_CONFIG); + try { + const { agent } = await session.createAgent( + { type: 'main', generate: createScriptedGenerate().generate }, + { profile: testProfile() }, + ); + + session.setSecondaryModelConfig(SECONDARY_POINTER_CONFIG); + + expect(session.kimiConfig?.secondaryModel).toEqual({ model: MOCK_PROVIDER.model }); + expect(agent.kimiConfig?.secondaryModel).toEqual({ model: MOCK_PROVIDER.model }); + // A pointer-only recipe synthesizes no derived entry. + expect(session.kimiConfig?.models?.['__secondary__']).toBeUndefined(); + + // Agents created after the switch read the updated snapshot too. + const { agent: second } = await session.createAgent( + { type: 'sub', generate: createScriptedGenerate().generate }, + { profile: testProfile(), parentAgentId: 'main' }, + ); + expect(second.kimiConfig?.secondaryModel).toEqual({ model: MOCK_PROVIDER.model }); + } finally { + await session.close(); + } + }); + + it('refreshes collaboration tool descriptions when live-applying a recipe', async () => { + const session = await makeSession(SECONDARY_BASE_CONFIG); + try { + const { agent } = await session.createAgent( + { type: 'main', generate: createScriptedGenerate().generate }, + { profile: { ...testProfile(), tools: ['Agent', 'AgentSwarm'] } }, + ); + agent.config.update({ modelAlias: MOCK_PROVIDER.model, thinkingEffort: 'off' }); + + const descriptionsBefore = Object.fromEntries( + agent.tools.loopTools.map((tool) => [tool.name, tool.description]), + ); + expect(descriptionsBefore['Agent']).not.toContain('Available models'); + expect(descriptionsBefore['AgentSwarm']).not.toContain('Available models'); + + session.setSecondaryModelConfig(SECONDARY_POINTER_CONFIG); + + const descriptionsAfter = Object.fromEntries( + agent.tools.loopTools.map((tool) => [tool.name, tool.description]), + ); + expect(descriptionsAfter['Agent']).toContain('- secondary: mock-model'); + expect(descriptionsAfter['AgentSwarm']).toContain('- secondary: mock-model'); + } finally { + await session.close(); + } + }); + + it('replaces a patched runtime snapshot with a pointer-only runtime snapshot', async () => { + const session = await makeSession(SECONDARY_BASE_CONFIG); + try { + session.setSecondaryModelConfig(SECONDARY_PATCHED_CONFIG); + const derived = session.kimiConfig?.models?.['__secondary__']; + expect(derived).toBeDefined(); + expect(derived?.overrides?.defaultEffort).toBe('low'); + + session.setSecondaryModelConfig(SECONDARY_POINTER_CONFIG); + expect(session.kimiConfig?.models?.['__secondary__']).toBeUndefined(); + } finally { + await session.close(); + } + }); + + it('keeps unrelated session settings when applying a secondary-model config', async () => { + const session = await makeSession({ + ...SECONDARY_BASE_CONFIG, + loopControl: { maxStepsPerTurn: 7 }, + }); + try { + session.setSecondaryModelConfig({ + ...SECONDARY_POINTER_CONFIG, + loopControl: { maxStepsPerTurn: 99 }, + }); + + expect(session.kimiConfig?.loopControl?.maxStepsPerTurn).toBe(7); + } finally { + await session.close(); + } + }); + + it('rejects a dangling pointer with the wrapped secondary-model error', async () => { + const session = await makeSession(SECONDARY_BASE_CONFIG); + try { + expect(() => + session.setSecondaryModelConfig({ + ...SECONDARY_BASE_CONFIG, + secondaryModel: { model: 'missing-model' }, + }), + ).toThrow(/\[secondary_model\]\.model/); + expect(session.kimiConfig?.secondaryModel).toBeUndefined(); + } finally { + await session.close(); + } + }); + + it('rejects when the complete config has no persisted secondary recipe', async () => { + const session = await makeSession(SECONDARY_BASE_CONFIG); + try { + expect(() => session.setSecondaryModelConfig(SECONDARY_BASE_CONFIG)).toThrow(/persist/); + } finally { + await session.close(); + } + }); + + it('rejects when the session has no config', async () => { + const session = await makeSession(); + try { + expect(() => session.setSecondaryModelConfig(SECONDARY_POINTER_CONFIG)).toThrow(/no config/); + } finally { + await session.close(); + } + }); +}); + async function makeTempDir(): Promise { const dir = await mkdtemp(join(tmpdir(), 'kimi-core-init-')); tempDirs.push(dir); diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index bcaace75d..52edef997 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -8,10 +8,14 @@ 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 type { ResolvedAgentProfile } from '../../src/profile'; +import type { KimiConfig } from '../../src/config'; +import { ErrorCodes, KimiError } from '../../src/errors'; +import { FlagResolver } from '../../src/flags'; +import { SessionAgentProfileCatalog, type ResolvedAgentProfile } from '../../src/profile'; import type { SDKSessionRPC } from '../../src/rpc'; import { Session } from '../../src/session'; import { collectGitContext } from '../../src/session/git-context'; +import { ProviderManager } from '../../src/session/provider-manager'; import { DEFAULT_SUBAGENT_TIMEOUT_MS, SessionSubagentHost, @@ -491,6 +495,7 @@ describe('SessionSubagentHost', () => { agents: new Map([['main', parent.agent]]), ensureAgentResumed: vi.fn(async () => parent.agent), createAgent, + agentCatalog: testAgentCatalog(), } as never, 'main', ); @@ -517,6 +522,7 @@ describe('SessionSubagentHost', () => { agents: new Map([['main', parent.agent]]), ensureAgentResumed: vi.fn(async () => parent.agent), createAgent, + agentCatalog: testAgentCatalog(), } as never, 'main', ); @@ -1170,6 +1176,235 @@ describe('SessionSubagentHost', () => { expect(child.agent.config.modelAlias).toBe(parent.agent.config.modelAlias); expect(child.agent.config.modelAlias).not.toBe('stale-model-from-initial-spawn'); }); + + describe('secondary model binding', () => { + const secondaryFlags = () => + new FlagResolver({ KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL: '1' }); + const LONG_SUMMARY = + 'Completed the delegated task end to end and reported a technically complete summary so the parent agent can continue without repeating prior work. ' + + 'The report covers the investigation, the changes made, and the verification results in enough detail for the caller to act on directly.'; + // Harness model registry entries resolvable through the child's + // ProviderManager: the secondary alias and the synthesized derived entry + // (in production `applySecondaryModelConfig` injects the latter into the + // session runtime config). + const withSecondaryModels = (config?: KimiConfig): KimiConfig => ({ + providers: { 'test-provider': { type: 'kimi', apiKey: 'test-key' } }, + ...config, + models: { + 'cheap-model': { + provider: 'test-provider', + model: 'cheap-model', + maxContextSize: 1_000_000, + }, + '__secondary__': { + provider: 'test-provider', + model: 'cheap-model', + maxContextSize: 65536, + }, + }, + }); + + async function spawnChild(options: { + config?: KimiConfig; + experimentalFlags?: FlagResolver; + providerManager?: Session['options']['providerManager']; + modelChoice?: 'primary' | 'secondary'; + profilePreference?: 'primary' | 'secondary'; + }) { + const parent = testAgent(); + parent.configure(); + const child = testAgent({ initialConfig: withSecondaryModels() }); + child.configure({ tools: ['Read'] }); + child.mockNextResponse({ type: 'text', text: LONG_SUMMARY }); + + const session = fakeSession(parent.agent, child.agent, {}, { + config: options.config, + experimentalFlags: options.experimentalFlags, + providerManager: options.providerManager, + }); + const host = new SessionSubagentHost(session, 'main'); + if (options.profilePreference !== undefined) { + vi.spyOn( + host as unknown as { + resolveProfile: (parent: Agent, name: string) => ResolvedAgentProfile; + }, + 'resolveProfile', + ).mockReturnValue( + profile({ + name: 'coder', + tools: ['Read'], + systemPrompt: 'coder prompt', + modelPreference: options.profilePreference, + }), + ); + } + const handle = await host.spawn({ + profileName: 'coder', + modelChoice: options.modelChoice, + parentToolCallId: 'call_agent', + prompt: 'Do work', + description: 'Do work', + runInBackground: false, + signal, + }); + await handle.completion; + return { parent, child, handle }; + } + + it('binds the secondary model when configured', async () => { + const { parent, child } = await spawnChild({ + experimentalFlags: secondaryFlags(), + config: { + providers: {}, + secondaryModel: { model: 'cheap-model' }, + }, + }); + expect(child.agent.config.modelAlias).toBe('cheap-model'); + expect(child.agent.config.modelAlias).not.toBe(parent.agent.config.modelAlias); + }); + + it('binds the derived entry when the recipe carries patch fields', async () => { + const { child } = await spawnChild({ + experimentalFlags: secondaryFlags(), + config: { + providers: {}, + secondaryModel: { model: 'cheap-model', defaultEffort: 'low' }, + }, + }); + // default_effort is part of the subagent-only patch, so the spawn binds + // the synthesized derived entry rather than the pointed alias. + expect(child.agent.config.modelAlias).toBe('__secondary__'); + }); + + it('inherits the parent model when the experiment is off', async () => { + const { parent, child } = await spawnChild({ + config: { providers: {}, secondaryModel: { model: 'cheap-model' } }, + }); + expect(child.agent.config.modelAlias).toBe(parent.agent.config.modelAlias); + }); + + it('inherits the parent model for an explicit model: primary choice', async () => { + const { parent, child } = await spawnChild({ + experimentalFlags: secondaryFlags(), + config: { providers: {}, secondaryModel: { model: 'cheap-model' } }, + modelChoice: 'primary', + }); + expect(child.agent.config.modelAlias).toBe(parent.agent.config.modelAlias); + }); + + it('honors the profile model_preference over the configured secondary model', async () => { + const { parent, child } = await spawnChild({ + experimentalFlags: secondaryFlags(), + config: { providers: {}, secondaryModel: { model: 'cheap-model' } }, + profilePreference: 'primary', + }); + expect(child.agent.config.modelAlias).toBe(parent.agent.config.modelAlias); + }); + + it('fails the spawn with a wrapped error when the secondary model does not resolve', async () => { + const parent = testAgent(); + parent.configure(); + const child = testAgent(); + child.configure({ tools: ['Read'] }); + const config: KimiConfig = { + providers: {}, + secondaryModel: { model: 'missing-model' }, + }; + const session = fakeSession(parent.agent, child.agent, {}, { + experimentalFlags: secondaryFlags(), + config, + providerManager: new ProviderManager({ config }), + }); + const host = new SessionSubagentHost(session, 'main'); + + await expect( + host.spawn({ + profileName: 'coder', + parentToolCallId: 'call_agent', + prompt: 'Do work', + description: 'Do work', + runInBackground: false, + signal, + }).then((handle) => handle.completion), + ).rejects.toThrow(/\[secondary_model\]\.model/); + }); + + it('preserves a provider configuration error when the secondary alias exists', async () => { + const parent = testAgent(); + parent.configure(); + const child = testAgent(); + child.configure({ tools: ['Read'] }); + const config: KimiConfig = { + providers: {}, + models: { + 'cheap-model': { + provider: 'missing-provider', + model: 'cheap-model', + maxContextSize: 1_000_000, + }, + }, + secondaryModel: { model: 'cheap-model' }, + }; + const session = fakeSession(parent.agent, child.agent, {}, { + experimentalFlags: secondaryFlags(), + config, + providerManager: new ProviderManager({ config }), + }); + const host = new SessionSubagentHost(session, 'main'); + + await expect( + host.spawn({ + profileName: 'coder', + parentToolCallId: 'call_agent', + prompt: 'Do work', + description: 'Do work', + runInBackground: false, + signal, + }).then((handle) => handle.completion), + ).rejects.toMatchObject({ + message: 'Provider "missing-provider" for model "cheap-model" is not configured.', + }); + }); + + it('keeps the spawned model on resume when the experiment is on', async () => { + const parent = testAgent(); + parent.configure(); + parent.agent.permission.setMode('yolo'); + + const child = testAgent({ initialConfig: withSecondaryModels() }); + child.configure({ tools: ['Read'] }); + child.agent.config.update({ modelAlias: 'cheap-model' }); + child.agent.useProfile( + profile({ name: 'coder', tools: ['Read'], systemPrompt: 'coder prompt' }), + ); + child.agent.context.appendUserMessage([{ type: 'text', text: 'Earlier context' }]); + child.mockNextResponse({ type: 'text', text: LONG_SUMMARY }); + + const session = fakeSession(parent.agent, child.agent, { + 'agent-0': { + homedir: '/tmp/kimi-session/agents/agent-0', + type: 'sub', + parentAgentId: 'main', + }, + }, { + experimentalFlags: secondaryFlags(), + config: { providers: {}, secondaryModel: { model: 'cheap-model' } }, + }); + const host = new SessionSubagentHost(session, 'main'); + + const handle = await host.resume('agent-0', { + parentToolCallId: 'call_agent', + prompt: 'Continue from context', + description: 'Continue work', + runInBackground: false, + signal, + }); + await handle.completion; + // With the experiment on, resume no longer realigns the child to the + // parent's model: the subagent keeps the model it was bound to at spawn. + expect(child.agent.config.modelAlias).toBe('cheap-model'); + }); + }); }); describe('Session resume permission parent chain', () => { @@ -1576,10 +1811,25 @@ describe('Session.createAgent', () => { }); }); +function testAgentCatalog(): SessionAgentProfileCatalog { + // A real catalog seeded with the builtin profiles; discovery roots point + // at nonexistent dirs so no file profiles leak into the merge. + return new SessionAgentProfileCatalog({ + workDir: '/nonexistent-kimi-test-workdir', + brandHomeDir: '/nonexistent-kimi-test-brandhome', + osHomeDir: '/nonexistent-kimi-test-oshome', + }); +} + function fakeSession( parent: Agent, child: Agent, metadataAgents: Session['metadata']['agents'] = {}, + sessionOptions?: { + config?: KimiConfig; + experimentalFlags?: FlagResolver; + providerManager?: Session['options']['providerManager']; + }, ) { const agents = new Map([['main', parent]]); if (metadataAgents['agent-0'] !== undefined) { @@ -1587,7 +1837,16 @@ function fakeSession( } return { agents, - options: { kimiHomeDir: undefined }, + options: { + kimiHomeDir: undefined, + config: sessionOptions?.config, + providerManager: sessionOptions?.providerManager, + }, + get kimiConfig() { + return sessionOptions?.config; + }, + experimentalFlags: sessionOptions?.experimentalFlags ?? new FlagResolver({}), + agentCatalog: testAgentCatalog(), metadata: { createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', @@ -1665,6 +1924,7 @@ function profile(input: { readonly systemPrompt: string; readonly description?: string | undefined; readonly subagents?: Record | undefined; + readonly modelPreference?: 'primary' | 'secondary'; }): ResolvedAgentProfile { return { name: input.name, @@ -1672,6 +1932,7 @@ function profile(input: { systemPrompt: () => input.systemPrompt, tools: [...input.tools], subagents: input.subagents, + modelPreference: input.modelPreference, }; } diff --git a/packages/agent-core/test/tools/agent.test.ts b/packages/agent-core/test/tools/agent.test.ts index ec5109c2e..0df5eab03 100644 --- a/packages/agent-core/test/tools/agent.test.ts +++ b/packages/agent-core/test/tools/agent.test.ts @@ -22,7 +22,7 @@ function context(args: Input, toolCallId = 'call_agent') { function mockSubagentHost & Partial>( host: T, ): T & SessionSubagentHost { - return { resume: vi.fn(), ...host } as unknown as T & SessionSubagentHost; + return { resume: vi.fn(), delegatableSubagents: vi.fn(() => ({})), ...host } as unknown as T & SessionSubagentHost; } function agentTool( @@ -136,12 +136,30 @@ describe('AgentTool', () => { expect(tool.description).toContain('Default to a foreground subagent'); }); - it('does not expose a model parameter in the JSON schema', () => { + it('exposes a primary/secondary model parameter in the JSON schema', () => { const host = mockSubagentHost({ spawn: vi.fn() }); const tool = agentTool(host); - const properties = (tool.parameters as { properties: Record }).properties; + const properties = ( + tool.parameters as { + properties: Record; + } + ).properties; - expect(properties).not.toHaveProperty('model'); + expect(properties['model']?.enum).toEqual(['primary', 'secondary']); + expect(properties['model']?.description).toContain('secondary'); + }); + + it('appends the subagent model description only when provided', () => { + const host = mockSubagentHost({ spawn: vi.fn() }); + const withoutModels = agentTool(host); + expect(withoutModels.description).not.toContain('Available models'); + + const withModels = agentTool(host, createBackgroundManager().manager, undefined, { + subagentModelDescription: + 'Available models (pass via model):\n- secondary: cheap (default)\n- primary: flagship', + }); + expect(withModels.description).toContain('Available models (pass via model):'); + expect(withModels.description).toContain('secondary: cheap'); }); it('renders the tool set for each subagent type', () => { @@ -278,6 +296,48 @@ describe('AgentTool', () => { ); }); + it('passes the model choice through to spawn, but not to resume', async () => { + const host = mockSubagentHost({ + spawn: vi.fn().mockResolvedValue({ + agentId: 'agent-child', + profileName: 'coder', + resumed: false, + completion: Promise.resolve({ result: 'child result' }), + }), + resume: vi.fn().mockResolvedValue({ + agentId: 'agent-existing', + profileName: 'coder', + resumed: true, + completion: Promise.resolve({ result: 'resumed result' }), + }), + }); + const tool = agentTool(host); + + await executeTool(tool, + context({ + prompt: 'Investigate', + description: 'Find cause', + model: 'primary', + }), + ); + expect(host.spawn).toHaveBeenCalledWith( + expect.objectContaining({ modelChoice: 'primary' }), + ); + + await executeTool(tool, + context({ + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + model: 'secondary', + }), + ); + expect(host.resume).toHaveBeenCalledWith( + 'agent-existing', + expect.not.objectContaining({ modelChoice: expect.anything() }), + ); + }); + it('resumes a foreground subagent when resume is provided', async () => { const host = mockSubagentHost({ spawn: vi.fn(), diff --git a/packages/agent-core/test/tools/builtin-current.test.ts b/packages/agent-core/test/tools/builtin-current.test.ts index f9bae6ea6..48d85813b 100644 --- a/packages/agent-core/test/tools/builtin-current.test.ts +++ b/packages/agent-core/test/tools/builtin-current.test.ts @@ -81,6 +81,7 @@ function mockSubagentHost>( resume: vi.fn(), runQueued: vi.fn(), getSwarmItem: vi.fn(), + delegatableSubagents: vi.fn(() => ({})), ...host, } as unknown as T & SessionSubagentHost; } diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 8e42ae048..388f5a763 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -73,6 +73,10 @@ export type { LogContext, LogLevel, LogPayload, Logger } from '@moonshot-ai/agen // config without spinning up a full KimiCore. export { effectiveModelAlias, loadRuntimeConfigSafe, resolveConfigPath } from '@moonshot-ai/agent-core'; export { limitAgentReplayByTurns } from '@moonshot-ai/agent-core'; +export { parseAgentFileText, resolveAgentPath } from '@moonshot-ai/agent-core'; +// The synthesized `[models]` alias a `[secondary_model]` recipe with patch +// fields materializes at runtime — hosts filter it out of model pickers. +export { SECONDARY_DERIVED_MODEL_ALIAS } from '@moonshot-ai/agent-core'; // Process-wide HTTP proxy bootstrap — installed once at CLI startup so all // outbound fetch honors HTTP_PROXY / HTTPS_PROXY / NO_PROXY. diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 653933c78..a32ee5c10 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -140,6 +140,8 @@ export class KimiHarness { if (active !== undefined) { if (kaos !== undefined || persistenceKaos !== undefined) { await this.rpc.resumeSessionWithKaos({ ...resumeInput, id }, kaos ?? persistenceKaos as Kaos, persistenceKaos); + } else if (input.agentProfile !== undefined) { + await this.rpc.resumeSession({ ...resumeInput, id }); } return active; } diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 1315812c0..42fc747ab 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -431,6 +431,11 @@ export abstract class SDKRpcClientBase { }); } + async applyPersistedSecondaryModel(input: SessionIdRpcInput): Promise { + const rpc = await this.getRpc(); + return rpc.applyPersistedSecondaryModel({ sessionId: input.sessionId }); + } + async setPermission(input: SetSessionPermissionRpcInput): Promise { const rpc = await this.getRpc(); return rpc.setPermission({ diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 571c2355f..922211283 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -207,6 +207,18 @@ export class Session { await this.rpc.setThinking({ sessionId: this.id, effort: normalized }); } + /** + * Live-apply the persisted `[secondary_model]` recipe to this session + * (subagent model binding). Persist the recipe via `KimiHarness.setConfig` + * first; this reloads the complete recipe and its synthesized derived entry + * before updating the session snapshot — mirroring the `/secondary_model` + * flow. + */ + async applyPersistedSecondaryModel(): Promise { + this.ensureOpen(); + await this.rpc.applyPersistedSecondaryModel({ sessionId: this.id }); + } + async setPermission(mode: PermissionMode): Promise { this.ensureOpen(); if (!isPermissionMode(mode)) { diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 0968d96a1..d65e5d30c 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -107,6 +107,16 @@ export interface CreateSessionOptions { readonly kaos?: Kaos | undefined; readonly persistenceKaos?: Kaos | undefined; readonly additionalDirs?: readonly string[]; + /** + * Main-agent profile name (`--agent`): a builtin profile or one defined by + * an agentfile discovered from the user/project agent directories. + */ + readonly agentProfile?: string; + /** + * Explicit agentfiles (`--agent-file`) loaded for this session with the + * highest precedence; an invalid file fails session creation. + */ + readonly agentFiles?: readonly string[]; readonly sessionStartedProperties?: TelemetryProperties; /** * Print-mode (`kimi -p`) only: when the main agent ends a turn while @@ -128,6 +138,8 @@ export interface ResumeSessionInput { readonly kaos?: Kaos | undefined; readonly persistenceKaos?: Kaos | undefined; readonly additionalDirs?: readonly string[]; + /** Re-select the session's already-bound main profile; a different name fails. */ + readonly agentProfile?: string; /** Include persisted subagent states in the returned replay snapshot. */ readonly includeSubagents?: boolean; /** diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index 9484facf1..3fae01040 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -345,6 +345,17 @@ describe('KimiHarness config API', () => { enabled: false, source: 'default', }, + { + id: 'secondary-model', + title: 'Secondary model for subagents', + description: + 'Let newly spawned subagents use a separately configured secondary model by default, with an explicit primary-model override for quality-sensitive tasks.', + surface: 'core', + env: 'KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', + defaultEnabled: false, + enabled: false, + source: 'default', + }, ]); }); diff --git a/packages/node-sdk/test/create-session-transport.test.ts b/packages/node-sdk/test/create-session-transport.test.ts index 491b19f25..ff09abf3b 100644 --- a/packages/node-sdk/test/create-session-transport.test.ts +++ b/packages/node-sdk/test/create-session-transport.test.ts @@ -1,5 +1,12 @@ +/** + * Scenario: KimiHarness session creation and resume transport behavior. + * Responsibilities: SDK options reach the in-process core and session identity remains stable. + * Wiring: the real SDK/core are used; model/network boundaries are configured but never called. + * Run: pnpm -C packages/node-sdk exec vitest run test/create-session-transport.test.ts + */ + import { existsSync } from 'node:fs'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -51,6 +58,16 @@ max_context_size = 1000 ); } +async function writeReviewerAgent(workDir: string): Promise { + const agentDir = join(workDir, '.kimi-code', 'agents'); + await mkdir(agentDir, { recursive: true }); + await writeFile( + join(agentDir, 'reviewer.md'), + '---\nname: reviewer\ndescription: Reviews code.\nsubagents:\n - explore\n---\n\nReview the requested change.\n', + 'utf-8', + ); +} + class StubRpc extends SDKRpcClientBase { resumeCalls: Array<{ input: ResumeSessionInput; kaos: Kaos; persistenceKaos?: Kaos }> = []; @@ -553,6 +570,82 @@ effort = "medium" } }); + it('does not persist a session record when the requested agent profile is missing', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const harness = createKimiHarness({ + identity: TEST_IDENTITY, + homeDir, + }); + + try { + await expect( + harness.createSession({ + id: 'ses_missing_agent_profile', + workDir, + agentProfile: 'missing-agent', + }), + ).rejects.toMatchObject({ + name: 'KimiError', + code: 'agent.not_found', + }); + expect(await harness.listSessions({ workDir })).toEqual([]); + expect(existsSync(join(homeDir, 'session_index.jsonl'))).toBe(false); + } finally { + await harness.close(); + } + }); + + it('allows the session ID to be reused after agent profile selection fails', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const harness = createKimiHarness({ + identity: TEST_IDENTITY, + homeDir, + }); + + try { + await expect( + harness.createSession({ + id: 'ses_reusable_after_missing_profile', + workDir, + agentProfile: 'missing-agent', + }), + ).rejects.toMatchObject({ code: 'agent.not_found' }); + + await expect( + harness.createSession({ + id: 'ses_reusable_after_missing_profile', + workDir, + }), + ).resolves.toMatchObject({ id: 'ses_reusable_after_missing_profile' }); + } finally { + await harness.close(); + } + }); + + it('does not persist a session record when an explicit agent file cannot be loaded', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const harness = createKimiHarness({ + identity: TEST_IDENTITY, + homeDir, + }); + + try { + await expect( + harness.createSession({ + id: 'ses_missing_explicit_agent_file', + workDir, + agentFiles: [join(workDir, 'missing-agent.md')], + }), + ).rejects.toThrow(/missing-agent\.md/); + expect(await harness.listSessions({ workDir })).toEqual([]); + } finally { + await harness.close(); + } + }); + it('closes active runtime handles through closeSession, session.close, and close', async () => { const homeDir = await makeTempDir(); const workDir = await makeTempDir(); @@ -772,6 +865,77 @@ effort = "medium" persistenceKaos: undefined, }); }); + + it('rejects an active session resume when the requested profile differs from its binding', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + await writeTestModelConfig(homeDir); + await writeReviewerAgent(workDir); + const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); + + try { + const session = await harness.createSession({ + id: 'ses_active_profile_identity', + workDir, + agentProfile: 'reviewer', + }); + + await expect( + harness.resumeSession({ id: session.id, agentProfile: 'agent' }), + ).rejects.toThrow( + 'agent is already bound to profile "reviewer"; cannot switch to "agent" in this session', + ); + } finally { + await harness.close(); + } + }); + + it('returns the active session when the requested profile matches its binding', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + await writeTestModelConfig(homeDir); + await writeReviewerAgent(workDir); + const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); + + try { + const session = await harness.createSession({ + id: 'ses_matching_profile_identity', + workDir, + agentProfile: 'reviewer', + }); + + await expect( + harness.resumeSession({ id: session.id, agentProfile: 'reviewer' }), + ).resolves.toBe(session); + } finally { + await harness.close(); + } + }); + + it('rejects a persisted session resume when the requested profile differs from its binding', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + await writeTestModelConfig(homeDir); + await writeReviewerAgent(workDir); + const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); + + try { + const session = await harness.createSession({ + id: 'ses_persisted_profile_identity', + workDir, + agentProfile: 'reviewer', + }); + await session.close(); + + await expect( + harness.resumeSession({ id: session.id, agentProfile: 'agent' }), + ).rejects.toThrow( + 'agent is already bound to profile "reviewer"; cannot switch to "agent" in this session', + ); + } finally { + await harness.close(); + } + }); }); function coreSessionIds(harness: KimiHarness): readonly string[] {