diff --git a/.changeset/spiceup-sampling-override.md b/.changeset/spiceup-sampling-override.md new file mode 100644 index 000000000..bf5afc10c --- /dev/null +++ b/.changeset/spiceup-sampling-override.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/kimi-code": minor +--- + +Add `/spiceup` slash command for session-level overrides of model sampling parameters (temperature, top_p, top_k, max_tokens, frequency_penalty, presence_penalty). Values apply immediately, last for the session, override config.toml defaults, and are inherited by subagents. diff --git a/.changeset/subagent-model-selection.md b/.changeset/subagent-model-selection.md new file mode 100644 index 000000000..0001a46e1 --- /dev/null +++ b/.changeset/subagent-model-selection.md @@ -0,0 +1,15 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code": minor +"@moonshot-ai/kosong": patch +"@moonshot-ai/protocol": patch +--- + +Add per-role and per-invocation model selection for subagents. + +A new `[subagent_models]` config.toml section maps subagent profile names +to model aliases so different roles (coder, explore, plan) can use +different LLM models. The Agent tool also accepts an optional `model` +parameter to override the model for a single invocation. When a subagent +uses a model that does not support thinking, the thinking level is +automatically disabled to avoid API errors. diff --git a/.changeset/sysprompt-override.md b/.changeset/sysprompt-override.md new file mode 100644 index 000000000..a47165fb3 --- /dev/null +++ b/.changeset/sysprompt-override.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code": minor +--- + +Support overriding the system prompt via `~/.kimi-code/sysprompt.md` or a project-level `.kimi-code/sysprompt.md` file. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index d95c02e35..0c78f552f 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -13,6 +13,10 @@ import { import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector'; import { PermissionSelectorComponent } from '../components/dialogs/permission-selector'; import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector'; +import { + SpiceupSelectorComponent, + type SpiceupSelection, +} from '../components/dialogs/spiceup-selector'; import { ThemeSelectorComponent } from '../components/dialogs/theme-selector'; import { UpdatePreferenceSelectorComponent } from '../components/dialogs/update-preference-selector'; import { saveTuiConfig } from '../config'; @@ -286,6 +290,61 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string = ); } +export function showSpiceupPicker(host: SlashCommandHost): void { + host.mountEditorReplacement( + new SpiceupSelectorComponent({ + currentValues: host.state.appState.generationKwargs ?? {}, + onSubmit: (selection) => { + host.restoreEditor(); + void applySpiceupChoice(host, selection); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +function spiceupSelectionToKwargs(selection: SpiceupSelection): Record { + const kwargs: Record = {}; + if (selection.temperature !== undefined) kwargs['temperature'] = selection.temperature; + if (selection.topP !== undefined) kwargs['top_p'] = selection.topP; + if (selection.topK !== undefined) kwargs['top_k'] = selection.topK; + if (selection.maxTokens !== undefined) kwargs['max_tokens'] = selection.maxTokens; + if (selection.frequencyPenalty !== undefined) kwargs['frequency_penalty'] = selection.frequencyPenalty; + if (selection.presencePenalty !== undefined) kwargs['presence_penalty'] = selection.presencePenalty; + return kwargs; +} + +export async function applySpiceupChoice(host: SlashCommandHost, selection: SpiceupSelection): Promise { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const kwargs = spiceupSelectionToKwargs(selection); + const keys = Object.keys(kwargs); + if (keys.length === 0) { + try { + await session.setGenerationKwargs({}); + host.setAppState({ generationKwargs: null }); + host.showStatus('Sampling overrides cleared for this session.'); + } catch (error) { + host.showError(`Failed to clear sampling overrides: ${formatErrorMessage(error)}`); + } + return; + } + + try { + await session.setGenerationKwargs(kwargs); + host.setAppState({ generationKwargs: selection }); + host.showStatus(`Sampling overrides set: ${keys.join(', ')}`); + } catch (error) { + host.showError(`Failed to set sampling overrides: ${formatErrorMessage(error)}`); + } +} + async function performModelSwitch(host: SlashCommandHost, alias: string, thinking: boolean): Promise { if (host.state.appState.streamingPhase !== 'idle') { host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.'); diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 397404e0f..c3c5bebb8 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -41,6 +41,7 @@ import { showModelPicker, showPermissionPicker, showSettingsSelector, + showSpiceupPicker, } from './config'; import { handleGoalCommand } from './goal'; import { handleProviderCommand } from './provider'; @@ -275,6 +276,9 @@ async function handleBuiltInSlashCommand( case 'model': handleModelCommand(host, args); return; + case 'spiceup': + showSpiceupPicker(host); + return; case 'provider': await handleProviderCommand(host); return; diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 464cc770d..950009a58 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -92,6 +92,13 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 100, availability: 'always', }, + { + name: 'spiceup', + aliases: [], + description: 'Adjust session-level model sampling parameters', + priority: 95, + availability: 'always', + }, { name: 'provider', aliases: ['providers'], diff --git a/apps/kimi-code/src/tui/components/dialogs/spiceup-selector.ts b/apps/kimi-code/src/tui/components/dialogs/spiceup-selector.ts new file mode 100644 index 000000000..940609569 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/spiceup-selector.ts @@ -0,0 +1,295 @@ +import { + Container, + Input, + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@earendil-works/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +export interface SpiceupSelection { + temperature?: number | undefined; + topP?: number | undefined; + topK?: number | undefined; + maxTokens?: number | undefined; + frequencyPenalty?: number | undefined; + presencePenalty?: number | undefined; +} + +export interface SpiceupSelectorOptions { + readonly currentValues: SpiceupSelection; + readonly onSubmit: (values: SpiceupSelection) => void; + readonly onCancel: () => void; +} + +interface SpiceField { + readonly key: keyof SpiceupSelection; + readonly label: string; + readonly description: string; + readonly min?: number | undefined; + readonly max?: number | undefined; + readonly integer: boolean; +} + +const FIELDS: readonly SpiceField[] = [ + { + key: 'temperature', + label: 'Temperature', + description: '0.0 = deterministic, 2.0 = very random', + min: 0, + max: 2, + integer: false, + }, + { + key: 'topP', + label: 'Top P', + description: 'Nucleus sampling cutoff (0.0–1.0)', + min: 0, + max: 1, + integer: false, + }, + { + key: 'topK', + label: 'Top K', + description: 'Limit token pool to top K candidates', + min: 1, + integer: true, + }, + { + key: 'maxTokens', + label: 'Max tokens', + description: 'Maximum tokens to generate', + min: 1, + integer: true, + }, + { + key: 'frequencyPenalty', + label: 'Frequency penalty', + description: 'Penalize repeated tokens (-2.0–2.0)', + min: -2, + max: 2, + integer: false, + }, + { + key: 'presencePenalty', + label: 'Presence penalty', + description: 'Penalize repeated topics (-2.0–2.0)', + min: -2, + max: 2, + integer: false, + }, +]; + +const FOOTER = '↑↓ navigate · ↵ edit · ctrl+s submit · esc cancel'; +const EDIT_FOOTER = '↵ save · esc stop editing'; + +export class SpiceupSelectorComponent extends Container implements Focusable { + focused = false; + + private readonly onSubmit: (values: SpiceupSelection) => void; + private readonly onCancel: () => void; + private readonly values: SpiceupSelection; + private readonly inputs: Input[] = FIELDS.map(() => new Input()); + private focusedField = 0; + private editing = false; + private errorMessage: string | undefined; + + constructor(opts: SpiceupSelectorOptions) { + super(); + this.onSubmit = opts.onSubmit; + this.onCancel = opts.onCancel; + this.values = { ...opts.currentValues }; + + for (let i = 0; i < FIELDS.length; i++) { + const field = FIELDS[i]; + if (field === undefined) continue; + const input = this.inputs[i]!; + const value = this.values[field.key]; + input.setValue(value !== undefined ? String(value) : ''); + input.onSubmit = () => { + this.commitEdit(); + if (this.errorMessage === undefined) { + this.editing = false; + } + }; + } + } + + private currentField(): SpiceField | undefined { + return FIELDS[this.focusedField]; + } + + private currentInput(): Input | undefined { + return this.inputs[this.focusedField]; + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { + if (this.editing) { + this.editing = false; + this.errorMessage = undefined; + return; + } + this.onCancel(); + return; + } + + if (matchesKey(data, Key.ctrl('s'))) { + this.submit(); + return; + } + + if (this.editing) { + this.currentInput()?.handleInput(data); + return; + } + + if (matchesKey(data, Key.up)) { + this.moveFocus(-1); + return; + } + if (matchesKey(data, Key.down) || matchesKey(data, Key.tab)) { + this.moveFocus(1); + return; + } + if (matchesKey(data, Key.enter)) { + this.editing = true; + this.errorMessage = undefined; + } + } + + override invalidate(): void { + super.invalidate(); + for (const input of this.inputs) { + input.invalidate(); + } + } + + override render(width: number): string[] { + const safeWidth = Math.max(44, width); + const innerWidth = Math.max(40, safeWidth - 4); + const pad = ' '; + + const border = (s: string): string => currentTheme.fg('primary', s); + const title = currentTheme.boldFg('textStrong', 'Spice up model sampling'); + const subtitle = currentTheme.fg( + 'textDim', + 'Set session-level overrides. Leave empty to clear.', + ); + const footer = currentTheme.fg('textDim', this.editing ? EDIT_FOOTER : FOOTER); + const error = + this.errorMessage !== undefined ? currentTheme.fg('error', this.errorMessage) : undefined; + + const lines: string[] = [ + '', + border('╭' + '─'.repeat(safeWidth - 2) + '╮'), + border('│') + ' '.repeat(safeWidth - 2) + border('│'), + ]; + + lines.push( + border('│') + + pad + + truncateToWidth(title, innerWidth, '…') + + ' '.repeat(Math.max(0, innerWidth - visibleWidth(title))) + + border('│'), + ); + lines.push( + border('│') + + pad + + truncateToWidth(subtitle, innerWidth, '…') + + ' '.repeat(Math.max(0, innerWidth - visibleWidth(subtitle))) + + border('│'), + ); + lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│')); + + for (let i = 0; i < FIELDS.length; i++) { + const field = FIELDS[i]; + if (field === undefined) continue; + const isFocused = i === this.focusedField; + const input = this.inputs[i]!; + const editingHere = this.editing && isFocused; + input.focused = this.focused && editingHere; + + const label = isFocused + ? currentTheme.boldFg('primary', `→ ${field.label}`) + : currentTheme.fg('text', ` ${field.label}`); + const desc = currentTheme.fg('textDim', field.description); + const inputWidth = Math.max(10, innerWidth - 24); + const rawInput = input.render(inputWidth)[0] ?? '> '; + const inputLine = rawInput.startsWith('> ') ? rawInput.slice(2) : rawInput; + const valueLine = currentTheme.fg(isFocused ? 'textStrong' : 'text', inputLine); + + const labelText = `${label}: ${valueLine}`; + const labelPadding = Math.max(0, innerWidth - visibleWidth(labelText)); + lines.push(border('│') + pad + labelText + ' '.repeat(labelPadding) + border('│')); + + const descPadding = Math.max(0, innerWidth - visibleWidth(desc)); + lines.push(border('│') + pad + desc + ' '.repeat(descPadding) + border('│')); + + if (i < FIELDS.length - 1) { + const spacerPadding = innerWidth; + lines.push(border('│') + pad + ' '.repeat(spacerPadding) + border('│')); + } + } + + if (error !== undefined) { + lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│')); + const errorPadding = Math.max(0, innerWidth - visibleWidth(error)); + lines.push(border('│') + pad + error + ' '.repeat(errorPadding) + border('│')); + } + + lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│')); + const footerPadding = Math.max(0, innerWidth - visibleWidth(footer)); + lines.push(border('│') + pad + footer + ' '.repeat(footerPadding) + border('│')); + lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│')); + lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); + lines.push(''); + + return lines.map((line) => truncateToWidth(line, width)); + } + + private moveFocus(delta: number): void { + this.commitEdit(); + this.editing = false; + this.focusedField = + ((this.focusedField + delta) % FIELDS.length + FIELDS.length) % FIELDS.length; + this.errorMessage = undefined; + } + + private commitEdit(): void { + this.errorMessage = undefined; + const field = this.currentField(); + const input = this.currentInput(); + if (field === undefined || input === undefined) return; + + const raw = input.getValue().trim(); + if (raw.length === 0) { + this.values[field.key] = undefined; + return; + } + + const num = field.integer ? Number.parseInt(raw, 10) : Number.parseFloat(raw); + if (!Number.isFinite(num)) { + this.errorMessage = `${field.label} must be a number`; + return; + } + if (field.min !== undefined && num < field.min) { + this.errorMessage = `${field.label} must be ≥ ${String(field.min)}`; + return; + } + if (field.max !== undefined && num > field.max) { + this.errorMessage = `${field.label} must be ≤ ${String(field.max)}`; + return; + } + this.values[field.key] = num; + } + + private submit(): void { + this.commitEdit(); + if (this.errorMessage !== undefined) return; + this.onSubmit({ ...this.values }); + } +} 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 6d08330c5..3f4f9b446 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -367,6 +367,7 @@ export class SubAgentEventHandler { parentToolCallId: event.parentToolCallId, agentName: event.subagentName, description: typeof description === 'string' ? description : undefined, + modelAlias: event.modelAlias, }; } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 0337785f0..da4e9f91f 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -182,6 +182,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { upgrade: input.tuiConfig.upgrade, availableModels: {}, availableProviders: {}, + generationKwargs: null, sessionTitle: null, goal: null, mcpServersSummary: null, diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index bbf047073..3d615f5a5 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -10,6 +10,7 @@ import type { import type { NotificationsConfig, UpgradePreferences } from './config'; import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; +import type { SpiceupSelection } from './components/dialogs/spiceup-selector'; import type { ColorToken, ThemeName } from './theme'; export interface BannerState { @@ -40,6 +41,7 @@ export interface AppState { upgrade: UpgradePreferences; availableModels: Record; availableProviders: Record; + generationKwargs: SpiceupSelection | null; sessionTitle: string | null; /** Current goal snapshot for the footer badge; null/undefined when no active goal. */ goal?: GoalSnapshot | null; @@ -93,6 +95,7 @@ export interface BackgroundAgentMetadata { readonly parentToolCallId: string; readonly agentName?: string; readonly description?: string; + readonly modelAlias?: string; } export type BackgroundAgentStatusPhase = 'started' | 'completed' | 'failed'; diff --git a/apps/kimi-code/src/tui/utils/background-agent-status.ts b/apps/kimi-code/src/tui/utils/background-agent-status.ts index a54257a97..a2668ef41 100644 --- a/apps/kimi-code/src/tui/utils/background-agent-status.ts +++ b/apps/kimi-code/src/tui/utils/background-agent-status.ts @@ -28,7 +28,8 @@ export function formatBackgroundAgentTranscript( ? `${subject} completed in background` : `${subject} failed in background`; const tail = phase === 'failed' ? normalizeBackgroundField(extras?.error) : undefined; - const detailParts = [normalizeBackgroundField(meta.description), tail].filter( + const modelPart = meta.modelAlias !== undefined ? `model: ${meta.modelAlias}` : undefined; + const detailParts = [normalizeBackgroundField(meta.description), modelPart, tail].filter( (part): part is string => part !== undefined, ); diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index edfeaa106..865269c0f 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -140,6 +140,7 @@ describe('built-in slash command registry', () => { 'model', 'new', 'permission', + 'spiceup', 'plan', 'reload', 'reload-tui', diff --git a/apps/kimi-code/test/tui/commands/spiceup.test.ts b/apps/kimi-code/test/tui/commands/spiceup.test.ts new file mode 100644 index 000000000..25c9f0524 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/spiceup.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { applySpiceupChoice } from '#/tui/commands/config'; +import { darkColors } from '#/tui/theme/colors'; +import type { AppState } from '#/tui/types'; + +function fakeHost(overrides?: Partial): { + state: { appState: AppState; theme: { palette: typeof darkColors } }; + setAppState: ReturnType; + showStatus: ReturnType; + showError: ReturnType; + session: { setGenerationKwargs: ReturnType }; +} { + const appState: AppState = { + model: 'test-model', + workDir: '/tmp/test', + sessionId: 'sess-1', + permissionMode: 'manual', + planMode: false, + swarmMode: false, + thinking: false, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + isCompacting: false, + isReplaying: false, + streamingPhase: 'idle', + streamingStartTime: 0, + theme: 'dark', + version: '0.0.0-test', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, + availableModels: {}, + availableProviders: {}, + generationKwargs: null, + sessionTitle: null, + mcpServersSummary: null, + ...overrides, + }; + + return { + state: { appState, theme: { palette: darkColors } }, + setAppState: vi.fn(), + showStatus: vi.fn(), + showError: vi.fn(), + session: { setGenerationKwargs: vi.fn().mockResolvedValue(undefined) }, + }; +} + +describe('applySpiceupChoice', () => { + it('sends converted kwargs to the session and updates app state', async () => { + const host = fakeHost(); + + await applySpiceupChoice(host, { + temperature: 0.7, + topP: 0.9, + topK: 50, + maxTokens: 4096, + frequencyPenalty: 0.1, + presencePenalty: 0.2, + }); + + expect(host.session.setGenerationKwargs).toHaveBeenCalledWith({ + temperature: 0.7, + top_p: 0.9, + top_k: 50, + max_tokens: 4096, + frequency_penalty: 0.1, + presence_penalty: 0.2, + }); + expect(host.setAppState).toHaveBeenCalledWith({ + generationKwargs: { + temperature: 0.7, + topP: 0.9, + topK: 50, + maxTokens: 4096, + frequencyPenalty: 0.1, + presencePenalty: 0.2, + }, + }); + expect(host.showStatus).toHaveBeenCalledWith( + 'Sampling overrides set: temperature, top_p, top_k, max_tokens, frequency_penalty, presence_penalty', + ); + }); + + it('clears overrides when the selection is empty', async () => { + const host = fakeHost({ generationKwargs: { temperature: 0.5 } }); + + await applySpiceupChoice(host, {}); + + expect(host.session.setGenerationKwargs).toHaveBeenCalledWith({}); + expect(host.setAppState).toHaveBeenCalledWith({ generationKwargs: null }); + expect(host.showStatus).toHaveBeenCalledWith('Sampling overrides cleared for this session.'); + }); + + it('shows an error when there is no active session', async () => { + const host = fakeHost(); + host.session = undefined as unknown as typeof host.session; + + await applySpiceupChoice(host, { temperature: 0.7 }); + + expect(host.showError).toHaveBeenCalledWith( + 'No active session. Send /login to login.', + ); + expect(host.session).toBeUndefined(); + }); + + it('surfaces session errors to the user', async () => { + const host = fakeHost(); + host.session.setGenerationKwargs = vi.fn().mockRejectedValue(new Error('provider refused')); + + await applySpiceupChoice(host, { temperature: 0.7 }); + + expect(host.showError).toHaveBeenCalledWith('Failed to set sampling overrides: provider refused'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index ab0878d6b..c99505efd 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -54,6 +54,7 @@ const appState: AppState = { upgrade: { autoInstall: true }, availableModels: {}, availableProviders: {}, + generationKwargs: null, mcpServersSummary: null, }; diff --git a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts index 4233934db..9e72c2c14 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -31,6 +31,7 @@ const appState: AppState = { upgrade: { autoInstall: true }, availableModels: {}, availableProviders: {}, + generationKwargs: null, mcpServersSummary: null, }; diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index eb9dbd986..b4741d23d 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -28,6 +28,7 @@ function fakeInitialAppState(): AppState { availableModels: {}, availableProviders: {}, sessionTitle: null, + generationKwargs: null, mcpServersSummary: null, }; } diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 0d64f5044..334d090c4 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -84,6 +84,7 @@ Fields in the config file fall into two categories: **top-level scalars** that d | `telemetry` | `boolean` | `true` | Whether anonymous telemetry is enabled; disabled only when explicitly set to `false` | | `providers` | `table` | `{}` | API provider table → [`providers`](#providers) | | `models` | `table` | — | Model alias table → [`models`](#models) | +| `subagent_models` | `table` | — | Subagent role-to-model mapping → [`subagent_models`](#subagent_models) | | `thinking` | `table` | — | Default parameters for Thinking mode → [`thinking`](#thinking) | | `loop_control` | `table` | — | Agent loop control parameters → [`loop_control`](#loop_control) | | `background` | `table` | — | Background task runtime parameters → [`background`](#background) | @@ -143,6 +144,26 @@ max_context_size = 1047576 You can also switch models temporarily without touching the config file — by setting `KIMI_MODEL_*` environment variables, the CLI synthesizes a temporary provider in memory that does not persist after restart. See [Define a model from environment variables](./env-vars.md#define-a-model-from-environment-variables-kimi_model). +## `subagent_models` + +`subagent_models` maps subagent profile names to model aliases, so different roles can use different LLMs. When a subagent is spawned, the model is resolved in this priority order: + +1. Per-invocation `model` parameter on the `Agent` tool (if the parent agent explicitly requests a model) +2. Role-based mapping in `[subagent_models]` (if the profile name has an entry) +3. Parent agent's model (default inheritance) + +When a subagent uses a model that does not support Thinking, the thinking level is automatically disabled to avoid API errors. + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `` | `string` | No | Model alias to use for the given profile; valid names are `coder`, `explore`, and `plan` | + +```toml +[subagent_models] +coder = "gpt-5.2" +explore = "glm-4.7" +``` + ## `thinking` `thinking` sets the global default behavior for Thinking mode. `mode = "off"` forces Thinking off even when the top-level `default_thinking = true`. diff --git a/docs/en/configuration/data-locations.md b/docs/en/configuration/data-locations.md index 9855c46bb..e85ae086c 100644 --- a/docs/en/configuration/data-locations.md +++ b/docs/en/configuration/data-locations.md @@ -30,6 +30,7 @@ $KIMI_CODE_HOME (default: ~/.kimi-code) ├── config.toml # User configuration ├── tui.toml # Terminal UI preferences (including auto-update toggle) ├── AGENTS.md # Global Kimi-specific agent instructions (optional) +├── sysprompt.md # Global system prompt override (optional) ├── mcp.json # User-level MCP server declarations (optional) ├── skills/ # Kimi-specific user-level Skills (optional) ├── plugins/ @@ -62,6 +63,7 @@ Each top-level file under the data root serves a specific purpose; most are mana - **`config.toml`**: the main runtime configuration file, storing user-level settings such as providers, models, and loop control. See [Configuration files](./config-files.md). - **`tui.toml`**: terminal UI client preferences, including `[upgrade].auto_install` (auto-update, on by default). You can disable it in `/settings` or by manually setting `auto_install = false`. - **`AGENTS.md`**: global Kimi-specific agent instructions. This file moves with `KIMI_CODE_HOME`; generic cross-tool instructions can still live under `~/.agents/AGENTS.md`. +- **`sysprompt.md`**: global system prompt override. When present, its contents replace the built-in profile system prompt for every session. Project-level `.kimi-code/sysprompt.md` takes precedence over this global file. See [Agents and Sub-Agents](../customization/agents.md#system-prompt-override). - **`mcp.json`**: user-level MCP server declarations, merged with the project-local `.kimi-code/mcp.json` on startup. See [MCP](../customization/mcp.md). - **`skills/`**: Kimi-specific user-level Skills. This directory moves with `KIMI_CODE_HOME`; generic cross-tool Skills can still live under `~/.agents/skills/`. See [Agent Skills](../customization/skills.md). - **`plugins/installed.json`**: records installed plugins, each plugin's enabled state, and MCP server capability state changes made via `/plugins` or `/plugins mcp disable|enable`. Files installed from local paths or zip URLs are copied to `plugins/managed//`. See [Plugins](../customization/plugins.md). diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md index 4eb0b753b..76d64e6c2 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -41,6 +41,36 @@ If you need a particular type of tool to be permanently unavailable inside sub-a Global Kimi-specific instructions can live at `$KIMI_CODE_HOME/AGENTS.md` (default: `~/.kimi-code/AGENTS.md`). When you relocate the data root with `KIMI_CODE_HOME`, this global instruction file moves with it. Generic cross-tool instructions can still live under `~/.agents/AGENTS.md` in the real OS home, and project-level instructions remain under the project tree, for example `.kimi-code/AGENTS.md` or `AGENTS.md`. +## System Prompt Override + +You can replace the default system prompt (the high-level behavior profile sent to the model at the start of every turn) with your own text. This is useful when you want a consistent persona, coding style, or set of ground rules across all sessions. + +Two override files are supported: + +- **Global**: `$KIMI_CODE_HOME/sysprompt.md` (default: `~/.kimi-code/sysprompt.md`) +- **Project-level**: `.kimi-code/sysprompt.md` in the current working directory + +If a project-level file exists, it takes precedence over the global file. If neither exists, the CLI falls back to its built-in profile system prompt. The file content is used verbatim, so write it as plain Markdown text. + +::: tip Note +`AGENTS.md` is appended as supplementary context; `sysprompt.md` replaces the entire system prompt. You can use both together — for example, put your base persona in `sysprompt.md` and project-specific conventions in `AGENTS.md`. +::: + +## Session-Level Sampling Overrides + +Use the `/spiceup` slash command to override model sampling parameters for the current session. The values take effect immediately, last until the session ends, override any defaults from `config.toml`, and are inherited by every sub-agent spawned in the session. + +The dialog lets you adjust: + +- **Temperature** — `0.0` (deterministic) to `2.0` (very random) +- **Top P** — nucleus-sampling cutoff (`0.0`–`1.0`) +- **Top K** — limit the token pool to the top K candidates +- **Max tokens** — maximum tokens to generate +- **Frequency penalty** — penalize repeated tokens (`-2.0`–`2.0`) +- **Presence penalty** — penalize repeated topics (`-2.0`–`2.0`) + +Leave a field empty to clear that override. When all fields are empty, `/spiceup` removes every session-level sampling override and the model reverts to the values from `config.toml`. + ## Storage Location in the Session Directory Sub-agent runtime state is persisted to the `agents/` subdirectory of the current session directory. Each sub-agent instance has its own directory, which contains a `wire.jsonl` file that records prompts, message history, and final state in chronological order. Background sub-agents also expose their lifecycle status through a `tasks/` subdirectory. diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index 497b4ea76..b98e161ef 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -89,7 +89,7 @@ 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`), and `run_in_background` (defaults to false). Agent tasks have a fixed 30-minute timeout. 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` (a model alias defined in `config.toml` to use for this subagent instead of the inherited model). Agent tasks have a fixed 30-minute timeout. 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`. 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. diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index e0a215f56..a7ab709de 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -84,6 +84,7 @@ timeout = 5 | `telemetry` | `boolean` | `true` | 是否启用匿名遥测;显式设为 `false` 时关闭 | | `providers` | `table` | `{}` | API 供应商表 → [`providers`](#providers) | | `models` | `table` | — | 模型别名表 → [`models`](#models) | +| `subagent_models` | `table` | — | 子 Agent 角色到模型的映射 → [`subagent_models`](#subagent_models) | | `thinking` | `table` | — | Thinking 模式默认参数 → [`thinking`](#thinking) | | `loop_control` | `table` | — | Agent 循环控制参数 → [`loop_control`](#loop_control) | | `background` | `table` | — | 后台任务运行参数 → [`background`](#background) | @@ -143,6 +144,26 @@ max_context_size = 1047576 无需修改配置文件也可以临时切换模型——通过 `KIMI_MODEL_*` 环境变量在内存里合成一个临时供应商,详见[用环境变量定义模型](./env-vars.md#用环境变量定义模型-kimi-model)。 +## `subagent_models` + +`subagent_models` 将子 Agent profile 名称映射到模型别名,让不同角色可以使用不同的 LLM。子 Agent 启动时,模型按以下优先级解析: + +1. `Agent` 工具的 `model` 参数(父 Agent 显式指定模型时) +2. `[subagent_models]` 中的角色映射(该 profile 有配置项时) +3. 父 Agent 的模型(默认继承) + +当子 Agent 使用的模型不支持 Thinking 时,Thinking 级别会自动关闭,以避免 API 报错。 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `` | `string` | 否 | 该 profile 使用的模型别名;有效名称包括 `coder`、`explore`、`plan` | + +```toml +[subagent_models] +coder = "gpt-5.2" +explore = "glm-4.7" +``` + ## `thinking` `thinking` 设置 Thinking 模式的全局默认行为。`mode = "off"` 会强制关闭 Thinking,即使顶层 `default_thinking = true` 也不例外。 diff --git a/docs/zh/configuration/data-locations.md b/docs/zh/configuration/data-locations.md index cb0fa66ce..bf5dc8ab1 100644 --- a/docs/zh/configuration/data-locations.md +++ b/docs/zh/configuration/data-locations.md @@ -30,6 +30,7 @@ $KIMI_CODE_HOME (默认 ~/.kimi-code) ├── config.toml # 用户配置 ├── tui.toml # 终端界面偏好(含自动更新开关) ├── AGENTS.md # 全局 Kimi 专属 Agent 指令(可选) +├── sysprompt.md # 全局系统提示词覆盖(可选) ├── mcp.json # 用户级 MCP server 声明(可选) ├── skills/ # Kimi 专属用户级 Skills(可选) ├── plugins/ @@ -62,6 +63,7 @@ $KIMI_CODE_HOME (默认 ~/.kimi-code) - **`config.toml`**:主运行时配置,存放供应商、模型、循环控制等用户级设置。详见[配置文件](./config-files.md)。 - **`tui.toml`**:终端界面客户端偏好,包括 `[upgrade].auto_install`(自动更新,默认开启)。可在 `/settings` 关闭,或手动设为 `auto_install = false`。 - **`AGENTS.md`**:全局 Kimi 专属 Agent 指令。该文件会随 `KIMI_CODE_HOME` 移动;跨工具通用指令仍可放在 `~/.agents/AGENTS.md`。 +- **`sysprompt.md`**:全局系统提示词覆盖。存在时,其内容会替换每次会话的内置 profile 系统提示词。项目级 `.kimi-code/sysprompt.md` 优先于此全局文件。详见 [Agent 与子 Agent](../customization/agents.md#系统提示词覆盖)。 - **`mcp.json`**:用户级 MCP server 声明,启动时与项目内的 `.kimi-code/mcp.json` 合并加载。详见 [MCP](../customization/mcp.md)。 - **`skills/`**:Kimi 专属用户级 Skills。该目录会随 `KIMI_CODE_HOME` 移动;跨工具通用 Skills 仍可放在 `~/.agents/skills/`。详见 [Agent Skills](../customization/skills.md)。 - **`plugins/installed.json`**:记录已安装的 plugin、每个 plugin 的启用状态,以及通过 `/plugins` 或 `/plugins mcp disable|enable` 修改的 MCP server 能力状态。本地路径和 zip URL 安装的文件会复制到 `plugins/managed//`。详见 [Plugins](../customization/plugins.md)。 diff --git a/docs/zh/customization/agents.md b/docs/zh/customization/agents.md index 5b754ada1..d63320b98 100644 --- a/docs/zh/customization/agents.md +++ b/docs/zh/customization/agents.md @@ -41,6 +41,36 @@ Kimi Code CLI 内置三种子 Agent,开箱即用,分别面向不同任务形 全局 Kimi 专属指令可放在 `$KIMI_CODE_HOME/AGENTS.md`(默认:`~/.kimi-code/AGENTS.md`)。当你用 `KIMI_CODE_HOME` 移动数据根时,这份全局指令文件也会一起移动。跨工具通用指令仍可放在真实 OS home 下的 `~/.agents/AGENTS.md`,项目级指令仍放在项目目录中,例如 `.kimi-code/AGENTS.md` 或 `AGENTS.md`。 +## 系统提示词覆盖 + +你可以用自己的文本替换默认的系统提示词(每轮开始时发送给模型的高层行为画像)。当你希望所有会话都保持一致的人格、编码风格或基本规则时,这会很有用。 + +支持两类覆盖文件: + +- **全局**:`$KIMI_CODE_HOME/sysprompt.md`(默认:`~/.kimi-code/sysprompt.md`) +- **项目级**:当前工作目录下的 `.kimi-code/sysprompt.md` + +如果项目级文件存在,它会优先于全局文件;如果都不存在,CLI 会回退到内置的 profile 系统提示词。文件内容会原样使用,因此直接以普通 Markdown 文本书写即可。 + +::: tip 提示 +`AGENTS.md` 是作为补充上下文追加的;`sysprompt.md` 会替换整个系统提示词。两者可以一起使用——例如,把基础人格放在 `sysprompt.md` 里,把项目专属约定放在 `AGENTS.md` 里。 +::: + +## 会话级采样参数覆盖 + +使用 `/spiceup` 斜杠命令覆盖当前会话的模型采样参数。这些值会立即生效,持续到会话结束,覆盖 `config.toml` 中的默认值,并会传递给该会话中派生的所有子 Agent。 + +弹窗可调整以下参数: + +- **Temperature(温度)** — `0.0`(确定性最高)到 `2.0`(非常随机) +- **Top P** — 核采样截断(`0.0`–`1.0`) +- **Top K** — 只从概率最高的 K 个候选 token 中采样 +- **Max tokens(最大 token 数)** — 模型最多生成的 token 数 +- **Frequency penalty(频率惩罚)** — 惩罚重复 token(`-2.0`–`2.0`) +- **Presence penalty(存在惩罚)** — 惩罚重复主题(`-2.0`–`2.0`) + +将某个字段留空即可清除该参数的覆盖。当所有字段都为空时,`/spiceup` 会移除全部会话级采样覆盖,模型将回退到 `config.toml` 中的配置。 + ## 会话目录中的存储位置 子 Agent 的运行状态持久化到当前会话目录的 `agents/` 子目录下,每个子 Agent 实例对应一个独立目录,其中包含按时间顺序记录提示词、消息历史与最终状态的 `wire.jsonl` 文件。后台子 Agent 还会通过 `tasks/` 子目录暴露生命周期状态。 diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index eef9fc7fe..e9baf6b91 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -89,7 +89,7 @@ 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)。Agent 任务使用固定 30 分钟超时。前台模式下父 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`(在 `config.toml` 中定义的模型别名,用于代替继承的模型)。Agent 任务使用固定 30 分钟超时。前台模式下父 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`。不传 `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)` 这类参数模式。 diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index 8e89aaf3a..89a1c551f 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -23,6 +23,7 @@ export class ConfigState { private _profileName: string | undefined; private _thinkingLevel: ThinkingEffort = 'off'; private _systemPrompt: string = ''; + private _generationKwargs: Record | undefined; constructor(protected readonly agent: Agent) { this._cwd = agent.kaos.getcwd(); @@ -59,6 +60,9 @@ export class ConfigState { if (changed.systemPrompt !== undefined) { this._systemPrompt = changed.systemPrompt; } + if (changed.generationKwargs !== undefined) { + this._generationKwargs = changed.generationKwargs; + } if (this.hasProvider && (changed.cwd !== undefined || changed.modelAlias)) { this.agent.tools.initializeBuiltinTools(); } @@ -75,6 +79,7 @@ export class ConfigState { profileName: this.profileName, thinkingLevel: this.thinkingLevel, systemPrompt: this.systemPrompt, + generationKwargs: this._generationKwargs, }; } @@ -104,8 +109,23 @@ export class ConfigState { // - withThinking: preserve thinking during compaction (#464) // - sampling params: KIMI_MODEL_TEMPERATURE / KIMI_MODEL_TOP_P // - thinking.keep: KIMI_MODEL_THINKING_KEEP (only while thinking is on) - const provider = createProvider(this.providerConfig).withThinking(this.thinkingLevel); - return applyKimiEnvThinkingKeep(applyKimiEnvSamplingParams(provider), this.thinkingLevel); + const baseConfig = this.providerConfig; + const sessionKwargs = this._generationKwargs; + let provider: ChatProvider; + if (sessionKwargs !== undefined && Object.keys(sessionKwargs).length > 0) { + const mergedConfig = { + ...baseConfig, + generationKwargs: { + ...(baseConfig as { generationKwargs?: Record }).generationKwargs, + ...sessionKwargs, + }, + } as ProviderConfig; + provider = createProvider(mergedConfig).withThinking(this.thinkingLevel); + } else { + provider = createProvider(baseConfig).withThinking(this.thinkingLevel); + } + provider = applyKimiEnvSamplingParams(provider); + return applyKimiEnvThinkingKeep(provider, this.thinkingLevel); } get model(): string { @@ -142,6 +162,10 @@ export class ConfigState { return this._systemPrompt; } + get generationKwargs(): Record | undefined { + return this._generationKwargs; + } + get modelCapabilities(): ModelCapability { return this.tryResolvedProviderConfig()?.modelCapabilities ?? UNKNOWN_CAPABILITY; } diff --git a/packages/agent-core/src/agent/config/types.ts b/packages/agent-core/src/agent/config/types.ts index fc7a785f5..fc307bb89 100644 --- a/packages/agent-core/src/agent/config/types.ts +++ b/packages/agent-core/src/agent/config/types.ts @@ -8,6 +8,7 @@ export interface AgentConfigData { profileName?: string; thinkingLevel: string; systemPrompt: string; + generationKwargs?: Record; } export type AgentConfigUpdateData = Partial<{ @@ -16,4 +17,5 @@ export type AgentConfigUpdateData = Partial<{ profileName: string; thinkingLevel: string; systemPrompt: string; + generationKwargs: Record | undefined; }>; diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 1b90276f9..c27c5186e 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -283,13 +283,15 @@ export class Agent { } useProfile(profile: ResolvedAgentProfile, context?: PreparedSystemPromptContext): void { - const systemPrompt = profile.systemPrompt({ - osEnv: this.kaos.osEnv, - cwd: this.config.cwd, - skills: this.skills?.registry, - cwdListing: context?.cwdListing, - agentsMd: context?.agentsMd, - }); + const systemPrompt = + context?.systemPromptOverride ?? + profile.systemPrompt({ + osEnv: this.kaos.osEnv, + cwd: this.config.cwd, + skills: this.skills?.registry, + cwdListing: context?.cwdListing, + agentsMd: context?.agentsMd, + }); this.config.update({ profileName: profile.name, systemPrompt }); this.tools.setActiveTools(profile.tools); } @@ -357,6 +359,9 @@ export class Agent { providerName: resolved?.providerName, }; }, + setGenerationKwargs: (payload) => { + this.config.update({ generationKwargs: payload.kwargs }); + }, getModel: () => { return this.config.modelAlias ?? ''; }, diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 094239b73..bf867b8ba 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -205,11 +205,15 @@ export const KimiConfigSchema = z.object({ background: BackgroundConfigSchema.optional(), experimental: ExperimentalConfigSchema.optional(), telemetry: z.boolean().optional(), + subagentModels: z.record(z.string(), z.string()).optional(), raw: z.record(z.string(), z.unknown()).optional(), }); export type KimiConfig = z.infer; +/** Maps subagent profile names (coder, explore, plan) to model aliases. */ +export type SubagentModels = Record; + const ProviderConfigPatchSchema = ProviderConfigSchema.partial(); const ModelAliasPatchSchema = ModelAliasSchema.partial(); const ThinkingConfigPatchSchema = ThinkingConfigSchema.partial(); @@ -244,6 +248,7 @@ export const KimiConfigPatchSchema = z background: BackgroundConfigPatchSchema.optional(), experimental: ExperimentalConfigPatchSchema.optional(), telemetry: z.boolean().optional(), + subagentModels: z.record(z.string(), z.string()).optional(), }) .strict(); diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index 172e97cfc..08e766aaa 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -314,6 +314,10 @@ export function transformTomlData(data: Record): Record — keys are profile names, values are model aliases. + // No per-entry transform needed; just clone the strings through. + result[targetKey] = cloneRecord(value); } else if (!isPlainObject(value)) { result[targetKey] = value; } @@ -486,6 +490,7 @@ export function configToTomlData(config: KimiConfig): Record { setSection(out, 'background', config.background, backgroundToToml); setSection(out, 'experimental', config.experimental, experimentalToToml); setSection(out, 'permission', config.permission, permissionToToml); + setSection(out, 'subagent_models', config.subagentModels, subagentModelsToToml); setHooks(out, config.hooks); return out; @@ -657,6 +662,17 @@ function experimentalToToml( return out; } +function subagentModelsToToml( + subagentModels: Record, + _raw: unknown, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(subagentModels)) { + setDefined(out, key, value); + } + return out; +} + function setHooks(out: Record, hooks: readonly HookDefConfig[] | undefined): void { if (hooks === undefined) { delete out['hooks']; diff --git a/packages/agent-core/src/profile/context.ts b/packages/agent-core/src/profile/context.ts index e03b8469c..c4319fa8a 100644 --- a/packages/agent-core/src/profile/context.ts +++ b/packages/agent-core/src/profile/context.ts @@ -9,17 +9,45 @@ const AGENTS_MD_MAX_BYTES = 32 * 1024; const S_IFMT = 0o170000; const S_IFREG = 0o100000; -export type PreparedSystemPromptContext = Pick; +export type PreparedSystemPromptContext = Pick & { + systemPromptOverride?: string | undefined; +}; export async function prepareSystemPromptContext( kaos: Kaos, brandHome?: string, ): Promise { - const [cwdListing, agentsMd] = await Promise.all([ + const [cwdListing, agentsMd, systemPromptOverride] = await Promise.all([ listDirectory(kaos), loadAgentsMd(kaos, brandHome), + loadSystemPromptOverride(kaos, brandHome), ]); - return { cwdListing, agentsMd }; + return { cwdListing, agentsMd, systemPromptOverride }; +} + +/** + * Load a user-provided system prompt override. + * + * Looks for `.kimi-code/sysprompt.md` in the current working directory first, + * then falls back to `/sysprompt.md` (default `~/.kimi-code/sysprompt.md`). + * A project-level file fully replaces a global file; if neither exists the + * agent falls back to the profile's rendered system prompt. + */ +export async function loadSystemPromptOverride( + kaos: Kaos, + brandHome?: string, +): Promise { + const workDir = kaos.getcwd(); + const realHome = kaos.gethome(); + const brandDir = brandHome ?? join(realHome, '.kimi-code'); + + const project = await readAgentFile(kaos, join(workDir, '.kimi-code', 'sysprompt.md')); + if (project !== undefined) return project.content; + + const global = await readAgentFile(kaos, join(brandDir, 'sysprompt.md')); + if (global !== undefined) return global.content; + + return undefined; } export async function loadAgentsMd(kaos: Kaos, brandHome?: string): Promise { diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index b080802ee..1180f91e5 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -164,6 +164,9 @@ export interface SetModelResult { readonly model: string; readonly providerName?: string | undefined; } +export interface SetGenerationKwargsPayload { + readonly kwargs: Record; +} export interface CancelPlanPayload { readonly id?: string; } @@ -313,6 +316,7 @@ export interface AgentAPI { setThinking: (payload: SetThinkingPayload) => void; setPermission: (payload: SetPermissionPayload) => void; setModel: (payload: SetModelPayload) => SetModelResult; + setGenerationKwargs: (payload: SetGenerationKwargsPayload) => void; getModel: (payload: EmptyPayload) => string; enterPlan: (payload: EmptyPayload) => void; cancelPlan: (payload: CancelPlanPayload) => void; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 204715da6..1f06d5645 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -80,6 +80,7 @@ import type { SessionSummary, SetActiveToolsPayload, SetKimiConfigPayload, + SetGenerationKwargsPayload, SetModelPayload, SetModelResult, SetPermissionPayload, @@ -531,6 +532,10 @@ export class KimiCore implements PromisableMethods { return this.sessionApi(sessionId).setModel(payload); } + setGenerationKwargs({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).setGenerationKwargs(payload); + } + setThinking({ sessionId, ...payload }: SessionAgentPayload) { return this.sessionApi(sessionId).setThinking(payload); } diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index f37001142..c5cbcc2d2 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -1,6 +1,6 @@ import type { Logger } from '#/logging/types'; import type { ProviderConfig as KosongProviderConfig, ModelCapability, ProviderRequestAuth } from '@moonshot-ai/kosong'; -import { APIStatusError, createProvider, UNKNOWN_CAPABILITY } from '@moonshot-ai/kosong'; +import { APIStatusError, createProvider, getAnthropicModelCapability, UNKNOWN_CAPABILITY } from '@moonshot-ai/kosong'; import type { KimiConfig, ModelAlias, OAuthRef, ProviderConfig } from '../config'; import { ErrorCodes, isKimiError, KimiError } from '../errors'; @@ -104,6 +104,15 @@ export class ProviderManager implements ModelProvider { ); } + const declaredThinking = (alias.capabilities ?? []).some( + (c) => c.trim().toLowerCase() === 'thinking', + ); + const thinkingSupported = + declaredThinking || + (providerConfig.type === 'anthropic' + ? getAnthropicModelCapability(alias.model).thinking + : undefined); + const provider = toKosongProviderConfig( providerConfig, alias.model, @@ -112,6 +121,7 @@ export class ProviderManager implements ModelProvider { alias.reasoningKey, this.options.promptCacheKey, alias.adaptiveThinking, + thinkingSupported, ); return { @@ -222,6 +232,7 @@ function toKosongProviderConfig( reasoningKey: string | undefined, promptCacheKey: string | undefined, adaptiveThinking: boolean | undefined, + thinkingSupported: boolean | undefined, ): KosongProviderConfig { switch (provider.type) { case 'anthropic': @@ -232,6 +243,7 @@ function toKosongProviderConfig( apiKey: providerApiKey(provider), ...(maxOutputSize !== undefined ? { defaultMaxTokens: maxOutputSize } : {}), ...(adaptiveThinking !== undefined ? { adaptiveThinking } : {}), + ...(thinkingSupported !== undefined ? { thinkingSupported } : {}), ...defaultHeadersField(provider.customHeaders), }; case 'openai': diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts index fe81014dc..ee0e325de 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -18,6 +18,7 @@ import type { RegisterToolPayload, SessionAPI, SetActiveToolsPayload, + SetGenerationKwargsPayload, SetModelPayload, SetPermissionPayload, SetThinkingPayload, @@ -114,6 +115,10 @@ export class SessionAPIImpl implements PromisableMethods { return (await this.getAgent(agentId)).setModel(payload); } + async setGenerationKwargs({ agentId, ...payload }: AgentScopedPayload) { + return (await this.getAgent(agentId)).setGenerationKwargs(payload); + } + async setThinking({ agentId, ...payload }: AgentScopedPayload) { return (await this.getAgent(agentId)).setThinking(payload); } diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index b47e1cd68..06e3ad369 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -81,6 +81,7 @@ export interface RunSubagentOptions { export interface SpawnSubagentOptions extends RunSubagentOptions { readonly profileName: string; + readonly model?: string; readonly swarmItem?: string; } @@ -119,10 +120,11 @@ export class SessionSubagentHost { { type: 'sub', generate: parent.rawGenerate }, { parentAgentId: this.ownerAgentId, swarmItem: options.swarmItem }, ); + const effectiveModel = this.resolveSubagentModel(parent, profile.name, options.model); const completion = this.runWithActiveChild(id, options, async (runOptions) => { - this.emitSubagentSpawned(parent, id, profile.name, runOptions); + this.emitSubagentSpawned(parent, id, profile.name, runOptions, effectiveModel); try { - await this.configureChild(parent, agent, profile); + await this.configureChild(parent, agent, profile, effectiveModel); return await this.runPromptTurn(parent, id, agent, profile.name, runOptions); } catch (error) { this.emitSubagentFailed(parent, id, runOptions, error); @@ -140,10 +142,13 @@ export class SessionSubagentHost { async resume(agentId: string, options: RunSubagentOptions): Promise { options.signal.throwIfAborted(); const { parent, child, profileName } = await this.ensureIdleSubagent(agentId); + const effectiveModel = this.resolveSubagentModel(parent, profileName); const completion = this.runWithActiveChild(agentId, options, async (runOptions) => { - this.emitSubagentSpawned(parent, agentId, profileName, runOptions); + this.emitSubagentSpawned(parent, agentId, profileName, runOptions, effectiveModel); try { - child.config.update({ modelAlias: parent.config.modelAlias }); + child.config.update({ + ...(effectiveModel !== undefined ? { modelAlias: effectiveModel } : {}), + }); return await this.runPromptTurn(parent, agentId, child, profileName, runOptions); } catch (error) { this.emitSubagentFailed(parent, agentId, runOptions, error); @@ -159,7 +164,9 @@ export class SessionSubagentHost { const completion = this.runWithActiveChild(agentId, options, async (runOptions) => { try { runOptions.signal.throwIfAborted(); - child.config.update({ modelAlias: parent.config.modelAlias }); + // Preserve the child's current model — it was already resolved during the + // initial spawn (via [subagent_models] config or per-invocation override). + // Resetting to parent.config.modelAlias would lose a role-based model. this.emitSubagentStarted(parent, agentId); const turnId = child.turn.retry('agent-host'); if (turnId === null) { @@ -223,6 +230,7 @@ export class SessionSubagentHost { modelAlias: parent.config.modelAlias, thinkingLevel: parent.config.thinkingLevel, systemPrompt: parent.config.systemPrompt, + generationKwargs: parent.config.generationKwargs, }); child.tools.copyLoopToolsFrom(parent.tools); child.context.useProjectedHistoryFrom(parent.context); @@ -354,12 +362,24 @@ export class SessionSubagentHost { parent: Agent, child: Agent, profile: ResolvedAgentProfile, + effectiveModel?: string, ): Promise { - // A subagent always inherits the parent agent's model. + const targetModel = effectiveModel ?? parent.config.modelAlias; + let thinkingLevel = parent.config.thinkingLevel; + + // If the subagent is using a different model from its parent, do not + // inherit the parent's thinking/reasoning level. Different models have + // different thinking support (e.g. grok-build-0.1 rejects reasoningEffort), + // and the safest default for a model switch is 'off'. + if (targetModel !== undefined && targetModel !== parent.config.modelAlias) { + thinkingLevel = 'off'; + } + child.config.update({ cwd: parent.config.cwd, - modelAlias: parent.config.modelAlias, - thinkingLevel: parent.config.thinkingLevel, + ...(effectiveModel !== undefined ? { modelAlias: effectiveModel } : {}), + thinkingLevel, + generationKwargs: parent.config.generationKwargs, }); const context = await prepareSystemPromptContext( @@ -409,11 +429,21 @@ export class SessionSubagentHost { .catch(() => {}); } + private resolveSubagentModel( + parent: Agent, + profileName: string, + modelOverride?: string, + ): string | undefined { + const subagentModels = this.session.options.config?.subagentModels; + return modelOverride ?? subagentModels?.[profileName] ?? parent.config.modelAlias; + } + private emitSubagentSpawned( parent: Agent, childId: string, profileName: string, options: RunSubagentOptions, + modelAlias?: string, ): void { parent.emitEvent({ type: 'subagent.spawned', @@ -425,6 +455,7 @@ export class SessionSubagentHost { description: options.description, swarmIndex: options.swarmIndex, runInBackground: options.runInBackground, + modelAlias, }); parent.telemetry.track('subagent_created', { subagent_name: profileName, diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.md b/packages/agent-core/src/tools/builtin/collaboration/agent.md index 6ff3f26a4..4ae6ca847 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.md +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.md @@ -6,6 +6,11 @@ Writing the prompt: - Investigations (figure out X, find why Y): give the question, not prescribed steps — fixed steps become dead weight when the premise is wrong. - Do not delegate understanding. If the task hinges on a file path or line number, find it yourself first and write it into the prompt. +Model selection: +- By default, subagents inherit the parent agent's model. +- The user can configure per-role model aliases in config.toml under `[subagent_models]` (e.g., `coder = "gpt-5.2"`). When set, that role always uses the configured model. +- You can also pass `model` to override the model for a single invocation. The value must be a model alias defined in config.toml. This takes highest priority. Only use this when the user explicitly asks you to use a specific model for a task. + Usage notes: - When the task continues earlier work a subagent already did, prefer resuming that agent (pass its `resume` id) over spawning a fresh instance — the resumed agent keeps its prior context. - A subagent's result is only visible to you, not to the user. When the user needs to see what a subagent produced, summarize the relevant parts yourself in your own reply. diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.ts b/packages/agent-core/src/tools/builtin/collaboration/agent.ts index 29de3ab7d..3dad05da1 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.ts @@ -75,6 +75,12 @@ export const AgentToolInputSchema = z.preprocess( .string() .optional() .describe('Optional agent ID to resume instead of creating a new instance'), + model: z + .string() + .optional() + .describe( + 'Model alias from config.toml to use for this subagent. Overrides the default model for the subagent role. Only used when spawning a new agent, not when resuming.', + ), run_in_background: z .boolean() .optional() @@ -195,6 +201,7 @@ export class AgentTool implements BuiltinTool { prompt: args.prompt, description: args.description, runInBackground, + model: args.model, signal: backgroundController?.signal ?? foregroundDeadline?.signal ?? signal, }; diff --git a/packages/agent-core/test/agent/config.test.ts b/packages/agent-core/test/agent/config.test.ts index 5fc9edf88..2740f5c77 100644 --- a/packages/agent-core/test/agent/config.test.ts +++ b/packages/agent-core/test/agent/config.test.ts @@ -82,6 +82,25 @@ describe('Agent config', () => { await ctx.expectResumeMatches(); }); + it('useProfile uses a system prompt override when one is provided', async () => { + const ctx = testAgent(); + ctx.configure(); + const profile: ResolvedAgentProfile = { + name: 'test-profile', + systemPrompt: () => 'Profile system prompt.', + tools: ['Bash'], + }; + + ctx.agent.useProfile(profile, { systemPromptOverride: 'Override system prompt.' }); + + expect(ctx.newEvents()).toMatchInlineSnapshot(` + [wire] config.update { "profileName": "test-profile", "systemPrompt": "Override system prompt.", "time": "