diff --git a/.agents/skills/agent-core-dev/config.md b/.agents/skills/agent-core-dev/config.md index 90e1dc1024..d7d6b12fce 100644 --- a/.agents/skills/agent-core-dev/config.md +++ b/.agents/skills/agent-core-dev/config.md @@ -101,7 +101,7 @@ pass `ConfigTarget.Memory` for a per-run override that is never written to disk. - `src/kosong/model/thinking.ts` (owner domain, not `config`) — the `resolveThinkingEffort` helper and the authoritative `ThinkingConfig` type (the `thinking` section itself registers from `src/app/kosongConfig/configSection.ts`). - `src/app/config/configPure.ts` — `isPlainObject`, `deepMerge`, `omitUndefined`, `describeUnknownError`. -A domain that owns a section keeps the schema in its own `configSection.ts` (e.g. `src/app/flag/flag.ts` for `experimental`, `src/agent/loop/configSection.ts` for `loopControl`). Exception: kosong-owned sections (`providers`, `models`, `thinking`) — kosong is a pure, persistence-free abstraction layer that defines only the types (`src/kosong/{provider,model}`); the section constants, the zod schemas (re-derived from those types and compile-time pinned via `AssertExact, Type>>`, see `_base/utils/typeEquality.ts`), the registrations, env bindings, and TOML transforms all live in the persistence wrapper `src/app/kosongConfig/configSection.ts`. (`modelCatalog` and `secondaryModel` have no kosong-side type at all — their sections are fully self-contained in `app/kosongConfig`, types derived from the schemas.) A cross-section env overlay (e.g. the `KIMI_MODEL_*` synthesis) lives in the wrapper too (`src/app/kosongConfig/envOverlay.ts`; the `[secondary_model]` derived-entry synthesis in `secondaryModelOverlay.ts`) and is registered via module-level `registerConfigOverlay`. The two-way sync between config sections and kosong's in-memory registries is owned by `IKosongConfigService` (`src/app/kosongConfig/kosongConfigService.ts`). +A domain that owns a section keeps the schema in its own `configSection.ts` (e.g. `src/app/flag/flag.ts` for `experimental`, `src/agent/loop/configSection.ts` for `loopControl`). Exception: kosong-owned sections (`providers`, `models`, `thinking`) — kosong is a pure, persistence-free abstraction layer that defines only the types (`src/kosong/{provider,model}`); the section constants, the zod schemas (re-derived from those types and compile-time pinned via `AssertExact, Type>>`, see `_base/utils/typeEquality.ts`), the registrations, env bindings, and TOML transforms all live in the persistence wrapper `src/app/kosongConfig/configSection.ts`. (`modelCatalog` has no kosong-side type at all — its section is fully self-contained in `app/kosongConfig`, types derived from the schema.) A cross-section env overlay (e.g. the `KIMI_MODEL_*` synthesis in `src/app/kosongConfig/envOverlay.ts`) lives in the wrapper too and is registered via module-level `registerConfigOverlay`. The session subagent domain owns two sections in `src/session/subagent/configSection.ts`: `[subagent]` (`timeout_ms` on disk) and `[secondary_model]` (`default_model` plus the `[secondary_model.models]` pool, with a lone legacy v1 `model` key honored as a fallback default below `default_model`), with the legacy `[subagent]` pool keys declared as deprecations; neither carries a cross-section overlay. Cross-field pool validation (default present / in-pool / every key resolvable) runs at session creation in `subagentModelsValidationService.ts`, not in the schema. The two-way sync between config sections and kosong's in-memory registries is owned by `IKosongConfigService` (`src/app/kosongConfig/kosongConfigService.ts`). ## Scope diff --git a/.changeset/remove-secondary-model-sdk.md b/.changeset/remove-secondary-model-sdk.md new file mode 100644 index 0000000000..837e4b8113 --- /dev/null +++ b/.changeset/remove-secondary-model-sdk.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": minor +--- + +Remove the secondary-model session API `Session.applyPersistedSecondaryModel`; subagent model selection is configured via `[secondary_model]` in config.toml instead. The `SECONDARY_DERIVED_MODEL_ALIAS` export stays (the v1 engine still synthesizes the entry at runtime, so hosts keep filtering it out of model pickers), and the SDK now also exports `PRIMARY_SUBAGENT_MODEL_CHOICE`, the v2 subagent model pool's reserved `primary` key. diff --git a/.changeset/subagent-model-pool.md b/.changeset/subagent-model-pool.md new file mode 100644 index 0000000000..9a59ab9d56 --- /dev/null +++ b/.changeset/subagent-model-pool.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add a configurable model pool for spawned subagents behind the `secondary-model` experiment (`KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`, or the master flag): with the experiment on, the `/secondary-model` command or the `[secondary_model]` section in config.toml sets a default model or a small named pool that the main agent picks from per spawn. A lone legacy `model` key in the same section keeps working as the fallback default. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index a3a0f9999d..d54b291c22 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -1,8 +1,8 @@ import { effectiveModelAlias, + PRIMARY_SUBAGENT_MODEL_CHOICE, SECONDARY_DERIVED_MODEL_ALIAS, type ExperimentalFeatureState, - type KimiConfig, type ModelAlias, type PermissionMode, type Session, @@ -269,6 +269,15 @@ export async function handleSecondaryModelCommand(host: SlashCommandHost, args: const alias = args.trim(); await refreshModelsForPicker(host); const models = pickerModelsForHost(host); + // The pool reserves `primary` as the symbolic "caller's own model" choice — + // a user alias with that name can never be the subagent default. + delete models[PRIMARY_SUBAGENT_MODEL_CHOICE]; + if (alias === PRIMARY_SUBAGENT_MODEL_CHOICE) { + host.showError( + `"${PRIMARY_SUBAGENT_MODEL_CHOICE}" is reserved by the subagent model pool (it always binds the caller's own model) — rename the [models] alias to use it here.`, + ); + return; + } if (Object.keys(models).length === 0) { host.showNotice( 'No models configured', @@ -281,7 +290,10 @@ export async function handleSecondaryModelCommand(host: SlashCommandHost, args: return; } const secondary = (await host.harness.getConfig()).secondaryModel; - showSecondaryModelPicker(host, models, secondary?.model ?? '', secondary?.defaultEffort, alias); + // The v2 engine honors a lone legacy `model` key as the fallback pool + // default — reflect it as the picker's current value. + const current = secondary?.defaultModel ?? secondary?.model ?? ''; + showSecondaryModelPicker(host, models, current, alias.length > 0 ? alias : undefined); } export async function handleEffortCommand(host: SlashCommandHost, args: string): Promise { @@ -427,8 +439,8 @@ async function applyEditorChoice(host: SlashCommandHost, value: string): Promise /** * 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. + * `__secondary__` derived entry — a runtime artifact of the v1 engine's + * `[secondary_model]` recipe that must never be selectable as a model. */ function pickerModelsForHost(host: SlashCommandHost): Record { return Object.fromEntries( @@ -604,14 +616,13 @@ async function persistModelSelection( } // --------------------------------------------------------------------------- -// Secondary model (`/secondary_model`) +// Secondary model (`/secondary-model`) — persists `[secondary_model] default_model` // --------------------------------------------------------------------------- function showSecondaryModelPicker( host: SlashCommandHost, models: Record, currentValue: string, - currentEffort: string | undefined, selectedValue?: string, ): void { host.mountEditorReplacement( @@ -619,11 +630,14 @@ function showSecondaryModelPicker( models, currentValue, selectedValue, - currentThinkingEffort: currentEffort ?? 'off', + currentThinkingEffort: 'off', + // Subagent pool bindings carry no explicit thinking level, so the picker + // hides the Thinking footer instead of offering a no-op choice. + thinkingControl: false, title: ' Select a secondary model (subagents)', - onSelect: ({ alias, thinking }) => { + onSelect: ({ alias }) => { host.restoreEditor(); - void performSecondaryModelSwitch(host, alias, thinking); + void performSecondaryModelSave(host, alias); }, onCancel: () => { host.restoreEditor(); @@ -633,65 +647,32 @@ function showSecondaryModelPicker( } /** - * 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. + * Persists `[secondary_model] default_model`. When a + * `[secondary_model.models]` pool exists and does not list the alias yet, the + * alias is added with an empty description — the engine requires the default + * to be a pool key. Without a pool the default alone forms an implicit + * single-entry pool, so nothing else is written. No live-apply step: the + * engine resolves the pool per spawn, so the next subagent dispatch picks the + * new value up on its own. */ -async function performSecondaryModelSwitch( - host: SlashCommandHost, - alias: string, - effort: ThinkingEffort, -): Promise { +async function performSecondaryModelSave(host: SlashCommandHost, alias: string): Promise { const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]); - let updatedConfig: KimiConfig; try { - updatedConfig = await host.harness.setConfig({ - secondaryModel: { model: alias, defaultEffort: effort }, - }); + const config = await host.harness.getConfig({ reload: true }); + const existing = config.secondaryModel?.models; + const patch: { defaultModel: string; models?: Record } = { + defaultModel: alias, + }; + if (existing !== undefined) { + patch.models = { ...existing, [alias]: existing[alias] ?? '' }; + } + await host.harness.setConfig({ secondaryModel: patch }); } 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}.`, + `Secondary model set to ${displayName}. Newly spawned subagents will use it by default.`, 'success', ); } diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index b36951c784..3e1b1f0c65 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -440,7 +440,7 @@ async function handleBuiltInSlashCommand( case 'model': await handleModelCommand(host, args); return; - case 'secondary_model': + case 'secondary-model': await handleSecondaryModelCommand(host, args); return; case 'effort': diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index dbfbddfcb2..61ec07b911 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -6,10 +6,12 @@ import { } from '@moonshot-ai/kimi-code-oauth'; import { applyCatalogProvider, + cascadeSubagentModelPool, catalogProviderModels, CatalogFetchError, DEFAULT_CATALOG_URL, resolveCatalogImport, + SECONDARY_DERIVED_MODEL_ALIAS, type Catalog, type ThinkingEffort, } from '@moonshot-ai/kimi-code-sdk'; @@ -231,6 +233,10 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { // entered. The model selector that follows is just a convenience to pick the // default model; ESC leaves the provider in place without a default selection. const existingConfig = await host.harness.getConfig(); + const poolSnapshot = + existingConfig.providers[providerId] !== undefined + ? existingConfig.secondaryModel + : undefined; if (existingConfig.providers[providerId] !== undefined) { await host.harness.removeProvider(providerId); } @@ -251,6 +257,16 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { models: config.models, }); + // removeProvider cascaded the subagent pool against a model table where + // every `${providerId}/...` alias was absent; restore the entries that + // survived the re-add (aliases the catalog genuinely dropped stay dropped). + if (poolSnapshot !== undefined) { + const restored = cascadeSubagentModelPool(poolSnapshot, config.models ?? {}); + if (restored !== null) { + await host.harness.setConfig({ secondaryModel: restored ?? poolSnapshot }); + } + } + await host.authFlow.refreshConfigAfterLogin(); host.track('connect', { provider: providerId, method: 'catalog' }); host.showStatus(`Provider added: ${entry.name ?? providerId}`); @@ -263,8 +279,11 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { // Build a merged model dictionary that includes existing models plus the // newly-persisted provider's models, so the tabbed selector shows every // provider's tab (the new provider's tab starts active via initialTabId). + // The v1 runtime may carry the synthesized `__secondary__` derived entry — + // never selectable in a picker. const stateModels = await host.harness.getConfig().then((c) => c.models ?? {}); const mergedModels = { ...stateModels }; + delete mergedModels[SECONDARY_DERIVED_MODEL_ALIAS]; const selector = new TabbedModelSelectorComponent({ models: mergedModels, @@ -356,8 +375,10 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise ); // Offer the model selector so the user can pick a default, just like the - // catalog (known-provider) flow. - const stateModels = await host.harness.getConfig().then((c) => c.models ?? {}); + // catalog (known-provider) flow. Copy without the v1-synthesized + // `__secondary__` derived entry — never selectable in a picker. + const stateModels = { ...(await host.harness.getConfig().then((c) => c.models ?? {})) }; + delete stateModels[SECONDARY_DERIVED_MODEL_ALIAS]; const firstNewAlias = Object.keys(stateModels).find((a) => addedProviderIds.some((pid) => a.startsWith(`${pid}/`)), ); diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 48b57aa3f8..d87e74b75d 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -185,8 +185,8 @@ export const BUILTIN_SLASH_COMMANDS = [ availability: 'always', }, { - name: 'secondary_model', - aliases: [], + name: 'secondary-model', + aliases: ['subagent-model'], description: 'Configure the secondary model for subagents', priority: 90, availability: 'always', 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 0299c6fde0..2532f14a2d 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -80,6 +80,9 @@ export interface ModelSelectorOptions { * line; wraps instead of truncating when it exceeds the width (e.g. the * mid-conversation switch cost notice). */ readonly warning?: string; + /** Set to false to hide the Thinking footer and disable ←/→ effort + * switching — for pickers whose selection carries no thinking level. */ + readonly thinkingControl?: boolean; readonly onSelect: (selection: ModelSelection) => void; /** When provided, Alt+S invokes this instead of onSelect — used to apply the * choice to the current session only, without persisting it as the default. */ @@ -225,7 +228,10 @@ export class ModelSelectorComponent extends Container implements Focusable { } // Left/Right move the active thinking effort within the model's segments. - if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) { + if ( + this.opts.thinkingControl !== false && + (matchesKey(data, Key.left) || matchesKey(data, Key.right)) + ) { const selected = this.selectedChoice(); if (selected !== undefined) { const segments = segmentsFor(selected.model); @@ -352,13 +358,13 @@ export class ModelSelectorComponent extends Container implements Focusable { lines.push(''); const selected = this.selectedChoice(); - if (selected !== undefined) { + if (selected !== undefined && this.opts.thinkingControl !== false) { const canSwitch = segmentsFor(selected.model).length > 1; const thinkingHeader = canSwitch ? ' Thinking (←→ to switch)' : ' Thinking'; lines.push(currentTheme.fg('textMuted', thinkingHeader)); lines.push(this.renderThinkingControl(selected)); + lines.push(''); } - lines.push(''); lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width)); } 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 d94de3b06d..9726ad4832 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 @@ -41,15 +41,17 @@ export interface TabbedModelSelectorOptions { 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). */ + * title line. */ readonly title?: string; /** When set, the tab for this provider id is initially active instead of the * tab derived from `currentValue`. */ readonly initialTabId?: string; - /** Forwarded to each inner selector; when set, warning-colored lines are - * rendered directly below the key-hint line, wrapping as needed (e.g. the - * mid-conversation switch cost notice). */ + /** When set, warning-colored lines are rendered directly below the key-hint + * line, wrapping as needed (e.g. the mid-conversation switch cost notice). */ readonly warning?: string; + /** Forwarded to each inner selector; set to false to hide the Thinking + * footer and disable ←/→ effort switching. */ + readonly thinkingControl?: boolean; readonly onSelect: (selection: ModelSelection) => void; /** Forwarded to each inner selector; when set, Alt+S applies the choice to * the current session only without persisting it as the default. */ @@ -187,6 +189,7 @@ function makeSelector( searchable: true, providerSwitchHint: true, warning: opts.warning, + thinkingControl: opts.thinkingControl, onSelect: opts.onSelect, onSessionOnlySelect: opts.onSessionOnlySelect, onCancel: opts.onCancel, 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 6bbf7b3bef..9f510b4505 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -137,8 +137,7 @@ export class SubAgentEventHandler { 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). + // falls back to the alias itself when the entry is unknown. modelDisplay: event.model === undefined ? undefined @@ -589,8 +588,7 @@ export class SubAgentEventHandler { // 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). + // to the alias itself when the entry is unknown. progress.setModelDisplay( modelDisplayName(event.model, this.host.state.appState.availableModels[event.model]), ); diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index a1964b5cbb..3dbeb4b4c4 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -167,7 +167,7 @@ describe('built-in slash command registry', () => { 'plan', 'reload', 'reload-tui', - 'secondary_model', + 'secondary-model', 'sessions', 'settings', 'status', @@ -191,8 +191,8 @@ describe('built-in slash command registry', () => { expect(resolveSlashCommandAvailability(reloadTui!, '')).toBe('always'); }); - it('gates secondary_model behind the secondary-model experiment, always available', () => { - const command = findBuiltInSlashCommand('secondary_model'); + 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 index 81b309ef01..9bce58d4c0 100644 --- a/apps/kimi-code/test/tui/commands/secondary-model.test.ts +++ b/apps/kimi-code/test/tui/commands/secondary-model.test.ts @@ -1,10 +1,11 @@ /** - * Scenario: /secondary_model command behavior in the interactive TUI. - * Responsibilities: picker filtering, persistence, live apply, and effective-model state refresh. + * Scenario: /secondary-model command behavior in the interactive TUI. + * Responsibilities: picker filtering, persistence of `[secondary_model] default_model` + * (keeping existing pool descriptions), and error paths. * 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 type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; import { describe, expect, it, vi } from 'vitest'; import type { SlashCommandHost } from '#/tui/commands'; @@ -14,9 +15,10 @@ import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-mo interface PickerOptions { readonly models: Record; readonly currentValue: string; - readonly currentThinkingEffort: string; + readonly selectedValue?: string; readonly title?: string; - readonly onSelect: (selection: { alias: string; thinking: ThinkingEffort }) => void; + readonly thinkingControl?: boolean; + readonly onSelect: (selection: { alias: string }) => void; } function model(name: string): ModelAlias { @@ -29,21 +31,16 @@ function model(name: string): 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 }; + readonly secondaryModel?: { defaultModel?: string; models?: Record }; }) { - 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. + // The v1 derived entry must never be selectable. '__secondary__': model('cheap'), + // The pool's reserved symbolic choice must never be selectable either. + 'primary': model('primary'), } as Record, availableProviders: {}, transcriptEntries: [], @@ -61,13 +58,8 @@ function makeHost(options?: { providers: {}, secondaryModel: options?.secondaryModel, })), - setConfig: vi.fn(async () => ({ - providers: {}, - models: options?.persistedModels, - secondaryModel: options?.effectiveSecondary, - })), + setConfig: vi.fn(async () => ({})), }, - session, setAppState: vi.fn((patch) => Object.assign(appState, patch)), mountEditorReplacement: vi.fn(), restoreEditor: vi.fn(), @@ -85,7 +77,7 @@ function makeHost(options?: { showError: ReturnType; showNotice: ReturnType; }; - return { host, session }; + return { host }; } function mountedPicker(host: { mountEditorReplacement: ReturnType }): PickerOptions { @@ -96,108 +88,86 @@ function mountedPicker(host: { mountEditorReplacement: ReturnType } 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' } }); + it('opens the picker filtered to user models, with the configured default as current', async () => { + const { host } = makeHost({ secondaryModel: { defaultModel: 'cheap' } }); 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'); + // Pool bindings carry no explicit thinking level — the picker hides the + // Thinking footer instead of offering a no-op choice. + expect(opts.thinkingControl).toBe(false); }); - it('persists first, then live-applies the selection to the session', async () => { - const { host, session } = makeHost(); + it('persists only default_model when no pool exists (implicit single-entry pool)', async () => { + const { host } = makeHost(); await handleSecondaryModelCommand(host, ''); - mountedPicker(host).onSelect({ alias: 'k2', thinking: 'high' }); + mountedPicker(host).onSelect({ alias: 'k2' }); await vi.waitFor(() => { expect(host.showStatus).toHaveBeenCalled(); }); expect(host.harness.setConfig).toHaveBeenCalledWith({ - secondaryModel: { model: 'k2', defaultEffort: 'high' }, + secondaryModel: { defaultModel: 'k2' }, }); - 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 () => { + it('adds the picked alias to an existing pool with an empty description', async () => { const { host } = makeHost({ - persistedModels: { - k2: model('k2'), - cheap: model('cheap'), - '__secondary__': model('k2'), + secondaryModel: { + defaultModel: 'cheap', + models: { cheap: 'fast and cheap' }, }, }); await handleSecondaryModelCommand(host, ''); - mountedPicker(host).onSelect({ alias: 'k2', thinking: 'high' }); + mountedPicker(host).onSelect({ alias: 'k2' }); await vi.waitFor(() => { expect(host.showStatus).toHaveBeenCalled(); }); - expect(host.state.appState.availableModels['__secondary__']?.displayName).toBe('k2'); + expect(host.harness.setConfig).toHaveBeenCalledWith({ + secondaryModel: { + defaultModel: 'k2', + models: { cheap: 'fast and cheap', 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. + it('keeps existing pool descriptions and other pool entries on save', async () => { const { host } = makeHost({ - effectiveSecondary: { model: 'cheap', defaultEffort: 'low' }, + secondaryModel: { + defaultModel: 'cheap', + models: { cheap: 'fast and cheap', k2: 'hard tasks' }, + }, }); await handleSecondaryModelCommand(host, ''); - mountedPicker(host).onSelect({ alias: 'k2', thinking: 'high' }); + mountedPicker(host).onSelect({ alias: 'k2' }); 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'), + expect(host.harness.setConfig).toHaveBeenCalledWith({ + secondaryModel: { + defaultModel: 'k2', + models: { cheap: 'fast and cheap', k2: 'hard tasks' }, }, }); - 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 }); + it('pre-selects a valid alias argument instead of erroring', async () => { + const { host } = makeHost(); - await handleSecondaryModelCommand(host, ''); - mountedPicker(host).onSelect({ alias: 'k2', thinking: 'off' }); + await handleSecondaryModelCommand(host, 'cheap'); - 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'); + const opts = mountedPicker(host); + expect(opts.selectedValue).toBe('cheap'); }); it('rejects an unknown alias argument without opening the picker', async () => { @@ -218,6 +188,26 @@ describe('handleSecondaryModelCommand', () => { expect(host.mountEditorReplacement).not.toHaveBeenCalled(); }); + it('rejects the reserved primary alias as an argument', async () => { + const { host } = makeHost(); + + await handleSecondaryModelCommand(host, 'primary'); + + expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('reserved')); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('reports the reserved error for primary even when it is the only configured model', async () => { + const { host } = makeHost(); + host.state.appState.availableModels = { primary: model('primary') }; + + await handleSecondaryModelCommand(host, 'primary'); + + expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('reserved')); + expect(host.showNotice).not.toHaveBeenCalled(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + it('shows a notice when no models are configured', async () => { const { host } = makeHost(); host.state.appState.availableModels = {}; @@ -227,4 +217,18 @@ describe('handleSecondaryModelCommand', () => { expect(host.showNotice).toHaveBeenCalled(); expect(host.mountEditorReplacement).not.toHaveBeenCalled(); }); + + it('reports a persistence failure without a status message', async () => { + const { host } = makeHost(); + host.harness.setConfig.mockRejectedValueOnce(new Error('disk full')); + + await handleSecondaryModelCommand(host, ''); + mountedPicker(host).onSelect({ alias: 'k2' }); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalled(); + }); + expect(host.showError.mock.calls[0]![0]).toContain('disk full'); + expect(host.showStatus).not.toHaveBeenCalled(); + }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index 8fced41768..e5159ec0d9 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -99,6 +99,39 @@ describe('ModelSelectorComponent', () => { expect(text(picker)).toContain('Thinking (←→ to switch)'); }); + it('hides the Thinking footer when thinkingControl is false', () => { + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2', ['thinking']) }, + currentValue: 'kimi', + currentThinkingEffort: 'on', + thinkingControl: false, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + expect(text(picker)).not.toContain('Thinking'); + }); + + it('ignores Left/Right when thinkingControl is false', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2', ['thinking']) }, + currentValue: 'kimi', + currentThinkingEffort: 'on', + thinkingControl: false, + onSelect, + onCancel: vi.fn(), + }); + + // Same setup as the toggle test above: either arrow would flip 'on' to 'off'. + picker.handleInput(LEFT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'on' }); + picker.handleInput(RIGHT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'on' }); + }); + it('forces always-thinking models on and unsupported models off', () => { const onSelect = vi.fn(); const picker = new ModelSelectorComponent({ 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 f6ffc64961..c202e0bf85 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 @@ -139,12 +139,12 @@ describe('TabbedModelSelectorComponent', () => { models: { k2: model('Kimi K2', 'managed:kimi-code') }, currentValue: 'k2', currentThinkingEffort: 'off', - title: ' Select a secondary model (subagents)', + title: ' Choose a model for this task', onSelect: vi.fn(), onCancel: vi.fn(), }); const out = strip(titled.render(120).join('\n')); - expect(out).toContain('Select a secondary model (subagents)'); + expect(out).toContain('Choose a model for this task'); expect(out).not.toContain('Select a model '); }); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 27f5f03f36..39bb9e37f9 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -192,7 +192,99 @@ You can also switch models temporarily without touching the config file — by s ## `secondary_model` -The secondary model is a second model configuration alongside the main model — typically a cheaper one, for features that do not need the main model's capability. 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; when unset, subagents inherit the main agent's model. +The secondary model is a second model configuration alongside the main model — typically a cheaper one, for features that do not need the main model's capability. Its consumer today is subagent spawning. Both engines read this section, but different keys from it, and both gate the feature behind the secondary-model experiment: + +- The default `agent-core-v2` engine (`kimi`, `kimi -p`, and `kimi web`) reads the [subagent model pool](#subagent-model-pool): `default_model` and the `[secondary_model.models]` table, and also honors a lone recipe `model` key as a fallback default. +- The legacy `agent-core` engine, selected for `kimi` / `kimi -p` with `KIMI_CODE_LEGACY_FLAG=1`, reads the [recipe keys](#secondary-model-recipe) (`model`, `default_effort`, and the patch fields). + +### Subagent model pool + +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. While the experiment is off, the pool keys stay inert: subagents inherit the caller's model and session startup skips the pool validation. + +The pool is read by the `agent-core-v2` engine only; the legacy `agent-core` engine selected with `KIMI_CODE_LEGACY_FLAG=1` ignores `default_model` and `[secondary_model.models]`, and resolves subagent models through the [recipe keys](#secondary-model-recipe) instead. + +To simply point every subagent at one model by default, no models table is needed — a single `default_model` line is a pool with a single entry: + +```toml +[secondary_model] +default_model = "kimi-code/kimi-for-coding-highspeed" +``` + +In the interactive TUI, the [`/secondary-model`](../reference/slash-commands.md) command (alias `/subagent-model`) opens a model selector for this: the choice is written to `default_model` (when a models table exists and the picked alias is not in it, an entry with an empty description is added), and newly spawned subagents pick up the new default immediately — no session restart needed. + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `default_model` | `string` | — | Default subagent model. Required when `[secondary_model.models]` is configured, and must be one of its keys; written on its own (without a models table) it is equivalent to a pool containing only that entry | +| `models` | `table` | — | Subagent model pool. Each key is the alias of a configured [`[models]`](#models) entry; each value is the description the main agent sees when picking a subagent model (Chinese or English; an empty string lists the alias with no hint) | +| `force` | `boolean` | `false` | Pin every subagent to `default_model`: the `model` parameter is not advertised, so the main agent cannot pick another model or `"primary"`. Requires `default_model` (or a lone `model` key); cannot be combined with `[secondary_model.models]` | + +A configured pool — an explicit `[secondary_model.models]` table or a lone `default_model` — enables model selection: the `Agent` / `AgentSwarm` tools gain a `model` parameter, and the tool description lists the pool (the default marked `[default]`) so the main agent can choose per spawn (unless `force` is set — see below). The pool only references configured [`[models]`](#models) entries — the `kimi-code/*` aliases below are provisioned by `/login` — and attaches the selection hints: + +```toml +[secondary_model] +default_model = "kimi-code/kimi-for-coding-highspeed" +[secondary_model.models] +"kimi-code/k3" = "难题选它。擅长复杂推理、算法设计、深度调试、数学和系统性难题。" +"kimi-code/kimi-for-coding-highspeed" = "又快又便宜。适合日常重构、代码解释、小改动、总结和批量简单任务。" +"kimi-code/kimi-for-coding" = "均衡的编码主力。适合大多数功能开发和代码修改任务。" +``` + +A spawn resolves the subagent's model in this order: an explicit tool-call `model` → `default_model`. The `model` parameter accepts any pool alias, or `"primary"` — the model the caller itself is running, always valid even when that model is not in the pool. When neither `default_model` nor `[secondary_model.models]` is configured, the parameter is not advertised and subagents inherit the caller's model. Binding a pool alias carries no explicit thinking effort — the subagent resolves it naturally (global `[thinking]` config → the bound model's default effort) instead of inheriting the caller's level, while `"primary"` inherits both the model and the level from the caller. + +To take the choice away from the main agent entirely — every subagent runs on one fixed model — add `force = true`: + +```toml +[secondary_model] +default_model = "kimi-code/kimi-for-coding-highspeed" +force = true +``` + +With `force` set, the `model` parameter is not advertised (just like when nothing is configured) and every spawn binds `default_model`; an explicit `model` argument, `"primary"` included, is rejected with an error. `force` requires `default_model` (or a lone `model` key) and cannot be combined with a `[secondary_model.models]` table — the table exists to offer a choice, and force removes it. + +Because natural resolution lands on the bound model's default effort, different pool entries can carry different thinking levels: register a second `[models]` entry as a "variant" of the same underlying model, override only its `default_effort` via [`[models."".overrides]`](#model-overrides), and list both aliases in the pool — the main agent picks the thinking level together with the alias: + +```toml +# "kimi-code/kimi-for-coding-highspeed" is provisioned by /login; this +# registers a higher-effort variant of the same model +[models.kimi-for-coding-highspeed-deep] +provider = "managed:kimi-code" +model = "kimi-for-coding-highspeed" + +[models.kimi-for-coding-highspeed-deep.overrides] +default_effort = "high" + +[secondary_model] +default_model = "kimi-code/kimi-for-coding-highspeed" +[secondary_model.models] +"kimi-code/kimi-for-coding-highspeed" = "又快又便宜。适合日常重构、代码解释、小改动、总结和批量简单任务。" +kimi-for-coding-highspeed-deep = "同一模型的高 Thinking 档位。适合较难的子任务。" +``` + +Note that `default_effort` stays a model-level default: once a global `[thinking].effort` is set, it wins for the main agent and subagents alike, and the variant's default only applies when no global effort is set. Value and fallback rules follow the [`[models]` entry's `default_effort`](#models). + +Configuration errors fail loudly instead of falling back silently: session creation, resume, and fork all fail at startup when `default_model` is missing, is not a pool key, or a pool key does not resolve to a configured `[models]` entry — and likewise when `force` is set without `default_model` or combined with a `[secondary_model.models]` table. The alias `primary` is reserved — it always binds the caller's own model — and is rejected as a pool key. A spawn whose `model` is neither a pool alias nor `"primary"` fails with an error listing the available choices. + +The pool keys used to live under `[subagent]`; a leftover `[subagent] default_model` or `[subagent.models]` table no longer applies and is reported as a deprecation warning — move them into `[secondary_model]` as shown above. + +When only the recipe `model` key is set — no `default_model`, no `[secondary_model.models]` table — the v2 engine reads it compatibly as the pool default: an implicit single-entry pool ranked below `default_model`, so a recipe setup keeps working unchanged. The compatibility only takes the model alias, though: the recipe patch fields (`default_effort`, `max_output_size`, …) do not carry over — write those settings onto the `[models]` entry the alias points to, for example via [`[models."".overrides]`](#model-overrides). Once a `[secondary_model.models]` table is configured, `default_model` stays required and `model` does not substitute for it. + +To migrate explicitly, point the pool default at the same alias: + +```toml +# Before +[secondary_model] +model = "kimi-code/kimi-for-coding-highspeed" + +# After +[secondary_model] +default_model = "kimi-code/kimi-for-coding-highspeed" +``` + +The recipe keys can stay in the section: the legacy engine keeps reading them. + +### Secondary-model recipe + +This reading is used by the legacy `agent-core` engine selected with `KIMI_CODE_LEGACY_FLAG=1`; the default v2 engine ignores the recipe keys. When set, newly spawned subagents (`Agent` / `AgentSwarm`) bind to it by default instead of inheriting the main agent's model; when unset, subagents inherit the main agent's model. This is a default binding, not a forced one. With the experiment enabled, the `Agent` / `AgentSwarm` tools gain a `model` parameter (accepting only the symbolic values `"secondary"` / `"primary"`), and the tool description lists the available models with the default marked. A spawn resolves the subagent's model in this order: an explicit tool-call `model` → the profile's [`model_preference`](../customization/agents.md#agent-file-format) → the configured secondary model (the default). Here `"primary"` means the model the main agent is currently running, not necessarily `default_model` — for example after a mid-session `/model` switch. @@ -200,11 +292,9 @@ Because overriding the default is the main agent's own decision (the tool descri 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 | | --- | --- | --- | --- | -| `model` | `string` | — | The alias of a configured [`[models]`](#models) entry, e.g. `kimi-code/kimi-k2.5` (any provider, not limited to Kimi models) | +| `model` | `string` | — | The alias of a configured [`[models]`](#models) entry, e.g. `kimi-code/kimi-for-coding` (any provider, not limited to Kimi models) | | `default_effort` | `string` | — | Thinking effort applied when subagents bind to the secondary model. Unset, the effort resolves naturally (global `[thinking]` config → the bound model's default effort) instead of inheriting the main agent's effort. Follows the main model's thinking-effort semantics: models with strict effort validation (e.g. Kimi models) fall back to their default effort for unsupported values; other providers receive the value as-is | | Other fields | — | — | Accepts every field of [`[models."".overrides]`](#models) (`max_context_size`, `max_output_size`, `support_efforts`, …) as a model patch applied only to subagents | @@ -212,7 +302,7 @@ Every field besides `model` forms a patch: when at least one patch field is set, ```toml [secondary_model] -model = "kimi-code/kimi-k2.5" +model = "kimi-code/kimi-for-coding" default_effort = "low" max_output_size = 8192 ``` @@ -285,11 +375,16 @@ In print mode (`kimi -p ""`), Kimi Code stays alive after the main agent ## `subagent` +`subagent` controls how spawned subagents (`Agent` / `AgentSwarm`) run. + | Field | Type | Default | Description | | --- | --- | --- | --- | | `timeout_ms` | `integer` | `7200000` (2 hours) | Maximum wall-clock time (milliseconds) a single subagent (`Agent` / `AgentSwarm`) is allowed to run before it is settled as `timed_out`. `0` means no timeout — the subagent runs until it finishes or the model stops it. This is the background-task manager's per-task timeout for each subagent task, so it applies to both foreground and background subagents. In print mode (`kimi -p`) the default is `0` unless explicitly set. Note: any value above `2147483647` (about 24.8 days) is clamped to roughly 24.8 days by the runtime | + `timeout_ms` can be overridden by the `KIMI_SUBAGENT_TIMEOUT_MS` environment variable, which takes higher priority than `config.toml`. +The model pool that used to be configured here (`default_model`, `[subagent.models]`) moved to the [subagent model pool](#subagent-model-pool) under `[secondary_model]`; the old keys no longer apply and are reported as deprecation warnings. + ## `mcp` | Field | Type | Default | Description | diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index af917ec881..f6fc667aef 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -133,7 +133,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_CODE_IDENTITY_SLUG` | Protocol identifier for the `User-Agent` product token sent to third-party providers and the MCP client name; takes higher priority than `[identity] slug`. Derived from the name when unset | Any non-empty string; normalized to lowercase with non-alphanumeric runs folded to `-` | | `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` | Whether the built-in skills documenting Kimi Code itself are offered to the model; takes higher priority than `builtin_product_skills` in `config.toml` (default enabled) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `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`](./config-files.md#secondary-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 | The alias of a configured `[models]` entry, e.g. `kimi-code/kimi-k2.5`; blank values are ignored | +| `KIMI_SECONDARY_MODEL` | Secondary model; takes higher priority than [`[secondary_model] model`](./config-files.md#secondary-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 | The alias of a configured `[models]` entry, e.g. `kimi-code/kimi-for-coding`; 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 | @@ -155,7 +155,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_CODE_NO_AUTO_UPDATE` | Fully disable the update preflight — no check, background install, or prompt. Legacy alias `KIMI_CLI_NO_AUTO_UPDATE` is also honored | Truthy: `1`/`true`/`yes`/`on` | | `KIMI_DISABLE_CRON` | Disable the scheduled-task tool (`CronCreate` rejects new schedules; existing tasks do not fire) | `1` to disable | -The three `KIMI_CODE_IDENTITY_*` / `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` variables are read by the default `agent-core-v2` engine. The legacy `kimi` / `kimi -p` path selected with `KIMI_CODE_LEGACY_FLAG=1` ignores them. +The three `KIMI_CODE_IDENTITY_*` / `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` variables are read by the default `agent-core-v2` engine. The legacy `kimi` / `kimi -p` path selected with `KIMI_CODE_LEGACY_FLAG=1` ignores them. Conversely, `KIMI_SECONDARY_MODEL` and `KIMI_SECONDARY_EFFORT` are read by the legacy engine only, and the default engine ignores them; `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` is read by both engines (it gates the v2 [subagent model pool](./config-files.md#subagent-model-pool) and the legacy [secondary-model recipe](./config-files.md#secondary-model-recipe) alike). ## Diagnostic logs diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md index 2b247a3a02..9cdb6e43d9 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -100,7 +100,7 @@ You are a strict code reviewer. Read the diff, then report findings grouped by s | `description` | yes | What the agent does. Shown to the main Agent when it picks a sub-agent, so write it to guide delegation decisions | | `whenToUse` | no | Extra hint describing when the agent should be used | | `override` | no | Whether this file may replace a same-name built-in Agent. Defaults to `false`; `--agent-file` is already explicit and does not require this field | -| `model_preference` | no | Symbolic default used when `Agent` or `AgentSwarm` spawns this profile: `primary` selects the model the caller is currently running, while `secondary` selects [`[secondary_model] model`](../configuration/config-files.md#secondary-model). An explicit tool-call `model` (which likewise accepts only `"primary"` / `"secondary"`) wins over this field; without either setting, the configured secondary model remains the default. If no secondary model is configured, the subagent inherits the caller's model | +| `model_preference` | no | Symbolic default used when `Agent` or `AgentSwarm` spawns this profile: `primary` selects the model the caller is currently running, while `secondary` selects [`[secondary_model] model`](../configuration/config-files.md#secondary-model). An explicit tool-call `model` (which likewise accepts only `"primary"` / `"secondary"`) wins over this field; without either setting, the configured secondary model remains the default. If no secondary model is configured, the subagent inherits the caller's model. Read only by the legacy `agent-core` engine selected with `KIMI_CODE_LEGACY_FLAG=1`; the default v2 engine ignores this field | | `tools` | no | Allowlist of tool names such as `Read` or `Bash`; MCP tools are matched with globs such as `mcp__github__*`. Accepts a YAML list or a comma-separated string (`tools: Read, Grep`). Omit to allow all tools; a lone `*` also allows all tools; an empty list (`tools: []`) disables all tools | | `disallowedTools` | no | Denylist with the same syntax and matching rules, applied after `tools` | | `subagents` | no | Allowlist of sub-agent names this agent may delegate to, with the same syntax as `tools` (YAML list or comma-separated string). Omit to allow every type; a lone `*` also allows all types | diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index f4aa141714..e8c112bcdd 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -16,7 +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-—-interactive-provider-management) | Yes | | `/model` | — | Switch the LLM model used in the current session | Yes | -| `/secondary_model` | — | Configure the secondary model that newly spawned subagents bind to by default (writes the [`[secondary_model]`](../configuration/config-files.md#secondary-model) section and applies to the current session immediately). Requires the `secondary-model` experiment | Yes | +| `/secondary-model` | `/subagent-model` | Pick the default model for subagents (writes `[secondary_model] default_model`; see the [subagent model pool](../configuration/config-files.md#subagent-model-pool)). Visible when the secondary-model experiment is enabled | 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 8b412b5366..c0ea940108 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). 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` (available only when the [subagent model pool](../configuration/config-files.md#subagent-model-pool) experiment is enabled and a pool is configured — a `[secondary_model.models]` table or a lone `default_model`: a pool alias, or `"primary"` for the model the caller itself is running; ignored when resuming). Without it, the subagent binds the pool's `default_model`; without a configured pool, subagents always inherit the caller's model. That is the default v2 engine behavior; on the legacy `agent-core` engine selected with `KIMI_CODE_LEGACY_FLAG=1`, `model` is available when the [secondary-model experiment](../configuration/config-files.md#secondary-model) is enabled instead, accepting only `"secondary"` / `"primary"` — an explicit choice overrides the profile's [`model_preference`](../customization/agents.md#agent-file-format), and the configured secondary model is the default. 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) 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 only when the [subagent model pool](../configuration/config-files.md#subagent-model-pool) experiment is enabled and a pool is configured — a `[secondary_model.models]` table or a lone `default_model`) to run item-spawned subagents on a pool alias or on the caller's own model (`"primary"`). Without it, item-spawned subagents bind the pool's `default_model`; without a configured pool, they inherit the caller's model. On the legacy `agent-core` engine selected with `KIMI_CODE_LEGACY_FLAG=1`, `model` follows the [secondary-model experiment](../configuration/config-files.md#secondary-model) instead (`"secondary"` / `"primary"`, defaulting to the configured secondary model). 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 f102efba0f..8e8b79710b 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -192,7 +192,98 @@ display_name = "Kimi for Coding (custom)" ## `secondary_model` -次主力模型是主模型之外的第二个模型配置——通常是一个更便宜的模型,供不需要主模型能力的功能绑定使用。它目前的消费者是子 Agent 派生:设置后,新派生的子 Agent(`Agent` / `AgentSwarm`)默认绑定该模型,而不再继承主 Agent 的模型;未设置时,子 Agent 继承主 Agent 的模型。 +次主力模型是主模型之外的第二个模型配置——通常是一个更便宜的模型,供不需要主模型能力的功能绑定使用。它目前的消费者是子 Agent 派生。两个引擎都会读取本节,但各取不同的键,且都以次主力模型实验功能为开关: + +- 默认的 `agent-core-v2` 引擎(`kimi`、`kimi -p` 和 `kimi web`)读取[子 Agent 模型池](#子-agent-模型池):`default_model` 与 `[secondary_model.models]` 表,并兼容读取单独的配方键 `model` 作为兜底。 +- 使用 `KIMI_CODE_LEGACY_FLAG=1` 为 `kimi` / `kimi -p` 选择旧版 `agent-core` 引擎后,该引擎读取[配方键](#次主力模型配方)(`model`、`default_effort` 及补丁字段)。 + +### 子 Agent 模型池 + +该功能目前是实验功能,默认关闭。通过 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1` 启用,或使用 master `KIMI_CODE_EXPERIMENTAL_FLAG=1`。它在包括交互式 TUI 在内的所有启动方式下生效。实验功能关闭时,模型池配置不生效:子 Agent 继承调用方模型,会话启动也会跳过池校验。 + +模型池仅由 `agent-core-v2` 引擎读取;使用 `KIMI_CODE_LEGACY_FLAG=1` 选择的旧版 `agent-core` 引擎会忽略 `default_model` 和 `[secondary_model.models]`,子 Agent 模型按[配方键](#次主力模型配方)解析。 + +只想让所有子 Agent 默认换用一个模型时不需要 models 表——一行 `default_model` 就是只含一个条目的模型池: + +```toml +[secondary_model] +default_model = "kimi-code/kimi-for-coding-highspeed" +``` + +在交互式 TUI 中,也可以用 [`/secondary-model`](../reference/slash-commands.md) 命令(别名 `/subagent-model`)打开模型选择器来设置:选择后写入 `default_model`(已有 models 表而所选别名不在其中时,会一并补一条空描述条目),之后派生的子 Agent 立即按新默认值绑定,无需重启会话。 + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `default_model` | `string` | — | 子 Agent 默认模型。配置 `[secondary_model.models]` 时必填,且必须是其中的 key;单独写下它(不写 models 表)则等价于只含它一个条目的模型池 | +| `models` | `table` | — | 子 Agent 模型池。key 是 [`[models]`](#models) 中已配置条目的别名,value 是主 Agent 挑选子 Agent 模型时看到的描述(中英文均可;空字符串表示只列出别名、不给提示) | +| `force` | `boolean` | `false` | 把所有子 Agent 固定到 `default_model`:不再提供 `model` 参数,主 Agent 无法改选其他模型或 `"primary"`。必须配置 `default_model`(或兼容读取的 `model` 键),且不能与 `[secondary_model.models]` 同时使用 | + +配置模型池(显式的 `[secondary_model.models]` 表,或仅一行 `default_model` 形成的隐式单条目池)即启用模型选择:`Agent` / `AgentSwarm` 工具会获得 `model` 参数,工具描述中会列出模型池(默认模型标注 `[default]`),主 Agent 可按次派生选择模型(除非设置了 `force`,见下文)。模型池只引用已配置的 [`[models]`](#models) 条目——下面的 `kimi-code/*` 别名由 `/login` 自动提供——并附上挑选提示: + +```toml +[secondary_model] +default_model = "kimi-code/kimi-for-coding-highspeed" +[secondary_model.models] +"kimi-code/k3" = "难题选它。擅长复杂推理、算法设计、深度调试、数学和系统性难题。" +"kimi-code/kimi-for-coding-highspeed" = "又快又便宜。适合日常重构、代码解释、小改动、总结和批量简单任务。" +"kimi-code/kimi-for-coding" = "均衡的编码主力。适合大多数功能开发和代码修改任务。" +``` + +派生时按以下顺序解析子 Agent 的模型:工具调用显式传入的 `model` → `default_model`。`model` 参数接受池中任意别名,或 `"primary"` ——调用方自己正在运行的模型,始终合法,即使它不在池中。`default_model` 与 `[secondary_model.models]` 都未配置时,该参数不会出现,子 Agent 继承调用方模型。绑定池中别名时不携带显式 Thinking 档位——子 Agent 按 "全局 `[thinking]` 配置 → 所绑定模型的默认 effort" 自然解析,不继承调用方的档位;`"primary"` 则连模型带档位一起继承调用方。 + +要彻底收回主 Agent 的选择权——让所有子 Agent 固定跑在同一个模型上——加上 `force = true`: + +```toml +[secondary_model] +default_model = "kimi-code/kimi-for-coding-highspeed" +force = true +``` + +设置 `force` 后不再提供 `model` 参数(与完全未配置时一样),每次派生都绑定 `default_model`;显式传入 `model`(包括 `"primary"`)会报错。`force` 必须搭配 `default_model`(或单独的 `model` 键),且不能与 `[secondary_model.models]` 表同时使用——表的意义在于提供选择,而 force 取消了选择。 + +利用自然解析会落到所绑定模型的默认 effort 这一点,可以给池中不同条目配不同的 Thinking 档位:为同一个底层模型再注册一个 `[models]` 条目作为「变体」,用 [`[models."".overrides]`](#模型覆盖项) 只覆盖 `default_effort`,再把两个别名都放进模型池——主 Agent 挑选别名时便同时选定了档位: + +```toml +# "kimi-code/kimi-for-coding-highspeed" 由 /login 提供;这里为同一模型注册一个高档位变体 +[models.kimi-for-coding-highspeed-deep] +provider = "managed:kimi-code" +model = "kimi-for-coding-highspeed" + +[models.kimi-for-coding-highspeed-deep.overrides] +default_effort = "high" + +[secondary_model] +default_model = "kimi-code/kimi-for-coding-highspeed" +[secondary_model.models] +"kimi-code/kimi-for-coding-highspeed" = "又快又便宜。适合日常重构、代码解释、小改动、总结和批量简单任务。" +kimi-for-coding-highspeed-deep = "同一模型的高 Thinking 档位。适合较难的子任务。" +``` + +注意 `default_effort` 是模型级默认值:一旦设置了全局 `[thinking].effort`,它对主 Agent 和子 Agent 都优先生效,变体的默认档位只在全局未设置时起作用。取值与回落规则同 [`[models]` 条目的 `default_effort`](#models)。 + +配置错误一律直接报错,不做静默回退:`default_model` 缺失、不是池中 key,或池中 key 无法解析到已配置的 `[models]` 条目时,会话的创建、恢复(resume)与 fork 都会在启动时直接失败;`force` 未搭配 `default_model` 或与 `[secondary_model.models]` 表同用时亦然。别名 `primary` 是保留字——它始终绑定调用方自己的模型——不能作为池中 key。工具调用传入的 `model` 既不是池中别名也不是 `"primary"` 时,本次派生报错并列出可选值。 + +模型池键之前位于 `[subagent]` 下;遗留的 `[subagent] default_model` 或 `[subagent.models]` 表不再生效,并会以弃用警告的形式报告——按上文示例移入 `[secondary_model]` 即可。 + +只写了配方键 `model`(没有 `default_model`,也没有 `[secondary_model.models]` 表)时,v2 引擎会兼容读取它,把该别名当作池的默认模型——等价于只含它一个条目的隐式模型池,优先级低于 `default_model`,所以从配方迁移过来不改配置也能工作。注意兼容只取模型别名:补丁字段(`default_effort`、`max_output_size` 等)不会随之生效——请把这些设置写到别名指向的 `[models]` 条目上,例如通过 [`[models."".overrides]`](#模型覆盖项)。一旦配置了 `[secondary_model.models]` 表,`default_model` 依旧必填,`model` 不能顶替。 + +要显式迁移,把模型别名改为池的默认模型即可: + +```toml +# 旧 +[secondary_model] +model = "kimi-code/kimi-for-coding-highspeed" + +# 新 +[secondary_model] +default_model = "kimi-code/kimi-for-coding-highspeed" +``` + +配方键可以继续留在本节中:旧版引擎会照常读取它们。 + +### 次主力模型配方 + +该读法由使用 `KIMI_CODE_LEGACY_FLAG=1` 选择的旧版 `agent-core` 引擎使用;默认的 v2 引擎会忽略配方键。设置后,新派生的子 Agent(`Agent` / `AgentSwarm`)默认绑定该模型,而不再继承主 Agent 的模型;未设置时,子 Agent 继承主 Agent 的模型。 这是默认绑定而非强制。实验功能启用后,`Agent` / `AgentSwarm` 工具会获得 `model` 参数(仅接受 `"secondary"` / `"primary"` 两个符号值),工具描述中也会列出可选模型并标注默认值。派生时按以下顺序解析子 Agent 的模型:工具调用显式传入的 `model` → 子 Agent profile 的 [`model_preference`](../customization/agents.md#agent-文件格式) → 已配置的次主力模型(默认)。其中 `"primary"` 指主 Agent 当前正在运行的模型,不一定是 `default_model`——例如会话中途用 `/model` 切换过模型。 @@ -200,11 +291,9 @@ display_name = "Kimi for Coding (custom)" 该功能目前是实验功能,默认关闭。通过 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1` 启用,或使用 master `KIMI_CODE_EXPERIMENTAL_FLAG=1`。它在包括交互式 TUI 在内的所有启动方式下生效。 -在交互式 TUI 中,可以使用 [`/secondary_model`](../reference/slash-commands.md) 命令打开模型选择器来设置该配置:选择后会写入本小节配置,并在当前会话立即生效——之后派生的子 Agent 会直接绑定新的次主力模型。 - | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| `model` | `string` | — | [`[models]`](#models) 中已配置条目的别名,如 `kimi-code/kimi-k2.5`(不限 kimi 模型,可用任意供应商) | +| `model` | `string` | — | [`[models]`](#models) 中已配置条目的别名,如 `kimi-code/kimi-for-coding`(不限 kimi 模型,可用任意供应商) | | `default_effort` | `string` | — | 子 Agent 绑定次主力模型时使用的 thinking effort。未设置时按"全局 `[thinking]` 配置 → 模型默认 effort"的链路解析,不再继承主 Agent 的 effort。与主模型的 thinking effort 语义一致:严格校验 effort 的模型(如 kimi 模型)在不支持该取值时回退到模型默认 effort,其他供应商的模型按原样发送给后端 | | 其他字段 | — | — | 接受 [`[models."".overrides]`](#models) 的全部字段(`max_context_size`、`max_output_size`、`support_efforts` 等),作为仅对子 Agent 生效的模型补丁 | @@ -212,7 +301,7 @@ display_name = "Kimi for Coding (custom)" ```toml [secondary_model] -model = "kimi-code/kimi-k2.5" +model = "kimi-code/kimi-for-coding" default_effort = "low" max_output_size = 8192 ``` @@ -285,11 +374,16 @@ max_output_size = 8192 ## `subagent` +`subagent` 控制派生子 Agent(`Agent` / `AgentSwarm`)的运行方式。 + | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `timeout_ms` | `integer` | `7200000`(2 小时) | 单个子代理(`Agent` / `AgentSwarm`)允许运行的最长时间(毫秒)。超时后子代理以 `timed_out` 收尾。`0` 表示无超时——子代理一直运行到自行结束或被模型手动停止。该值是后台任务管理器对每个子代理任务的 per-task timeout,因此对前台与后台子代理同时生效。在 print 模式(`kimi -p`)下未显式设置时默认为 `0`。注意:超过 `2147483647`(约 24.8 天)的值会被运行时钳到约 24.8 天 | + `timeout_ms` 可被环境变量 `KIMI_SUBAGENT_TIMEOUT_MS` 覆盖,优先级高于配置文件。 +之前在此配置的模型池(`default_model`、`[subagent.models]`)已移至 `[secondary_model]` 下的[子 Agent 模型池](#子-agent-模型池);旧键不再生效,并会以弃用警告的形式报告。 + ## `mcp` | 字段 | 类型 | 默认值 | 说明 | diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index e846aa093d..1e7372afaf 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -133,8 +133,8 @@ kimi | `KIMI_CODE_IDENTITY_SLUG` | 协议标识,用于发给第三方 provider 的 `User-Agent` 产品名和 MCP 客户端名,优先级高于 `[identity] slug`。未设置时由名称派生 | 任意非空字符串;会转小写并将连续非字母数字字符折叠为 `-` | | `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` | 是否向模型提供介绍 Kimi Code 自身的内置 Skills,优先级高于 `config.toml` 的 `builtin_product_skills`(默认开启) | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `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`](./config-files.md#secondary-model)。次主力模型实验功能启用后,新派生的子 Agent 默认绑定该模型,而不再继承主 Agent 的模型 | `[models]` 中已配置条目的别名,如 `kimi-code/kimi-k2.5`;空白值被忽略 | -| `KIMI_SECONDARY_EFFORT` | 次主力模型的 thinking effort;优先级高于 `config.toml` 的 `[secondary_model] default_effort`,仅在次主力模型及其实验功能均启用时生效 | effort 取值,如 `low`;空白值被忽略 | +| `KIMI_SECONDARY_MODEL` | 次主力模型;优先级高于 `config.toml` 的 [`[secondary_model] model`](./config-files.md#secondary-model)。次主力模型实验功能启用后,新派生的子 Agent 默认绑定该模型,而不再继承主 Agent 的模型 | `[models]` 中已配置条目的别名,如 `kimi-code/kimi-for-coding`;空白值被忽略 | +| `KIMI_SECONDARY_EFFORT` | 次主力模型的 thinking effort;优先级高于 `[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` 表示无上限) | 非负整数;非法值被忽略 | @@ -155,7 +155,7 @@ kimi | `KIMI_CODE_NO_AUTO_UPDATE` | 完全禁用更新预检——不检查、不后台安装、不提示。同时兼容旧名 `KIMI_CLI_NO_AUTO_UPDATE` | 真值:`1`/`true`/`yes`/`on` | | `KIMI_DISABLE_CRON` | 禁用定时任务工具(`CronCreate` 拒绝新计划,已有任务不触发) | `1` 表示禁用 | -`KIMI_CODE_IDENTITY_*` 和 `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` 这三个变量由默认的 `agent-core-v2` 引擎读取。设置 `KIMI_CODE_LEGACY_FLAG=1` 后,旧版 `kimi` / `kimi -p` 路径会忽略它们。 +`KIMI_CODE_IDENTITY_*` 和 `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` 这三个变量由默认的 `agent-core-v2` 引擎读取。设置 `KIMI_CODE_LEGACY_FLAG=1` 后,旧版 `kimi` / `kimi -p` 路径会忽略它们。反过来,`KIMI_SECONDARY_MODEL` 和 `KIMI_SECONDARY_EFFORT` 仅由旧版引擎读取,默认引擎会忽略它们;`KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` 则由两个引擎共同读取(它同时门控 v2 的[子 Agent 模型池](./config-files.md#子-agent-模型池)和旧版的[次主力模型配方](./config-files.md#次主力模型配方))。 ## 诊断日志 diff --git a/docs/zh/customization/agents.md b/docs/zh/customization/agents.md index 97d98de3e2..0dffbe352b 100644 --- a/docs/zh/customization/agents.md +++ b/docs/zh/customization/agents.md @@ -100,7 +100,7 @@ disallowedTools: | `description` | 是 | Agent 的用途。主 Agent 挑选子 Agent 时会看到,请围绕委派决策来写 | | `whenToUse` | 否 | 补充说明何时应使用该 Agent | | `override` | 否 | 是否允许覆盖同名内置 Agent,默认 `false`。`--agent-file` 属于显式启动意图,无需设置此字段 | -| `model_preference` | 否 | `Agent` 或 `AgentSwarm` 启动该 profile 时的符号默认值:`primary` 选择调用方当前运行的模型,`secondary` 选择 [`[secondary_model] model`](../configuration/config-files.md#secondary-model)。工具调用显式传入的 `model`(同样只接受 `"primary"` / `"secondary"` 两个符号值)优先于该字段;两者均未设置时,已配置的次主力模型仍为默认值。未配置次主力模型时,子 Agent 继承调用方模型 | +| `model_preference` | 否 | `Agent` 或 `AgentSwarm` 启动该 profile 时的符号默认值:`primary` 选择调用方当前运行的模型,`secondary` 选择 [`[secondary_model] model`](../configuration/config-files.md#secondary-model)。工具调用显式传入的 `model`(同样只接受 `"primary"` / `"secondary"` 两个符号值)优先于该字段;两者均未设置时,已配置的次主力模型仍为默认值。未配置次主力模型时,子 Agent 继承调用方模型。仅使用 `KIMI_CODE_LEGACY_FLAG=1` 选择的旧版 `agent-core` 引擎读取该字段;默认的 v2 引擎会忽略 | | `tools` | 否 | 工具名允许列表,如 `Read`、`Bash`;MCP 工具用 glob 匹配,如 `mcp__github__*`。支持 YAML 列表或逗号分隔字符串(`tools: Read, Grep`)两种写法。缺省表示允许全部工具;单独的 `*` 同样表示允许全部工具;空列表(`tools: []`)表示禁用全部工具 | | `disallowedTools` | 否 | 禁止列表,写法与匹配规则相同,在 `tools` 之后应用 | | `subagents` | 否 | 允许委派的子 Agent 名称列表,写法与 `tools` 相同(YAML 列表或逗号分隔字符串)。缺省表示可委派所有类型;单独的 `*` 同样表示全部 | diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 4a8b24451f..c5a76ce2f1 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -16,7 +16,7 @@ | `/logout` | — | 清除当前所选账号的凭据 | 否 | | `/provider` | — | 打开交互式供应商管理器,查看、添加和删除已配置的供应商。详见[平台与模型 — `/provider` 与供应商管理](../configuration/providers.md#provider-—-交互式供应商管理) | 是 | | `/model` | — | 切换当前会话使用的 LLM 模型 | 是 | -| `/secondary_model` | — | 配置子 Agent 默认绑定的次主力模型(写入 [`[secondary_model]`](../configuration/config-files.md#secondary-model) 配置并在当前会话立即生效)。需开启 `secondary-model` 实验功能 | 是 | +| `/secondary-model` | `/subagent-model` | 选择子 Agent 的默认模型(写入 `[secondary_model] default_model`,详见[子 Agent 模型池](../configuration/config-files.md#子-agent-模型池))。在次主力模型实验功能启用时可见 | 是 | | `/settings` | `/config` | 打开 TUI 内的设置面板 | 是 | | `/experiments` | `/experimental` | 打开实验功能面板 | 是 | | `/permission` | — | 选择权限模式 | 是 | diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 009ff3d052..1c2c3fc53c 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 时无效;次主力模型实验功能启用后可用)。显式 `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`(仅在启用 [子 Agent 模型池](../configuration/config-files.md#子-agent-模型池) 实验功能并配置模型池后可用——`[secondary_model.models]` 表或仅一行 `default_model`:池中别名,或 `"primary"` 表示调用方自己运行的模型;resume 时无效)。未传入时子 Agent 绑定池的 `default_model`;未配置模型池时,子 Agent 一律继承调用方模型。以上是默认 v2 引擎的行为;在使用 `KIMI_CODE_LEGACY_FLAG=1` 选择的旧版 `agent-core` 引擎上,`model` 改为在启用[次主力模型实验功能](../configuration/config-files.md#secondary-model)后可用,仅接受 `"secondary"` / `"primary"`——显式传入会覆盖 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`(次主力模型实验功能启用后可用)可以让新启动的子 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 模型池](../configuration/config-files.md#子-agent-模型池) 实验功能并配置模型池后可用——`[secondary_model.models]` 表或仅一行 `default_model`)可以让新启动的子 Agent 运行在池中别名指定的模型或调用方自己的模型(`"primary"`)上。未传入时新启动的子 Agent 绑定池的 `default_model`;未配置模型池时则继承调用方模型。在使用 `KIMI_CODE_LEGACY_FLAG=1` 选择的旧版 `agent-core` 引擎上,`model` 按[次主力模型实验功能](../configuration/config-files.md#secondary-model)工作(`"secondary"` / `"primary"`,默认绑定已配置的次主力模型)。恢复的子 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/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml index a638728399..463356d402 100644 --- a/packages/agent-core-v2/docs/config-manifest.toml +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -8,7 +8,7 @@ # commented "# field: type" lines describe the remaining schema fields. # Values resolve as: default -> config.toml -> env overlay -> memory. -# Index (25 sections · 3 overlay(s)) +# Index (25 sections · 2 overlay(s)) # background src/agent/task/configSection.ts # builtinProductSkills src/app/skillCatalog/configSection.ts # cron src/app/cron/configSection.ts @@ -27,7 +27,7 @@ # models src/app/kosongConfig/configSection.ts # permission src/agent/permissionRules/configSection.ts # providers src/app/kosongConfig/configSection.ts -# secondaryModel src/app/kosongConfig/configSection.ts +# secondaryModel src/session/subagent/configSection.ts # services src/app/auth/configSection.ts # subagent src/session/subagent/configSection.ts # task src/agent/task/configSection.ts @@ -36,7 +36,6 @@ # tools src/agent/toolPolicy/configSection.ts # (overlay) servicesCredentialEnvOverlay src/app/auth/configSection.ts # (overlay) kimiModelEnvOverlay src/app/kosongConfig/envOverlay.ts -# (overlay) secondaryModelOverlay src/app/kosongConfig/secondaryModelOverlay.ts # ########################################################################## # background @@ -318,15 +317,15 @@ merge_all_available_skills = true # ########################################################################## # secondaryModel (config.toml: secondary_model) -# owner: src/app/kosongConfig/configSection.ts +# owner: src/session/subagent/configSection.ts # scope: core -# hooks: stripEnv -# env: -# model <- KIMI_SECONDARY_MODEL (custom parse) -# default_effort <- KIMI_SECONDARY_EFFORT (custom parse) # ########################################################################## [secondary_model] +# default_model: string +# models: record +# force: boolean +# model: string # max_context_size: integer # max_input_size: integer # max_output_size: integer @@ -337,7 +336,6 @@ merge_all_available_skills = true # support_efforts: string[] # default_effort: string # off_effort: string -# model: string # ########################################################################## # services @@ -376,6 +374,9 @@ merge_all_available_skills = true # owner: src/session/subagent/configSection.ts # scope: core # hooks: stripEnv +# deprecations (old key is ignored + warns; rename manually): +# default_model -> secondary_model.default_model +# models -> secondary_model.models # env: # timeout_ms <- KIMI_SUBAGENT_TIMEOUT_MS (custom parse) # ########################################################################## diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 5642e036e2..08c2ca5f1b 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -120,7 +120,6 @@ import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import type { ToolSource } from '#/tool/toolContract'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; -import { subagentDisplayModel } from '#/session/subagent/configSection'; import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { BUILTIN_SKILL_SOURCE_ID } from '#/app/skillCatalog/skillSource'; @@ -756,7 +755,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ const maxContextTokens = capabilities?.max_input_tokens ?? capabilities?.max_context_tokens; this.eventBus.publish({ type: 'agent.status.updated', - model: subagentDisplayModel(this.config, modelAlias), + model: modelAlias, thinkingEffort: includeThinkingEffort ? this.getEffectiveThinkingLevel() : undefined, diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index 8f56806509..f5e68826ae 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -72,7 +72,10 @@ const ABORT_GRACE_MS = 2_000; const TOOL_OUTPUT_EMPTY = 'Tool output is empty.'; const TOOL_OUTPUT_NON_TEXT = 'Tool returned non-text content.'; -const validators = new WeakMap(); +const validators = new WeakMap< + ExecutableTool, + { schema: Record; validator: ToolArgsValidator } +>(); export interface ToolExecutionTask { readonly accesses: ToolAccesses; @@ -793,16 +796,17 @@ function preflightToolCall( } function validateExecutableToolArgs(tool: ExecutableTool, args: unknown): string | null { - let validator = validators.get(tool); - if (validator === undefined) { + const schema = tool.parameters; + let cached = validators.get(tool); + if (cached === undefined || cached.schema !== schema) { try { - validator = compileToolArgsValidator(tool.parameters); - validators.set(tool, validator); + cached = { schema, validator: compileToolArgsValidator(schema) }; + validators.set(tool, cached); } catch (error) { return error instanceof Error ? error.message : String(error); } } - return validateToolArgs(validator, args as JsonType); + return validateToolArgs(cached.validator, args as JsonType); } function toolCallDisplayFieldsFromExecution( diff --git a/packages/agent-core-v2/src/agent/tools/agent-swarm/agent-swarm.ts b/packages/agent-core-v2/src/agent/tools/agent-swarm/agent-swarm.ts index f1a7349ab2..b936fa3bd7 100644 --- a/packages/agent-core-v2/src/agent/tools/agent-swarm/agent-swarm.ts +++ b/packages/agent-core-v2/src/agent/tools/agent-swarm/agent-swarm.ts @@ -54,10 +54,10 @@ export const AgentSwarmToolInputSchema = z 'Map of existing subagent agent_id to the prompt used to resume that subagent. These resumed subagents are launched before new item-based subagents.', ), model: z - .enum(['secondary', 'primary']) + .string() .optional() .describe( - 'Which model to run the item-spawned subagents on: "secondary" = the configured secondary model; "primary" = the main model you are running on (for hard, quality-sensitive tasks). This explicit choice overrides the selected agent type\'s model_preference; without either, secondary is the default when configured. Only effective when a secondary model is configured; otherwise subagents inherit your model. Resumed subagents always keep their own model.', + 'Which model to run the item-spawned subagents on: one of the aliases listed under "Available models" in this tool description, or "primary" for the main model you are running on (for hard, quality-sensitive tasks). When omitted, the configured default model is used. Resumed subagents always keep their own model.', ), }) .strict(); diff --git a/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts b/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts index b7d1a0b586..a7999260e5 100644 --- a/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts @@ -7,14 +7,16 @@ * per-subagent XML result. Reads persisted swarm item labels through the * Session-scoped coordinator so later `resume_agent_ids` calls relabel * resumed subagents like v1. When the caller has a model bound, the tool - * resolves the explicit or target-profile model preference up front via + * resolves the explicit tool `model` choice up front via * `resolveSubagentBinding` (against `IConfigService`, `IFlagService`, * `ISessionAgentProfileCatalog`, and the caller's `IAgentProfileService`) and * threads it through the swarm tasks; otherwise binding is left to the * service, which keeps its own "no model bound" check and inherit-caller - * fallback. The advertised `model` parameter lists the secondary/primary - * pair via `buildSubagentModelDescriptions`, suffixing each line with the - * entry's capability flags resolved through `IModelCatalog`. Swarm mode is + * fallback. The advertised `model` parameter lists the configured + * `[secondary_model.models]` pool via `buildSubagentModelDescriptions`; the + * pool is gated behind the `secondary-model` experiment, so while it is off + * (or under `[secondary_model].force`) the parameter is not advertised at + * all. Swarm mode is * entered through `IAgentSwarmService`; the caller's agent id comes from * `IAgentScopeContext`. Pure tool — owns no scoped state. * @@ -34,7 +36,6 @@ import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution' import { toInputJsonSchema } from '#/tool/input-schema'; import { IConfigService } from '#/app/config/config'; import { IFlagService } from '#/app/flag/flag'; -import { IModelCatalog } from '#/kosong/model/catalog'; import { ISessionSwarmService, type SessionSwarmTask } from '#/session/swarm/sessionSwarm'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { IAgentProfileService } from '#/agent/profile/profile'; @@ -46,11 +47,11 @@ import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentSwarmService } from '#/agent/swarm/swarm'; import { buildSubagentModelDescriptions, + exposesSubagentModelChoice, resolveSubagentBinding, resolveSubagentTimeoutMs, stripSubagentModelParameter, } from '#/session/subagent/configSection'; -import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; import { AgentSwarmToolInputSchema, IAgentSwarmTool, @@ -96,7 +97,7 @@ export class AgentSwarmTool implements IAgentSwarmTool { readonly name = 'AgentSwarm' as const; get parameters(): Record { - return this.flags.enabled(SECONDARY_MODEL_FLAG_ID) + return exposesSubagentModelChoice(this.config, this.flags) ? AGENT_SWARM_PARAMETERS : AGENT_SWARM_PARAMETERS_NO_MODEL; } @@ -111,7 +112,6 @@ export class AgentSwarmTool implements IAgentSwarmTool { @IFlagService private readonly flags: IFlagService, @ISessionAgentProfileCatalog private readonly catalog: ISessionAgentProfileCatalog, @IAgentProfileService private readonly profile: IAgentProfileService, - @IModelCatalog private readonly modelCatalog: IModelCatalog, ) { this.callerAgentId = scopeContext.agentId; } @@ -121,7 +121,6 @@ export class AgentSwarmTool implements IAgentSwarmTool { this.config, this.flags, this.profile.data().modelAlias, - this.modelCatalog, ); return modelLines === undefined ? AGENT_SWARM_DESCRIPTION @@ -190,7 +189,7 @@ export class AgentSwarmTool implements IAgentSwarmTool { this.config, this.flags, { modelAlias: own.modelAlias, thinkingLevel: own.thinkingLevel }, - args.model ?? targetProfile.modelPreference, + args.model, ); binding = { model: resolved.model, thinking: resolved.thinking }; } diff --git a/packages/agent-core-v2/src/agent/tools/agent/agent.ts b/packages/agent-core-v2/src/agent/tools/agent/agent.ts index 7bc0bda0f6..d1025ff21c 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agent.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agent.ts @@ -56,10 +56,10 @@ export const SubagentToolInputSchema = z.preprocess( 'If true, return immediately without waiting for completion. Prefer false unless the task can run independently and there is a clear benefit to not waiting.', ), model: z - .enum(['secondary', 'primary']) + .string() .optional() .describe( - 'Which model to run the subagent on: "secondary" = the configured secondary model; "primary" = the main model you are running on (for hard, quality-sensitive tasks). This explicit choice overrides the selected agent type\'s model_preference; without either, secondary is the default when configured. Only effective when a secondary model is configured; otherwise the subagent inherits your model. Ignored when resuming — resumed subagents keep their own model.', + 'Which model to run the subagent on: one of the aliases listed under "Available models" in this tool description, or "primary" for the main model you are running on (for hard, quality-sensitive tasks). When omitted, the configured default model is used. Ignored when resuming — resumed subagents keep their own model.', ), }), ); diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index 330549a1f5..6abcde9f8b 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -10,9 +10,14 @@ * under TaskList/TaskOutput/TaskStop when `run_in_background=true` or after * detach), and terminal text formatting. * - * Spawn bindings use an explicit tool choice first, then the target profile's - * symbolic model preference, before `resolveSubagentBinding` falls back to the - * configured secondary model or the caller's model. The selected alias is + * Spawn bindings use the explicit tool `model` choice first, before + * `resolveSubagentBinding` falls back to the configured `[secondary_model.models]` + * pool default or the caller's model; with `[secondary_model].force` set the + * `model` parameter is not advertised and every spawn binds `default_model`. + * The pool is gated behind the `secondary-model` experiment (via + * `IFlagService`): while it is off the `model` parameter is stripped and + * every spawn inherits the caller's model. + * The selected alias is * resolved through the model catalog before lifecycle allocation. A resumed * agent keeps the model recorded in its own wire journal — with per-subagent * models there is no "child follows the parent's current model" invariant to @@ -87,14 +92,13 @@ import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/subagent/mirrorAg import { ISessionSubagentService } from '#/session/subagent/subagent'; import { buildSubagentModelDescriptions, + exposesSubagentModelChoice, formatSubagentTimeoutDescription, resolveSubagentBinding, resolveSubagentTimeoutMs, stripSubagentModelParameter, - subagentDisplayModel, wrapSubagentModelError, } from '#/session/subagent/configSection'; -import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; import { BACKGROUND_AGENT_UNAVAILABLE, DEFAULT_PROFILE_NAME, @@ -120,7 +124,7 @@ export class SubagentTool implements ISubagentTool { readonly name: string = 'Agent'; get parameters(): Record { - return this.flags.enabled(SECONDARY_MODEL_FLAG_ID) + return exposesSubagentModelChoice(this.config, this.flags) ? SUBAGENT_TOOL_PARAMETERS : SUBAGENT_TOOL_PARAMETERS_NO_MODEL; } @@ -174,7 +178,6 @@ export class SubagentTool implements ISubagentTool { this.knownToolReferences(), (profile, name, source) => this.toolPolicy.isToolActiveForProfile(profile, name, source), - this.flags.enabled(SECONDARY_MODEL_FLAG_ID), ); if (typeLines) { description += `\n\nAvailable agent types (pass via subagent_type):\n${typeLines}`; @@ -183,7 +186,6 @@ export class SubagentTool implements ISubagentTool { this.config, this.flags, this.profile.data().modelAlias, - this.modelCatalog, ); if (modelLines !== undefined) { description += `\n\n${modelLines}`; @@ -284,10 +286,7 @@ export class SubagentTool implements ISubagentTool { agentId = target.id; const resumed = target.accessor.get(IAgentProfileService).data(); profileName = resumed.profileName ?? RESUMED_LABEL; - displayModel = - resumed.modelAlias === undefined - ? undefined - : subagentDisplayModel(this.config, resumed.modelAlias); + displayModel = resumed.modelAlias; } else { const requestedProfileName = args.subagent_type?.length ? args.subagent_type @@ -317,7 +316,7 @@ export class SubagentTool implements ISubagentTool { this.config, this.flags, { modelAlias: own.modelAlias, thinkingLevel: own.thinkingLevel }, - args.model ?? profile.modelPreference, + args.model, ); let created: IAgentScopeHandle; try { @@ -339,7 +338,7 @@ export class SubagentTool implements ISubagentTool { .inheritUserTools(requester.accessor.get(IAgentUserToolService)); agentId = created.id; profileName = profile.name; - displayModel = binding.displayModel; + displayModel = binding.model; promptText = await applyProfilePromptPrefix(profile, args.prompt, { cwd: this.workspace.workDir, runner: this.processRunner, @@ -535,7 +534,6 @@ function buildProfileDescriptions( name: string, source: ToolReference['source'], ) => boolean, - showModelPreferences: boolean, ): string { return profiles .map((profile) => { @@ -543,10 +541,6 @@ function buildProfileDescriptions( (part): part is string => part !== undefined && part.length > 0, ); const header = details.length === 0 ? `- ${profile.name}` : `- ${profile.name}: ${details.join(' ')}`; - const headerLines = - !showModelPreferences || profile.modelPreference === undefined - ? header - : `${header}\n Model preference: ${profile.modelPreference}`; const activeTools = resolveActiveToolNames(profile); const externallyRestricted = tools.some( (tool) => @@ -558,20 +552,20 @@ function buildProfileDescriptions( .filter((tool) => isToolActive(profile, tool.name, tool.source)) .map((tool) => tool.name); if (effectiveTools.length === 0) { - return `${headerLines}\n Tools: none`; + return `${header}\n Tools: none`; } - return `${headerLines}\n Tools: ${effectiveTools.join(', ')}`; + return `${header}\n Tools: ${effectiveTools.join(', ')}`; } if (activeTools === undefined) { if ((profile.disallowedTools?.length ?? 0) > 0) { - return `${headerLines}\n Tools: all except ${profile.disallowedTools!.join(', ')}`; + return `${header}\n Tools: all except ${profile.disallowedTools!.join(', ')}`; } - return `${headerLines}\n Tools: all`; + return `${header}\n Tools: all`; } if (activeTools.length === 0) { - return `${headerLines}\n Tools: none`; + return `${header}\n Tools: none`; } - return `${headerLines}\n Tools: ${activeTools.join(', ')}`; + return `${header}\n Tools: ${activeTools.join(', ')}`; }) .join('\n'); } diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts index 9f20446527..048cd5e94f 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts @@ -14,9 +14,7 @@ * `systemPrompt(context)` is the same render's text only — it is derived from * `renderSystemPrompt` at registration, so the two can never drift apart. * Profiles stay - * independent of concrete model aliases, but may declare - * a symbolic primary/secondary preference used as the default when spawned as - * a subagent. The builtin {@link DEFAULT_AGENT_PROFILE_NAME} (`agent`) is the + * independent of concrete model aliases. The builtin {@link DEFAULT_AGENT_PROFILE_NAME} (`agent`) is the * default profile used when an Agent is bound to a Model without naming a * profile. * @@ -43,8 +41,6 @@ import type { ISessionProcessRunner } from '#/session/process/processRunner'; export const DEFAULT_AGENT_PROFILE_NAME = 'agent'; -export type AgentModelPreference = 'primary' | 'secondary'; - export interface AgentProfilePromptPrefixContext { readonly cwd: string; readonly runner: ISessionProcessRunner; @@ -95,7 +91,6 @@ export interface AgentProfile { readonly tools?: readonly string[]; readonly disallowedTools?: readonly string[]; readonly subagents?: readonly string[]; - readonly modelPreference?: AgentModelPreference; readonly systemPrompt: (context: AgentProfileContext) => string; readonly renderSystemPrompt: (context: AgentProfileContext) => SystemPromptRenderResult; readonly promptPrefix?: (ctx: AgentProfilePromptPrefixContext) => Promise; diff --git a/packages/agent-core-v2/src/app/kosongConfig/configSection.ts b/packages/agent-core-v2/src/app/kosongConfig/configSection.ts index 7af26196b3..68382257d5 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/configSection.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/configSection.ts @@ -2,13 +2,13 @@ * `kosongConfig` domain — config-section declarations for kosong. * * The persistence wrapper for kosong's provider/model registries and the - * thinking / model-catalog / secondary-model preferences: declares every + * thinking / model-catalog preferences: declares every * kosong-owned section constant and its zod schema, plus the env bindings / * write-path strips and the snake_case ↔ camelCase TOML transforms. Where * kosong owns a pure type (`providers` / `models` / `thinking`), the schema * is re-derived from it and pinned by an `AssertExact` assertion (schema ≡ - * type at compile time); `modelCatalog` and `secondaryModel` have no - * kosong-side type — theirs derive from the local schemas. Self-registered + * type at compile time); `modelCatalog` has no + * kosong-side type — its derives from the local schema. Self-registered * at module load via `registerConfigSection`. * * `ProviderTypeSchema` is deliberately free-form text: vendor identity is @@ -25,7 +25,6 @@ import { z } from 'zod'; import { type ConfigStripEnv, envBindings, - stripEnvBoundFields, } from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; import { @@ -311,33 +310,6 @@ registerConfigSection(THINKING_SECTION, ThinkingConfigSchema, { stripEnv: stripThinkingEnv, }); -export const SECONDARY_MODEL_SECTION = 'secondaryModel'; - -export const SECONDARY_MODEL_ENV = 'KIMI_SECONDARY_MODEL'; -export const SECONDARY_MODEL_EFFORT_ENV = 'KIMI_SECONDARY_EFFORT'; - -export const SecondaryModelConfigSchema = ModelOverrideSchema.extend({ - model: z.string().min(1).optional(), -}); - -export type SecondaryModelConfig = z.infer; - -function parseNonEmptyEnv(raw: string): string | undefined { - const trimmed = raw.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -export const secondaryModelEnvBindings = envBindings(SecondaryModelConfigSchema, { - model: { env: SECONDARY_MODEL_ENV, parse: parseNonEmptyEnv }, - defaultEffort: { env: SECONDARY_MODEL_EFFORT_ENV, parse: parseNonEmptyEnv }, -}); - -registerConfigSection(SECONDARY_MODEL_SECTION, SecondaryModelConfigSchema, { - env: secondaryModelEnvBindings, - stripEnv: stripEnvBoundFields(secondaryModelEnvBindings), -}); - - export const MODEL_CATALOG_SECTION = 'modelCatalog'; export const ModelCatalogConfigSchema = z.object({ diff --git a/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts index 76ffe7b5a4..390d49866f 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts @@ -29,6 +29,10 @@ * registries therefore never pass through a halfway-removed state — that * intermediate state was the source of the "provider/model not * configured" startup race against profile binding. + * A write that replaces the models table also folds the + * `[secondary_model]` subagent pool through `cascadeSubagentModelPool` + * into the same transition, so a refresh that drops an alias can never + * leave a dangling pool for the session-start validation to trip on. * - The env-synthesized `__kimi_env__` slice is never written to config: * it lives in the effective overlay, and the bridge's event-driven sync * carries it into the registries on its own. `defaultModel` / `thinking` @@ -70,6 +74,11 @@ import { PROVIDERS_SECTION, THINKING_SECTION, } from './configSection'; +import { + SECONDARY_MODEL_SECTION, + cascadeSubagentModelPool, + type SecondaryModelConfig, +} from '#/session/subagent/configSection'; import { IProviderDiscoveryService, type RefreshProviderModelsOptions, @@ -254,6 +263,16 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService { if ('thinking' in patch) { sections[THINKING_SECTION] = restoreDefault ? exclusion.thinking : patch.thinking; } + const nextModels = sections[MODELS_SECTION] as Record | undefined; + if (nextModels !== undefined) { + const cascadedPool = cascadeSubagentModelPool( + this.config.inspect(SECONDARY_MODEL_SECTION).userValue, + nextModels, + ); + if (cascadedPool !== undefined) { + sections[SECONDARY_MODEL_SECTION] = cascadedPool ?? undefined; + } + } await this.config.replaceSections(sections); return { providers: diff --git a/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts b/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts index bf56aac771..bd1c665399 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts @@ -24,7 +24,11 @@ * `setDefined` drops those), and the models.dev import swaps aliases in two * passes (drop, then re-add onto clean slots). The kosong persistence * bridge then pushes the change into the registries, which is also what - * invalidates the runtime model catalog. + * invalidates the runtime model catalog. Each FINAL models-table pass also + * folds the `[secondary_model]` subagent pool through + * `cascadeSubagentModelPool` (the drop passes deliberately skip it — the + * re-add pass is what the pool must agree with), so an import that drops a + * pooled alias never leaves a dangling pool for session-start validation. * * Both third-party fetches — the models.dev directory and the custom-registry * import — send the identity snapshot's `outboundUserAgent`, matching what @@ -53,6 +57,11 @@ import { modelsDevProviderModels, resolveModelsDevImport } from './modelsDev'; import { DEFAULT_MODEL_SECTION, MODELS_SECTION, PROVIDERS_SECTION } from './configSection'; import { ModelsDevImportErrors } from './errors'; import { IKosongConfigService } from './kosongConfig'; +import { + SECONDARY_MODEL_SECTION, + cascadeSubagentModelPool, + type SecondaryModelConfig, +} from '#/session/subagent/configSection'; import { IModelsDevImportService, PROVIDER_ID_PATTERN, @@ -133,6 +142,19 @@ export class ModelsDevImportService implements IModelsDevImportService { return this.config; } + private async cascadePool( + config: IConfigService, + nextModels: Record, + ): Promise { + const cascaded = cascadeSubagentModelPool( + config.inspect(SECONDARY_MODEL_SECTION).userValue, + nextModels, + ); + if (cascaded !== undefined) { + await config.replace(SECONDARY_MODEL_SECTION, cascaded); + } + } + private async doImportModelsDevProvider( options: ImportModelsDevProviderOptions, ): Promise { @@ -201,6 +223,7 @@ export class ModelsDevImportService implements IModelsDevImportService { nextModels[`${targetId}/${model.id}`] = modelsDevModelToRecord(targetId, model); } await config.replace(MODELS_SECTION, nextModels); + await this.cascadePool(config, nextModels); const firstModel = models[0]; if (firstModel !== undefined) { @@ -289,6 +312,7 @@ export class ModelsDevImportService implements IModelsDevImportService { } await config.replace(PROVIDERS_SECTION, applied.providers as ProvidersSection); await config.replace(MODELS_SECTION, (applied.models ?? {}) as ModelsSection); + await this.cascadePool(config, applied.models ?? {}); const firstEntry = Object.values(entries)[0]; const firstModelKey = firstEntry === undefined ? undefined : Object.keys(firstEntry.models)[0]; diff --git a/packages/agent-core-v2/src/app/kosongConfig/secondaryModelOverlay.ts b/packages/agent-core-v2/src/app/kosongConfig/secondaryModelOverlay.ts deleted file mode 100644 index 899fc387f0..0000000000 --- a/packages/agent-core-v2/src/app/kosongConfig/secondaryModelOverlay.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * `kosongConfig` domain — `[secondary_model]` derived-entry overlay. - * - * When the secondary-model recipe carries patch fields, synthesizes the - * derived registry entry (`SECONDARY_DERIVED_MODEL_ID`) into the effective - * `models` view: a copy of the pointed entry with the patch merged into its - * `overrides` block (patch wins conflicts) and `aliases` dropped, so the - * derived entry never competes in name/alias routing. Subagent binding then - * resolves it by name through the standard catalog path, and the patch rides - * the same `effectiveModelConfig` merge as any `models.*.overrides` - * (including its supportEfforts/defaultEffort pruning and input clamping). - * - * Like the env overlay, the synthesized entry lives ONLY in the in-memory - * effective view: `strip` removes it from `models` writes so it never - * reaches `config.toml`, and the persistence bridge's deep-equal guards keep - * the two-way sync silent. `strip` also rolls back a `defaultModel` pointer - * set to the derived id (restoring the raw value, mirroring the env - * overlay's pinned-pointer handling) — the pointer can never dangle on disk - * after the recipe is removed. Nothing is synthesized when the recipe has no - * patch fields (subagents bind the pointed entry directly), when - * `secondary.model` is unset, or when the pointed entry does not exist (the - * warning service reports the dangling pointer; spawn fails with the wrapped - * error). The id is reserved: a user-configured entry under it is stripped - * on write all the same. - * - * Self-registered at module load via `registerConfigOverlay`; it is imported - * for side effects after the env overlay, so a `secondary.model` pointing at - * the env-synthesized entry sees the already-applied env view. - */ - -import type { ConfigEffectiveOverlay } from '#/app/config/config'; -import { registerConfigOverlay } from '#/app/config/configOverlayContributions'; -import { isPlainObject } from '#/app/config/toml'; -import type { ModelOverride } from '#/kosong/model/model'; - -import { - DEFAULT_MODEL_SECTION, - MODELS_SECTION, - SECONDARY_MODEL_SECTION, - type SecondaryModelConfig, -} from './configSection'; - -export const SECONDARY_DERIVED_MODEL_ID = '__secondary__'; - -export function secondaryModelPatch( - secondary: SecondaryModelConfig | undefined, -): ModelOverride | undefined { - if (secondary === undefined) return undefined; - const { model: _model, ...patch } = secondary; - return Object.keys(patch).length > 0 ? patch : undefined; -} - -function asRecord(value: unknown): Record { - return isPlainObject(value) ? value : {}; -} - -function withoutKey(value: unknown, key: string): unknown { - if (!isPlainObject(value) || !(key in value)) return value; - const out: Record = { ...value }; - delete out[key]; - return out; -} - -export const secondaryModelOverlay: ConfigEffectiveOverlay = { - apply(effective, _getEnv, validate) { - const secondary = effective[SECONDARY_MODEL_SECTION] as SecondaryModelConfig | undefined; - const patch = secondaryModelPatch(secondary); - const baseId = secondary?.model; - if (patch === undefined || baseId === undefined || baseId === SECONDARY_DERIVED_MODEL_ID) { - return []; - } - const models = asRecord(effective[MODELS_SECTION]); - const base = models[baseId]; - if (!isPlainObject(base)) return []; - const { overrides: baseOverrides, aliases: _aliases, ...baseFields } = base; - const derived: Record = { - ...baseFields, - overrides: { ...asRecord(baseOverrides), ...patch }, - }; - effective[MODELS_SECTION] = validate(MODELS_SECTION, { - ...models, - [SECONDARY_DERIVED_MODEL_ID]: derived, - }); - return [MODELS_SECTION]; - }, - - strip(domain, value, rawSnake) { - switch (domain) { - case MODELS_SECTION: - return withoutKey(value, SECONDARY_DERIVED_MODEL_ID); - case DEFAULT_MODEL_SECTION: - if (value !== SECONDARY_DERIVED_MODEL_ID) return value; - return typeof rawSnake['default_model'] === 'string' - ? rawSnake['default_model'] - : undefined; - default: - return value; - } - }, -}; - -registerConfigOverlay(secondaryModelOverlay); 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 1558387748..9fcdddb4b4 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`, `secondary_model` (subagent model), `providers`, `models`, `thinking`, `permission`, `hooks`, `loop_control`, etc. +- **`config.toml`** — agent / runtime settings: `default_model`, `[secondary_model]` (experimental `secondary-model` flag: `default_model` / `[secondary_model.models]` subagent model pool / `force` to pin subagents to `default_model`; a lone legacy v1 `model` key is honored as a fallback default), `[subagent]` (`timeout_ms`), `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-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 3af2678add..13902b07eb 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -149,7 +149,6 @@ export * from '#/kosong/protocol/protocol'; export * from '#/kosong/protocol/protocolBase'; export * from '#/kosong/protocol/protocolTrait'; import '#/app/kosongConfig/envOverlay'; -import '#/app/kosongConfig/secondaryModelOverlay'; export * from '#/kosong/model/completionBudget'; export * from '#/kosong/model/hostRequestHeaders'; export * from '#/kosong/model/model'; @@ -165,12 +164,6 @@ export { ModelCatalogConfigSchema, type ModelCatalogConfig, } from '#/app/kosongConfig/configSection'; -export type { SecondaryModelConfig } from '#/app/kosongConfig/configSection'; -export { - SECONDARY_DERIVED_MODEL_ID, - secondaryModelOverlay, - secondaryModelPatch, -} from '#/app/kosongConfig/secondaryModelOverlay'; export * from '#/app/kosongConfig/kosongConfig'; export * from '#/app/kosongConfig/kosongConfigService'; export * from '#/kosong/model/modelOAuth'; @@ -398,8 +391,8 @@ export * from '#/workspace/workspaceMcp/workspaceMcpService'; export * from '#/session/subagent/subagent'; export * from '#/session/subagent/subagentService'; import '#/session/subagent/flag'; -export * from '#/session/subagent/secondaryModelWarning'; -export * from '#/session/subagent/secondaryModelWarningService'; +export * from '#/session/subagent/subagentModelsValidation'; +import '#/session/subagent/subagentModelsValidationService'; export * from '#/agent/tools/agent/subagent-task'; export { AGENT_RUN_PROMPT_ORIGIN } from '#/session/subagent/runAgentTurn'; export * from '#/session/subagent/mirrorAgentRun'; diff --git a/packages/agent-core-v2/src/session/subagent/configSection.ts b/packages/agent-core-v2/src/session/subagent/configSection.ts index 38c743ac3a..5140f67939 100644 --- a/packages/agent-core-v2/src/session/subagent/configSection.ts +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -1,57 +1,109 @@ /** - * `subagent` domain — subagent config-section schema, env binding, and + * `subagent` domain — subagent config-section schemas, env binding, and * timeout / model resolution. * - * Owns the `[subagent]` configuration section (`timeout_ms` on disk) together - * with the `KIMI_SUBAGENT_TIMEOUT_MS` env override (precedence: env > - * config.toml > 2h default). While - * the env var is set, `stripEnvBoundFields` restores the env-free raw value - * before persistence, so the override never leaks into `config.toml`. Per-run - * timeouts resolve through `resolveSubagentTimeoutMs`, and the timeout - * message renders with `formatSubagentTimeoutDescription`. + * Owns two on-disk sections: * - * The model half of the spawn binding is the secondary model (the - * `[secondary_model]` section on disk): when its - * experiment is enabled and the model is set, newly spawned subagents bind to - * it by default instead of inheriting the caller's model, and the - * `Agent`/`AgentSwarm` tools let the parent model pick per spawn via their - * `model` parameter. When unset, spawning behavior is unchanged (subagents - * inherit the caller's model). A recipe with patch fields binds the - * synthesized derived entry (`SECONDARY_DERIVED_MODEL_ID`); a pointer-only - * recipe binds the pointed entry directly. `default_effort` is passed as the - * explicit subagent thinking; without it the subagent resolves thinking + * - `[subagent]` — `timeout_ms`, together with the `KIMI_SUBAGENT_TIMEOUT_MS` + * env override (precedence: env > config.toml > 2h default). While the env + * var is set, `stripEnvBoundFields` restores the env-free raw value before + * persistence, so the override never leaks into `config.toml`. Per-run + * timeouts resolve through `resolveSubagentTimeoutMs`, and the timeout + * message renders with `formatSubagentTimeoutDescription`. The pool keys + * `default_model` / `models` are declared as deprecations on this section: + * they moved to `[secondary_model]` and their values here no longer apply. + * + * - `[secondary_model]` — the subagent model pool: `default_model` names the + * fallback model and the `[secondary_model.models]` table maps alias → + * description. A `default_model` without a `[secondary_model.models]` table + * stands on its own as an implicit single-entry pool (empty description) — + * the minimal "secondary model" configuration. As a compatibility fallback + * for the v1 engine's recipe, a lone legacy `model` key (likewise without a + * pool table) forms the same implicit single-entry pool, ranked below + * `default_model`; the recipe's patch fields (`default_effort`, ...) have + * no pool counterpart and are ignored by pool resolution — but the schema + * still declares them so validation never strips them and config + * reads/writes round-trip losslessly for the v1 engine, and `model` never + * substitutes for + * the pool table's required `default_model`. `force = true` instead + * removes the choice entirely: every spawn binds the resolved default + * (`default_model` ?? `model`), the tools hide the `model` parameter + * exactly like the no-pool case, and combining + * it with a `[secondary_model.models]` table is rejected — the table's only + * purpose is offering the main agent a choice. + * + * When a pool is configured (and not forced), newly spawned subagents + * bind to the pool's default model unless the parent model picks a pool alias + * — or `primary` (`PRIMARY_SUBAGENT_MODEL_CHOICE`), the always-available + * symbolic choice binding the caller's own model and thinking level — per + * spawn via the `Agent` / `AgentSwarm` tool `model` parameter. Pool bindings + * carry no explicit thinking level, so the subagent resolves thinking * naturally (global thinking config → the bound model's default effort) - * rather than inheriting the caller's level. Both tools resolve spawn - * bindings through `resolveSubagentBinding`, advertise the pair via - * `buildSubagentModelDescriptions` (each line suffixed with the entry's - * resolved capability flags, so the parent can route multimodal or - * thinking-heavy subagent tasks instead of guessing from the model id), - * and wrap spawn failures with - * `wrapSubagentModelError`; while the experiment is off they also strip the - * no-op `model` parameter from their advertised schemas via - * `stripSubagentModelParameter`. Spawn reporting reads the display-facing - * alias from `subagentDisplayModel`: the derived entry id means nothing to a - * user, so it resolves back to the recipe's base alias — flag-independent on - * purpose, since interpreting an already-persisted derived binding (resume) - * must keep working after the experiment is switched off. Self-registered - * at module load via `registerConfigSection`. + * rather than inheriting the caller's level. Without a pool, spawning + * behavior is unchanged (subagents inherit the caller's model) and the tools + * strip the no-op `model` parameter from their advertised schemas via + * `stripSubagentModelParameter`, so the concept never enters the prompt and a + * stray `model` argument is rejected instead of silently inheriting; the + * strip returns a shallow copy and never mutates the input, so callers can + * keep both schema variants as shared constants. `force = true` shares this + * hidden-parameter surface (see `exposesSubagentModelChoice`) while binding + * every spawn to the resolved default in `resolveSubagentBinding`. The whole + * pool is gated behind the `secondary-model` experimental flag (`flag.ts`): + * while the experiment is off the section is inert — the tools strip the + * `model` parameter, spawns bind the caller's model, and validation is + * skipped. + * + * Spawn bindings resolve through `resolveSubagentBinding`: a forced + * configuration short-circuits to the resolved default before anything else, and + * any explicit request — `primary` included — throws (defensive; the tools + * strip the parameter); `primary` + * short-circuits to the caller's own model+thinking; with no pool a stray + * non-`primary` request throws (defensive — the tools strip the parameter); + * with a pool the request must be a pool alias, an omitted request falls back + * to `default_model`, and anything else throws `CONFIG_INVALID` listing the + * available choices so the parent model can retry. The tools advertise the + * pool via `buildSubagentModelDescriptions`: the default model leads with a + * `[default]` marker, the remaining aliases follow in config order, and the + * caller's own alias is listed like any other pool entry (marked + * `[main model]`) — pool alias bindings carry no thinking, so the trailing + * `primary` line stays distinct from it: it binds the caller's model WITH the + * caller's current thinking level, and names the alias in parentheses when + * the caller is in the pool. An empty-string description renders a bare + * `- alias` line. Spawn failures are wrapped by `wrapSubagentModelError`: + * when the bound model is not the caller's own and the catalog failed on + * exactly that alias, the parent model gets guidance toward + * `[secondary_model.models]` instead of a bare resolution error. + * Cross-field validation is NOT part of the schema — it is enforced as + * `Error2(CONFIG_INVALID)` by `assertValidSubagentModelConfig` (run before + * session materialization by the session lifecycle, with the Session-scope + * validation service in `subagentModelsValidationService.ts` as backstop), + * which checks the `force` rules (a default — `default_model` or the legacy + * `model` fallback — required, a + * `[secondary_model.models]` table rejected) and delegates the pool checks to + * `assertValidSubagentModelPool`: the default must be present and name a + * pool key, every pool key must resolve through the model catalog, and the + * reserved `primary` alias is rejected outright — as a pool key it would be + * unreachable (explicit requests short-circuit to the caller's model) and + * would render a self-contradictory description. `resolveSubagentBinding` + * repeats the reserved-key and force-rule checks so a pool broken by a + * runtime config edit fails loudly at spawn instead of binding the wrong + * model; any other malformation the startup checks missed surfaces as the + * spawn-time errors above. Writes that rewrite the `[models]` table + * (provider removal/replace at the edge, background catalog refreshes) + * fold the pool through `cascadeSubagentModelPool` into the same atomic + * write — renamed aliases are repointed, dropped aliases filtered, and the + * whole section cleared when its effective default dangles (an emptied pool + * table folds into the implicit single-entry form — a default naming no + * pool key would fail validation) — so the startup + * validation never meets a pool orphaned by a write it did not see. + * Self-registered at module load via `registerConfigSection`. */ import { z } from 'zod'; import { Error2, ErrorCodes, isError2 } from '#/errors'; -import type { AgentModelPreference } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { isPlainObject } from '#/app/config/toml'; import type { IFlagService } from '#/app/flag/flag'; -import { - SECONDARY_MODEL_ENV, - SECONDARY_MODEL_SECTION, -} from '#/app/kosongConfig/configSection'; -import { - SECONDARY_DERIVED_MODEL_ID, - secondaryModelPatch, -} from '#/app/kosongConfig/secondaryModelOverlay'; -import { type SecondaryModelConfig } from '#/app/kosongConfig/configSection'; import { type EnvBindings, envBindings, @@ -59,12 +111,12 @@ import { type IConfigService, } from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; -import type { ModelCapability } from '#/kosong/contract/capability'; import type { IModelCatalog } from '#/kosong/model/catalog'; import { SECONDARY_MODEL_FLAG_ID } from './flag'; export const SUBAGENT_SECTION = 'subagent'; +export const SECONDARY_MODEL_SECTION = 'secondaryModel'; export const SubagentConfigSchema = z.object({ timeoutMs: z.number().int().min(0).optional(), @@ -72,6 +124,25 @@ export const SubagentConfigSchema = z.object({ export type SubagentConfig = z.infer; +export const SecondaryModelConfigSchema = z.object({ + defaultModel: z.string().min(1).optional(), + models: z.record(z.string(), z.string()).optional(), + force: z.boolean().optional(), + model: z.string().min(1).optional(), + maxContextSize: z.number().int().min(1).optional(), + maxInputSize: z.number().int().min(1).optional(), + maxOutputSize: z.number().int().min(1).optional(), + capabilities: z.array(z.string()).optional(), + displayName: z.string().optional(), + reasoningKey: z.string().optional(), + adaptiveThinking: z.boolean().optional(), + supportEfforts: z.array(z.string()).optional(), + defaultEffort: z.string().optional(), + offEffort: z.string().optional(), +}); + +export type SecondaryModelConfig = z.infer; + export const DEFAULT_SUBAGENT_TIMEOUT_MS = 2 * 60 * 60 * 1000; export const SUBAGENT_TIMEOUT_ENV = 'KIMI_SUBAGENT_TIMEOUT_MS'; @@ -94,8 +165,14 @@ registerConfigSection(SUBAGENT_SECTION, SubagentConfigSchema, { defaultValue: { timeoutMs: DEFAULT_SUBAGENT_TIMEOUT_MS }, env: subagentEnvBindings, stripEnv: stripSubagentEnv, + deprecations: [ + { key: 'default_model', replacement: 'secondary_model.default_model' }, + { key: 'models', replacement: 'secondary_model.models' }, + ], }); +registerConfigSection(SECONDARY_MODEL_SECTION, SecondaryModelConfigSchema); + export function resolveSubagentTimeoutMs(config: IConfigService): number { return ( config.get(SUBAGENT_SECTION)?.timeoutMs ?? @@ -103,91 +180,247 @@ export function resolveSubagentTimeoutMs(config: IConfigService): number { ); } -export type SubagentModelChoice = AgentModelPreference; +export const PRIMARY_SUBAGENT_MODEL_CHOICE = 'primary'; + +export interface SubagentModelPool { + readonly defaultModel?: string; + readonly models: Record; +} + +export function resolveSubagentModelPool(config: IConfigService): SubagentModelPool | undefined { + const section = config.get(SECONDARY_MODEL_SECTION); + if (section?.models !== undefined) { + return { defaultModel: section.defaultModel, models: section.models }; + } + if (section?.defaultModel !== undefined) { + return { defaultModel: section.defaultModel, models: { [section.defaultModel]: '' } }; + } + if (section?.model !== undefined) { + return { defaultModel: section.model, models: { [section.model]: '' } }; + } + return undefined; +} + +export const SECONDARY_MODEL_FORCE_REQUIRES_DEFAULT_MESSAGE = + '[secondary_model].default_model is required when [secondary_model].force is set'; + +export const SECONDARY_MODEL_FORCE_EXCLUDES_MODELS_MESSAGE = + '[secondary_model].force cannot be combined with [secondary_model.models]: the pool table only exists to offer the main agent a choice, and force removes that choice'; + +export function isSubagentModelForced(config: IConfigService): boolean { + return config.get(SECONDARY_MODEL_SECTION)?.force === true; +} + +export function exposesSubagentModelChoice(config: IConfigService, flags: IFlagService): boolean { + if (!flags.enabled(SECONDARY_MODEL_FLAG_ID)) return false; + if (isSubagentModelForced(config)) return false; + return resolveSubagentModelPool(config) !== undefined; +} + +export const SECONDARY_MODEL_DEFAULT_MODEL_REQUIRED_MESSAGE = + '[secondary_model].default_model is required when [secondary_model.models] is configured'; + +export const SECONDARY_MODEL_PRIMARY_MODEL_RESERVED_MESSAGE = `[secondary_model.models] key "${PRIMARY_SUBAGENT_MODEL_CHOICE}" is reserved: it always binds the caller's own model. Rename the pool entry.`; + +export function assertValidSubagentModelPool( + pool: SubagentModelPool, + modelCatalog: IModelCatalog, +): void { + if (Object.hasOwn(pool.models, PRIMARY_SUBAGENT_MODEL_CHOICE)) { + throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_PRIMARY_MODEL_RESERVED_MESSAGE, { + details: { + section: SECONDARY_MODEL_SECTION, + field: 'models', + model: PRIMARY_SUBAGENT_MODEL_CHOICE, + }, + }); + } + const aliases = Object.keys(pool.models); + if (pool.defaultModel === undefined) { + throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_DEFAULT_MODEL_REQUIRED_MESSAGE, { + details: { section: SECONDARY_MODEL_SECTION, field: 'defaultModel' }, + }); + } + if (!Object.hasOwn(pool.models, pool.defaultModel)) { + throw new Error2( + ErrorCodes.CONFIG_INVALID, + `[secondary_model].default_model "${pool.defaultModel}" is not a [secondary_model.models] key. Available models: ${aliases.join(', ')}.`, + { details: { model: pool.defaultModel, availableModels: aliases } }, + ); + } + for (const alias of aliases) { + try { + modelCatalog.get(alias); + } catch (error) { + throw new Error2( + ErrorCodes.CONFIG_INVALID, + `[secondary_model.models] entry "${alias}" could not be resolved: ${error instanceof Error ? error.message : String(error)}`, + { cause: error, details: { model: alias } }, + ); + } + } +} -export function resolveSecondaryModel( +export function assertValidSubagentModelConfig( config: IConfigService, flags: IFlagService, -): SecondaryModelConfig | undefined { - if (!flags.enabled(SECONDARY_MODEL_FLAG_ID)) return undefined; - return config.get(SECONDARY_MODEL_SECTION); + modelCatalog: IModelCatalog, +): void { + if (!flags.enabled(SECONDARY_MODEL_FLAG_ID)) return; + const section = config.get(SECONDARY_MODEL_SECTION); + if (section?.force === true) { + if (section.models !== undefined) { + throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_FORCE_EXCLUDES_MODELS_MESSAGE, { + details: { section: SECONDARY_MODEL_SECTION, field: 'force' }, + }); + } + if (section.defaultModel === undefined && section.model === undefined) { + throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_FORCE_REQUIRES_DEFAULT_MESSAGE, { + details: { section: SECONDARY_MODEL_SECTION, field: 'defaultModel' }, + }); + } + } + const pool = resolveSubagentModelPool(config); + if (pool !== undefined) assertValidSubagentModelPool(pool, modelCatalog); +} + +export function cascadeSubagentModelPool( + section: SecondaryModelConfig | undefined, + survivingModels: Record, + renamedAliases: ReadonlyMap = new Map(), +): SecondaryModelConfig | null | undefined { + if (section === undefined) return undefined; + const remap = (alias: string): string => renamedAliases.get(alias) ?? alias; + const nextDefault = section.defaultModel === undefined ? undefined : remap(section.defaultModel); + const nextLegacyDefault = section.model === undefined ? undefined : remap(section.model); + const effectiveDefault = nextDefault ?? nextLegacyDefault; + if (effectiveDefault !== undefined && !(effectiveDefault in survivingModels)) return null; + + let changed = nextDefault !== section.defaultModel || nextLegacyDefault !== section.model; + let nextPool: Record | undefined; + if (section.models !== undefined) { + nextPool = {}; + for (const [alias, description] of Object.entries(section.models)) { + const key = remap(alias); + if (!(key in survivingModels)) { + changed = true; + continue; + } + if (key !== alias) changed = true; + nextPool[key] = description; + } + if (Object.keys(nextPool).length === 0) { + nextPool = undefined; + changed = true; + } + } + if (!changed) return undefined; + return { ...section, defaultModel: nextDefault, model: nextLegacyDefault, models: nextPool }; } export function resolveSubagentBinding( config: IConfigService, flags: IFlagService, own: { modelAlias: string; thinkingLevel: string }, - requested?: SubagentModelChoice, -): { model: string; thinking?: string; displayModel: string } { - const secondary = resolveSecondaryModel(config, flags); - if (requested !== 'primary' && secondary?.model !== undefined) { - const model = - secondaryModelPatch(secondary) === undefined ? secondary.model : SECONDARY_DERIVED_MODEL_ID; - return { - model, - thinking: secondary.defaultEffort, - displayModel: subagentDisplayModel(config, model), - }; - } - return { - model: own.modelAlias, - thinking: own.thinkingLevel, - displayModel: subagentDisplayModel(config, own.modelAlias), - }; -} - -export function subagentDisplayModel( - config: IConfigService, - boundAlias: string, -): string { - if (boundAlias !== SECONDARY_DERIVED_MODEL_ID) return boundAlias; - return ( - config.get(SECONDARY_MODEL_SECTION)?.model ?? boundAlias - ); + requested?: string, +): { model: string; thinking?: string } { + const enabled = flags.enabled(SECONDARY_MODEL_FLAG_ID); + const section = config.get(SECONDARY_MODEL_SECTION); + if (enabled && section?.force === true) { + if (section.models !== undefined) { + throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_FORCE_EXCLUDES_MODELS_MESSAGE, { + details: { section: SECONDARY_MODEL_SECTION, field: 'force' }, + }); + } + const forcedModel = section.defaultModel ?? section.model; + if (forcedModel === undefined) { + throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_FORCE_REQUIRES_DEFAULT_MESSAGE, { + details: { section: SECONDARY_MODEL_SECTION, field: 'defaultModel' }, + }); + } + if (requested !== undefined) { + throw new Error2( + ErrorCodes.CONFIG_INVALID, + `Invalid model "${requested}": [secondary_model].force is set, so every subagent binds "${forcedModel}" (omit the model parameter).`, + { details: { model: requested } }, + ); + } + return { model: forcedModel }; + } + if (requested === PRIMARY_SUBAGENT_MODEL_CHOICE) { + return { model: own.modelAlias, thinking: own.thinkingLevel }; + } + const pool = enabled ? resolveSubagentModelPool(config) : undefined; + if (pool === undefined) { + if (requested !== undefined) { + throw new Error2( + ErrorCodes.CONFIG_INVALID, + `Invalid model "${requested}": no [secondary_model.models] pool is configured, so subagents inherit the caller's model (pass "primary" or omit the model parameter).`, + { details: { model: requested } }, + ); + } + return { model: own.modelAlias, thinking: own.thinkingLevel }; + } + if (Object.hasOwn(pool.models, PRIMARY_SUBAGENT_MODEL_CHOICE)) { + throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_PRIMARY_MODEL_RESERVED_MESSAGE, { + details: { + section: SECONDARY_MODEL_SECTION, + field: 'models', + model: PRIMARY_SUBAGENT_MODEL_CHOICE, + }, + }); + } + const choice = requested ?? pool.defaultModel; + if (choice === undefined) { + throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_DEFAULT_MODEL_REQUIRED_MESSAGE, { + details: { section: SECONDARY_MODEL_SECTION, field: 'defaultModel' }, + }); + } + if (!Object.hasOwn(pool.models, choice)) { + const available = [...Object.keys(pool.models), PRIMARY_SUBAGENT_MODEL_CHOICE]; + throw new Error2( + ErrorCodes.CONFIG_INVALID, + `Invalid model "${choice}". Available models: ${available.join(', ')}.`, + { details: { model: choice, availableModels: available } }, + ); + } + return { model: choice }; } export function buildSubagentModelDescriptions( config: IConfigService, flags: IFlagService, callerModelAlias: string | undefined, - modelCatalog: IModelCatalog, ): string | undefined { - const secondary = resolveSecondaryModel(config, flags); - const secondaryModel = secondary?.model; - if (secondaryModel === undefined || callerModelAlias === undefined) return undefined; - const boundSecondary = - secondaryModelPatch(secondary) === undefined ? secondaryModel : SECONDARY_DERIVED_MODEL_ID; - return [ - 'Available models (pass via model):', - `- secondary: ${secondaryModel} (default) — the configured secondary model; prefer it for routine subagent tasks${capabilitiesSuffix(resolvedCapabilities(modelCatalog, boundSecondary))}`, - `- primary: ${callerModelAlias} — the main model you are running on; use it for hard, quality-sensitive subagent tasks${capabilitiesSuffix(resolvedCapabilities(modelCatalog, callerModelAlias))}`, - ].join('\n'); -} - -const ADVERTISED_CAPABILITY_FLAGS = [ - 'image_in', - 'video_in', - 'audio_in', - 'thinking', - 'tool_use', - 'dynamically_loaded_tools', -] as const satisfies readonly (keyof ModelCapability)[]; - -function capabilitiesSuffix(capability: ModelCapability | undefined): string { - if (capability === undefined) return ''; - const names = ADVERTISED_CAPABILITY_FLAGS.filter((flag) => capability[flag] === true); - return `; capabilities: ${names.length === 0 ? 'none' : names.join(', ')}`; + if (!exposesSubagentModelChoice(config, flags)) return undefined; + const pool = resolveSubagentModelPool(config)!; + const lines = ['Available models (pass via model):']; + const defaultModel = pool.defaultModel; + const markersFor = (alias: string): string => { + const markers: string[] = []; + if (alias === defaultModel) markers.push('[default]'); + if (alias === callerModelAlias) markers.push('[main model]'); + return markers.length === 0 ? '' : ` ${markers.join(' ')}`; + }; + if (defaultModel !== undefined && Object.hasOwn(pool.models, defaultModel)) { + lines.push( + formatPoolLine(`${defaultModel}${markersFor(defaultModel)}`, pool.models[defaultModel]!), + ); + } + for (const [alias, description] of Object.entries(pool.models)) { + if (alias === defaultModel) continue; + lines.push(formatPoolLine(`${alias}${markersFor(alias)}`, description)); + } + const callerInPool = + callerModelAlias !== undefined && Object.hasOwn(pool.models, callerModelAlias); + lines.push( + `- ${PRIMARY_SUBAGENT_MODEL_CHOICE}${callerInPool ? ` (${callerModelAlias})` : ''}: the main model you are running on, bound with your current thinking level; use it for hard, quality-sensitive subagent tasks`, + ); + return lines.join('\n'); } -function resolvedCapabilities( - modelCatalog: IModelCatalog, - model: string, -): ModelCapability | undefined { - try { - return modelCatalog.get(model).capabilities; - } catch { - return undefined; - } +function formatPoolLine(label: string, description: string): string { + return description === '' ? `- ${label}` : `- ${label}: ${description}`; } export function stripSubagentModelParameter( @@ -213,22 +446,17 @@ export function wrapSubagentModelError( if (boundModel === callerModelAlias) return error; if (!isError2(error) || error.code !== ErrorCodes.CONFIG_INVALID) return error; if (error.details?.['model'] !== boundModel) return error; - const displayModel = - boundModel === SECONDARY_DERIVED_MODEL_ID - ? `the derived entry "${SECONDARY_DERIVED_MODEL_ID}"` - : `"${boundModel}"`; return new Error2( error.code, - `${error.message} (secondary model ${displayModel} comes from [secondary_model].model / ${SECONDARY_MODEL_ENV} — check that it names a valid [models] entry)`, + `${error.message} (subagent model "${boundModel}" comes from [secondary_model.models] — check that it names a valid [models] entry)`, { cause: error, name: error.name, details: { ...error.details, - secondaryModel: boundModel, - secondaryModelConfig: { - section: 'secondaryModel.model', - environment: SECONDARY_MODEL_ENV, + subagentModel: boundModel, + subagentModelConfig: { + section: 'secondary_model.models', }, }, }, diff --git a/packages/agent-core-v2/src/session/subagent/flag.ts b/packages/agent-core-v2/src/session/subagent/flag.ts index 67ec3795c0..9a0c7b3a5f 100644 --- a/packages/agent-core-v2/src/session/subagent/flag.ts +++ b/packages/agent-core-v2/src/session/subagent/flag.ts @@ -2,8 +2,8 @@ * `subagent` domain — registers the `secondary-model` experimental flag * into `flag`. * - * Gates secondary-model selection for newly spawned subagents, including the - * agent-facing model choices and startup validation warning. Off by default; + * Gates the subagent model pool for newly spawned subagents, including the + * agent-facing model choices and startup pool validation. Off by default; * enable via `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL`, the master * `KIMI_CODE_EXPERIMENTAL_FLAG`, or the `[experimental]` config section. */ diff --git a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts index 095a1cb705..b5a6f605e2 100644 --- a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts +++ b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts @@ -16,8 +16,7 @@ * Wire shape note: the signals are still named `subagent.spawned / started / * completed / failed` and telemetry still tracks `subagent_created` so existing * session recordings and dashboards stay valid. The spawned signal also - * reports the child's display-normalized model alias (the derived secondary - * entry resolves to its base alias) and its effective thinking effort, so + * reports the child's bound model alias and its effective thinking effort, so * clients can render both at spawn instead of waiting for the first * `agent.status.updated` frame. */ diff --git a/packages/agent-core-v2/src/session/subagent/secondaryModelWarning.ts b/packages/agent-core-v2/src/session/subagent/secondaryModelWarning.ts deleted file mode 100644 index 31017de140..0000000000 --- a/packages/agent-core-v2/src/session/subagent/secondaryModelWarning.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * `subagent` domain — `ISessionSecondaryModelWarningService` contract: - * early validation of the configured secondary model. - * - * The secondary-model pointer (`[secondary_model]` / `KIMI_SECONDARY_MODEL`) - * is otherwise validated lazily at spawn time, so a typo surfaces as a - * mid-conversation tool failure handed back to the parent model. This service - * front-loads the same resolution to session start (main-agent creation): an - * unresolvable model or an effort the model does not list becomes a `warning` - * event on the main agent's event bus, and stays cached for the edge to pull. - * A mid-session `[secondary_model]` change refreshes the cache through - * `recheckSecondaryModelWarning`. Session-scoped — one instance per session. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; - -export const SECONDARY_MODEL_INVALID_WARNING_CODE = 'secondary-model-invalid'; -export const SECONDARY_MODEL_EFFORT_WARNING_CODE = 'secondary-model-effort-not-listed'; - -export interface SecondaryModelWarning { - readonly code: string; - readonly message: string; -} - -export interface ISessionSecondaryModelWarningService { - readonly _serviceBrand: undefined; - getSecondaryModelWarning(): SecondaryModelWarning | undefined; - recheckSecondaryModelWarning(): SecondaryModelWarning | undefined; -} - -export const ISessionSecondaryModelWarningService: ServiceIdentifier = - createDecorator('sessionSecondaryModelWarningService'); diff --git a/packages/agent-core-v2/src/session/subagent/secondaryModelWarningService.ts b/packages/agent-core-v2/src/session/subagent/secondaryModelWarningService.ts deleted file mode 100644 index 16e8f4250f..0000000000 --- a/packages/agent-core-v2/src/session/subagent/secondaryModelWarningService.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * `subagent` domain — `ISessionSecondaryModelWarningService` implementation. - * - * When enabled through `flag`, runs the secondary-model check once per session - * when the main agent appears (`agentLifecycle` onDidCreate, or an - * already-present main at construction): - * resolves the pointed entry through the kosong `modelCatalog` and, when the - * recipe carries patch fields, checks `default_effort` against the patched - * `supportEfforts` (what the derived entry will carry) — on failure, caches a - * warning and publishes it as a `warning` event on the main agent's - * `eventBus`, and stays cached for the edge to pull. - * `recheckSecondaryModelWarning` recomputes - * the cache after a mid-session `[secondary_model]` change, re-publishing - * only when the warning actually changed. Never throws: a broken secondary - * model demotes to a notice here, with spawn-time resolution staying as the - * backstop. Bound at Session scope. - */ - -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope } from '#/app/scopes'; -import { - type IAgentScopeHandle, - ScopeActivation, - registerScopedService, -} from '#/_base/di/scope'; -import { IConfigService } from '#/app/config/config'; -import { IEventBus } from '#/app/event/eventBus'; -import { IFlagService } from '#/app/flag/flag'; -import { - SECONDARY_MODEL_EFFORT_ENV, - SECONDARY_MODEL_ENV, -} from '#/app/kosongConfig/configSection'; -import { IModelCatalog, type Model } from '#/kosong/model/catalog'; -import { secondaryModelPatch } from '#/app/kosongConfig/secondaryModelOverlay'; -import { normalizeRequestedThinkingEffort } from '#/kosong/model/thinking'; -import { - IAgentLifecycleService, - MAIN_AGENT_ID, -} from '#/session/agentLifecycle/agentLifecycle'; - -import { resolveSecondaryModel } from './configSection'; -import { - ISessionSecondaryModelWarningService, - SECONDARY_MODEL_EFFORT_WARNING_CODE, - SECONDARY_MODEL_INVALID_WARNING_CODE, - type SecondaryModelWarning, -} from './secondaryModelWarning'; - -// NOTE: stays Disposable — its own 'config' collides with the Fiber -export class SessionSecondaryModelWarningService - extends Disposable - implements ISessionSecondaryModelWarningService -{ - declare readonly _serviceBrand: undefined; - - private warning: SecondaryModelWarning | undefined; - private checked = false; - - constructor( - @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, - @IConfigService private readonly config: IConfigService, - @IFlagService private readonly flags: IFlagService, - @IModelCatalog private readonly modelCatalog: IModelCatalog, - ) { - super(); - this._register( - this.agentLifecycle.onDidCreate((handle) => { - if (handle.id === MAIN_AGENT_ID) this.check(handle); - }), - ); - const main = this.agentLifecycle.get(MAIN_AGENT_ID); - if (main !== undefined) this.check(main); - } - - getSecondaryModelWarning(): SecondaryModelWarning | undefined { - return this.warning; - } - - recheckSecondaryModelWarning(): SecondaryModelWarning | undefined { - const previous = this.warning; - this.warning = this.computeWarning(); - const changed = - previous?.code !== this.warning?.code || previous?.message !== this.warning?.message; - if (changed && this.warning !== undefined) { - this.agentLifecycle - .get(MAIN_AGENT_ID) - ?.accessor.get(IEventBus) - .publish({ - type: 'warning', - code: this.warning.code, - message: this.warning.message, - }); - } - return this.warning; - } - - private check(main: IAgentScopeHandle): void { - if (this.checked) return; - this.checked = true; - this.warning = this.computeWarning(); - if (this.warning !== undefined) { - main.accessor.get(IEventBus).publish({ - type: 'warning', - code: this.warning.code, - message: this.warning.message, - }); - } - } - - private computeWarning(): SecondaryModelWarning | undefined { - const secondary = resolveSecondaryModel(this.config, this.flags); - if (secondary?.model === undefined) return undefined; - let model: Model; - try { - model = this.modelCatalog.get(secondary.model); - } catch (error) { - return { - code: SECONDARY_MODEL_INVALID_WARNING_CODE, - message: - `Secondary model "${secondary.model}" (from [secondary_model].model / ${SECONDARY_MODEL_ENV}) ` + - `could not be resolved: ${error instanceof Error ? error.message : String(error)}. ` + - 'Subagent spawning will fail until this is fixed.', - }; - } - const patch = secondaryModelPatch(secondary); - return effortWarning( - secondary.model, - secondary.defaultEffort, - patch?.supportEfforts ?? model.supportEfforts, - ); - } -} - -function effortWarning( - alias: string, - effort: string | undefined, - supportEfforts: readonly string[] | undefined, -): SecondaryModelWarning | undefined { - const requested = normalizeRequestedThinkingEffort(effort); - if (requested === undefined || requested === 'off' || requested === 'on') return undefined; - const known = (supportEfforts ?? []) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); - if (known.length === 0 || known.includes(requested)) return undefined; - return { - code: SECONDARY_MODEL_EFFORT_WARNING_CODE, - message: - `Secondary model default effort "${requested}" (from [secondary_model].default_effort / ${SECONDARY_MODEL_EFFORT_ENV}) ` + - `is not listed for model "${alias}" (known: ${known.join(', ')}). ` + - 'Subagents may clamp or reject it.', - }; -} - -registerScopedService( - LifecycleScope.Session, - ISessionSecondaryModelWarningService, - SessionSecondaryModelWarningService, - ScopeActivation.OnScopeCreated, - 'subagent', -); diff --git a/packages/agent-core-v2/src/session/subagent/subagentModelsValidation.ts b/packages/agent-core-v2/src/session/subagent/subagentModelsValidation.ts new file mode 100644 index 0000000000..1e158d9a5e --- /dev/null +++ b/packages/agent-core-v2/src/session/subagent/subagentModelsValidation.ts @@ -0,0 +1,24 @@ +/** + * `subagent` domain — `ISessionSubagentModelsValidationService` contract: + * startup validation of the configured subagent model pool. + * + * The pool is primarily validated before session materialization by the + * session lifecycle (see `workspace/sessionLifecycle`); this service repeats + * the same check at Session-scope activation as a backstop, so a pool with a + * missing/out-of-pool `default_model`, a reserved `primary` key, or an + * unresolvable alias fails the session with `Error2(CONFIG_INVALID)` instead + * of degrading into a mid-conversation tool failure handed back to the + * parent model. Session-scoped — one instance per session; the contract + * carries no methods because the validation is the construction side effect. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface ISessionSubagentModelsValidationService { + readonly _serviceBrand: undefined; +} + +export const ISessionSubagentModelsValidationService: ServiceIdentifier = + createDecorator( + 'sessionSubagentModelsValidationService', + ); diff --git a/packages/agent-core-v2/src/session/subagent/subagentModelsValidationService.ts b/packages/agent-core-v2/src/session/subagent/subagentModelsValidationService.ts new file mode 100644 index 0000000000..6163ba9273 --- /dev/null +++ b/packages/agent-core-v2/src/session/subagent/subagentModelsValidationService.ts @@ -0,0 +1,46 @@ +/** + * `subagent` domain — `ISessionSubagentModelsValidationService` implementation. + * + * Backstop for the session lifecycle's pre-materialization check: validates + * the configured subagent model section (`[secondary_model.models]` + + * `[secondary_model].default_model`, plus the `force` rules) once per session + * at scope construction (`ScopeActivation.OnScopeCreated`), so a broken pool + * or forced model fails session creation with `Error2(CONFIG_INVALID)` even + * on paths that bypass the lifecycle service. Reads the section through + * `config` and resolves aliases through the model catalog — a lone + * `default_model` included, as the implicit single-entry pool; a session + * with neither pool nor force, or running with the `secondary-model` + * experiment off, is a no-op. The checks themselves live in + * `assertValidSubagentModelConfig` (configSection). Bound at Session scope. + */ + +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IConfigService } from '#/app/config/config'; +import { IFlagService } from '#/app/flag/flag'; +import { IModelCatalog } from '#/kosong/model/catalog'; + +import { assertValidSubagentModelConfig } from './configSection'; +import { ISessionSubagentModelsValidationService } from './subagentModelsValidation'; + +export class SessionSubagentModelsValidationService + implements ISessionSubagentModelsValidationService +{ + declare readonly _serviceBrand: undefined; + + constructor( + @IConfigService config: IConfigService, + @IFlagService flags: IFlagService, + @IModelCatalog modelCatalog: IModelCatalog, + ) { + assertValidSubagentModelConfig(config, flags, modelCatalog); + } +} + +registerScopedService( + LifecycleScope.Session, + ISessionSubagentModelsValidationService, + SessionSubagentModelsValidationService, + ScopeActivation.OnScopeCreated, + 'subagent', +); diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts index 811cb21ee7..651bbb7c38 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts @@ -30,7 +30,6 @@ import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMo import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentUserToolService } from '#/agent/userTool/userTool'; import { IEventBus } from '#/app/event/eventBus'; -import { IConfigService } from '#/app/config/config'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { applyProfilePromptPrefix } from '#/app/agentProfileCatalog/promptPrefix'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; @@ -42,10 +41,7 @@ import { } from '#/session/agentLifecycle/subagentMetadata'; import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/subagent/mirrorAgentRun'; import { ISessionSubagentService } from '#/session/subagent/subagent'; -import { - subagentDisplayModel, - wrapSubagentModelError, -} from '#/session/subagent/configSection'; +import { wrapSubagentModelError } from '#/session/subagent/configSection'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata, type AgentMeta } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionProcessRunner } from '#/session/process/processRunner'; @@ -94,7 +90,6 @@ export class SessionSwarmService implements ISessionSwarmService { @ISessionProcessRunner private readonly processRunner: ISessionProcessRunner, @ILogService private readonly log: ILogService, @IModelCatalog private readonly modelCatalog: IModelCatalog, - @IConfigService private readonly config: IConfigService, ) {} async getSwarmItem(args: { @@ -192,7 +187,7 @@ export class SessionSwarmService implements ISessionSwarmService { description: options.description, swarmIndex: options.swarmIndex, runInBackground: options.runInBackground, - model: subagentDisplayModel(this.config, binding.model), + model: binding.model, }); const promptText = await applyProfilePromptPrefix(profile, options.prompt, { cwd: this.sessionContext.cwd, @@ -227,10 +222,7 @@ export class SessionSwarmService implements ISessionSwarmService { description: options.description, swarmIndex: options.swarmIndex, runInBackground: options.runInBackground, - model: - resumedModel === undefined - ? undefined - : subagentDisplayModel(this.config, resumedModel), + model: resumedModel, }); } const request = retryTurn diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 2d4f322283..7942c9a2f0 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -67,7 +67,23 @@ * depends on MCP. * The session-level services whose subscriptions * must exist before the first agent / turn (external hooks, cron, the - * secondary-model startup warning) opt into `OnScopeCreated` activation. + * subagent model-pool startup validation) opt into `OnScopeCreated` activation. + * The subagent model pool itself is validated even earlier — at + * the top of `materializeSession`, before the MCP overlay, the session scope, + * and any persisted artifact come into existence, and again at the top of + * `fork` before the source session's files are copied — so a broken pool + * (or invalid `force` configuration) fails create/resume/fork without + * leaving orphaned session dirs or leaked + * overlay connections behind; the Session-scope validation service + * (`session/subagent/subagentModelsValidationService.ts`) repeats the same + * check at scope activation as a backstop for paths that bypass this service. + * That pre-flight awaits the kosong model/provider registries' `ready` + * alongside `config.ready` first: the catalog resolves aliases through those + * registries rather than the config document, so a cold bootstrap that + * creates a session before hydration completes must not fail a valid pool + * with `CONFIG_INVALID`. + * The pool is gated behind the `secondary-model` experiment, so with the + * experiment off these validations are no-ops and the section stays inert. */ import { randomUUID } from 'node:crypto'; @@ -129,6 +145,11 @@ import { createWireMetadataRecord, type WireRecord, } from '#/wire/record'; +import { IModelCatalog } from '#/kosong/model/catalog'; +import { IModelService } from '#/kosong/model/model'; +import { IProviderService } from '#/kosong/provider/provider'; +import { IFlagService } from '#/app/flag/flag'; +import { assertValidSubagentModelConfig } from '#/session/subagent/configSection'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; @@ -207,6 +228,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private readonly pluginAgentProfileLoader: IPluginAgentProfileLoader, @IWorkspaceDirs private readonly workspaceDirs: IWorkspaceDirs, @ISessionProcessRunner private readonly processRunner: ISessionProcessRunner, + @IModelCatalog private readonly modelCatalog: IModelCatalog, + @IModelService private readonly models: IModelService, + @IProviderService private readonly providers: IProviderService, + @IFlagService private readonly flags: IFlagService, ) { super(); } @@ -247,11 +272,17 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec return handle; } + private async assertSubagentModelPoolPreFlight(): Promise { + await Promise.all([this.config.ready, this.models.ready, this.providers.ready]); + assertValidSubagentModelConfig(this.config, this.flags, this.modelCatalog); + } + private async materializeSession(opts: MaterializeSessionOptions): Promise { const workspaceId = this.workspaceId; const sessionScope = sessionScopeOf(this.handlerScope, opts.sessionId); const sessionDir = sessionDirOf(this.bootstrap.homeDir, this.handlerScope, opts.sessionId); const metaScope = sessionScope; + await this.assertSubagentModelPoolPreFlight(); await this.workspaceDirs.ready; await this.workspaceDirs.mergeAdditionalDirs(opts.workDir, opts.additionalDirs ?? []); const ctx: ISessionContext = { @@ -488,6 +519,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec let target: ISessionScopeHandle | undefined; let targetSessionDir: string | undefined; try { + await this.assertSubagentModelPoolPreFlight(); // A turn that just ended may still have its outcome write queued; // settle pending metadata writes before reading the source for // inheritance, or the fork could copy a stale (or absent) outcome. diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFile.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFile.ts index 6d5eaba3a0..4b8a8871fd 100644 --- a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFile.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFile.ts @@ -94,7 +94,6 @@ export function parseAgentFileText(options: ParseAgentFileOptions): AgentFileDef 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) { @@ -109,24 +108,12 @@ export function parseAgentFileText(options: ParseAgentFileOptions): AgentFileDef 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; diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileFromFile.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileFromFile.ts index 66089adb5c..1e9cc2d36d 100644 --- a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileFromFile.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileFromFile.ts @@ -8,8 +8,7 @@ * marked as builtin overrides; directory files must opt in through frontmatter. * `tools` passes through as the allowlist (`undefined` = every tool active); * `disallowedTools` passes through as the tool denylist; `subagents` passes - * through as the delegation allowlist; `model_preference` becomes the - * symbolic default model used when the profile is delegated to. + * through as the delegation allowlist. * `profilesFromDiscovery` packs a whole discovery pass into an * `AgentProfileContribution`, binding each profile's `${base_prompt}` * placeholder lazily at render time so it always reflects the effective @@ -45,7 +44,6 @@ export function agentProfileFromFile( tools: definition.tools, disallowedTools: definition.disallowedTools, subagents: definition.subagents, - modelPreference: definition.modelPreference, renderSystemPrompt: (context) => renderPromptTemplateResult(definition.prompt, context, { skillActive }, basePrompt), }); diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/types.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/types.ts index 729653f012..f34c7dfec7 100644 --- a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/types.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/types.ts @@ -7,7 +7,6 @@ * Pure data; no scoped state. */ -import type { AgentModelPreference } from '#/app/agentProfileCatalog/agentProfileCatalog'; import type { SkippedAgentFile } from '#/app/agentProfileCatalog/agentProfileContribution'; export type { SkippedAgentFile } from '#/app/agentProfileCatalog/agentProfileContribution'; @@ -27,7 +26,6 @@ export interface AgentFileDefinition { readonly tools?: readonly string[]; readonly disallowedTools?: readonly string[]; readonly subagents?: readonly string[]; - readonly modelPreference?: AgentModelPreference; readonly prompt: string; readonly path: string; readonly source: AgentFileSource; diff --git a/packages/agent-core-v2/test/agent/profile/config-state.test.ts b/packages/agent-core-v2/test/agent/profile/config-state.test.ts index 0f198a4af5..870f7900f0 100644 --- a/packages/agent-core-v2/test/agent/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/agent/profile/config-state.test.ts @@ -3,7 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester'; import { IAgentProfileService } from '#/agent/profile/profile'; -import { SECONDARY_DERIVED_MODEL_ID } from '#/app/kosongConfig/secondaryModelOverlay'; import type { ModelRecord } from '#/kosong/model/model'; import { configServices, @@ -120,19 +119,6 @@ describe('ConfigState model capabilities', () => { }); }); - it('reports the recipe base alias when bound to the derived secondary entry', () => { - kimiConfig = { - providers: {}, - secondaryModel: { model: 'provider/secondary', defaultEffort: 'low' }, - } as TestKimiConfig; - - profile.update({ modelAlias: SECONDARY_DERIVED_MODEL_ID }); - - const statuses = ctx.allEvents.filter((entry) => entry.event === 'agent.status.updated'); - const last = statuses.at(-1)?.args as { model?: string }; - expect(last.model).toBe('provider/secondary'); - }); - it('omits maxContextTokens when the bound model no longer resolves', () => { // `update` accepts an alias without validating resolvability; a model entry // removed from config afterwards lands in the same state. The capabilities diff --git a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts index 27f2aff6ab..6e9fb08114 100644 --- a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts @@ -16,6 +16,7 @@ import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; import { ILogService } from '#/_base/log/log'; import { stubLog } from '../../_base/log/stubs'; +import { stubFlag } from '../../app/flag/stubs'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { AgentContextInjectorService } from '#/agent/contextInjector/contextInjectorService'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; @@ -36,7 +37,6 @@ import { IAgentSwarmService } from '#/agent/swarm/swarm'; import { AgentSwarmService } from '#/agent/swarm/swarmService'; import SWARM_MODE_ENTER_REMINDER from '../../../src/agent/swarm/enter-reminder.md?raw'; import { SwarmModel } from '#/agent/swarm/swarmOps'; -import { SECONDARY_DERIVED_MODEL_ID } from '#/app/kosongConfig/secondaryModelOverlay'; import { AgentSwarmToolInputSchema } from '#/agent/tools/agent-swarm/agent-swarm'; import { AgentSwarmTool } from '#/agent/tools/agent-swarm/agentSwarmTool'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; @@ -46,8 +46,6 @@ import type { ResolvedToolExecutionHookContext, } from '#/agent/toolExecutor/toolHooks'; import type { ToolCall } from '#/kosong/contract/message'; -import type { ModelCapability } from '#/kosong/contract/capability'; -import { IModelCatalog } from '#/kosong/model/catalog'; import type { ExecutableToolContext } from '#/tool/toolContract'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService'; @@ -69,7 +67,6 @@ import { executeTool } from '../../tools/fixtures/execute-tool'; import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs'; import { stubLoopWithHooks } from '../loop/stubs'; import { stubToolExecutorEvents, type ToolExecutorEventStubs } from '../toolExecutor/stubs'; -import { stubFlag } from '../../app/flag/stubs'; import { createTestAgent } from '../../harness'; const signal = new AbortController().signal; @@ -139,8 +136,8 @@ function mockSwarmMode() { function stubConfig(section?: { timeoutMs?: number; - model?: string; - defaultEffort?: string; + defaultModel?: string; + models?: Record; }): IConfigService { return { _serviceBrand: undefined, @@ -194,19 +191,6 @@ function stubCallerProfile( } as unknown as IAgentProfileService; } -function stubModelCatalog( - capabilities: Readonly> = {}, -): IModelCatalog { - return { - _serviceBrand: undefined, - get: (id: string) => { - const capability = capabilities[id]; - if (capability === undefined) throw new Error(`Model "${id}" is not configured.`); - return { capabilities: capability }; - }, - } as unknown as IModelCatalog; -} - describe('AgentSwarmService', () => { let disposables: DisposableStore; let ix: TestInstantiationService; @@ -581,7 +565,7 @@ describe('AgentSwarmTool', () => { ]), }); const swarmMode = mockSwarmMode(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), swarmMode, stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile(), stubModelCatalog()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), swarmMode, stubConfig({ defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap' } }), stubFlag(true), stubSwarmCatalog(), stubCallerProfile()); const input = { description: 'Review files', prompt_template: 'Review {{item}}', @@ -678,7 +662,7 @@ describe('AgentSwarmTool', () => { it('does not expose permission rule argument matching', () => { const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile(), stubModelCatalog()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile()); const execution = tool.resolveExecution({ description: 'Review files', prompt_template: 'Review {{item}}', @@ -693,7 +677,7 @@ describe('AgentSwarmTool', () => { it('description states the enforced input requirements', () => { const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile(), stubModelCatalog()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile()); expect(tool.description).toContain('at least 2'); expect(tool.description).toContain('{{item}}'); expect(tool.description.toLowerCase()).toContain('distinct'); @@ -715,7 +699,6 @@ describe('AgentSwarmTool', () => { stubFlag(true), stubSwarmCatalog(caller), stubCallerProfile({ profileName: 'deleted-profile', subagents: ['explore'] }), - stubModelCatalog(), ); const result = await executeTool( @@ -779,7 +762,7 @@ describe('AgentSwarmTool', () => { for (const testCase of cases) { const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile(), stubModelCatalog()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile()); const result = await executeTool(tool, context(testCase.input)); @@ -812,7 +795,7 @@ describe('AgentSwarmTool', () => { async ({ agentId }: { readonly agentId: string }) => persistedItems[agentId], ); const host = mockSwarmHost({ run, getSwarmItem }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile(), stubModelCatalog()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile()); const input = { description: 'Finish review', subagent_type: 'explore', @@ -932,7 +915,7 @@ describe('AgentSwarmTool', () => { ); const getSwarmItem = vi.fn(async () => 'src/old-a.ts'); const host = mockSwarmHost({ run, getSwarmItem }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile(), stubModelCatalog()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile()); const input = { description: 'Resume review', resume_agent_ids: { @@ -995,7 +978,7 @@ describe('AgentSwarmTool', () => { }, ]), }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile(), stubModelCatalog()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile()); const result = await executeTool( tool, @@ -1021,7 +1004,7 @@ describe('AgentSwarmTool', () => { it('passes the configured subagent timeout to swarm tasks', async () => { const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ timeoutMs: 5_000 }), stubFlag(true), stubSwarmCatalog(), stubCallerProfile(), stubModelCatalog()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ timeoutMs: 5_000 }), stubFlag(true), stubSwarmCatalog(), stubCallerProfile()); await executeTool( tool, @@ -1042,9 +1025,9 @@ describe('AgentSwarmTool', () => { ); }); - it('resolves spawn task bindings from the configured secondary model', async () => { + it('resolves spawn task bindings from the configured model pool default', async () => { const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ model: 'provider/secondary', defaultEffort: 'low' }), stubFlag(true), stubSwarmCatalog(), stubCallerProfile({ modelAlias: 'main-model', thinkingLevel: 'high' }), stubModelCatalog()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap', 'provider/smart': 'hard tasks' } }), stubFlag(true), stubSwarmCatalog(), stubCallerProfile({ modelAlias: 'main-model', thinkingLevel: 'high' })); await executeTool( tool, @@ -1058,8 +1041,8 @@ describe('AgentSwarmTool', () => { expect(host.swarmService.run).toHaveBeenCalledWith( expect.objectContaining({ tasks: [ - expect.objectContaining({ binding: { model: SECONDARY_DERIVED_MODEL_ID, thinking: 'low' } }), - expect.objectContaining({ binding: { model: SECONDARY_DERIVED_MODEL_ID, thinking: 'low' } }), + expect.objectContaining({ binding: { model: 'provider/fast', thinking: undefined } }), + expect.objectContaining({ binding: { model: 'provider/fast', thinking: undefined } }), ], }), ); @@ -1067,13 +1050,7 @@ describe('AgentSwarmTool', () => { it('lets the tool call opt back into the primary model', async () => { const host = mockSwarmHost(); - const secondaryCoder: AgentProfile = normalizeAgentProfile({ - name: 'coder', - description: 'test coder', - modelPreference: 'secondary', - systemPrompt: () => 'coder', - }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ model: 'provider/secondary', defaultEffort: 'low' }), stubFlag(true), stubSwarmCatalog(DEFAULT_CALLER_PROFILE, [secondaryCoder]), stubCallerProfile({ modelAlias: 'main-model', thinkingLevel: 'high' }), stubModelCatalog()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap' } }), stubFlag(true), stubSwarmCatalog(), stubCallerProfile({ modelAlias: 'main-model', thinkingLevel: 'high' })); await executeTool( tool, @@ -1095,48 +1072,23 @@ describe('AgentSwarmTool', () => { ); }); - it('advertises both selectable models in the description only when configured', async () => { + it('advertises the configured pool in the description only when configured', async () => { const host = mockSwarmHost(); - const configured = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ model: 'provider/secondary' }), stubFlag(true), stubSwarmCatalog(), stubCallerProfile({ modelAlias: 'main-model' }), stubModelCatalog({ - 'provider/secondary': { image_in: true, video_in: false, audio_in: false, thinking: true, tool_use: true, max_context_tokens: 262_144 }, - 'main-model': { image_in: false, video_in: false, audio_in: false, thinking: false, tool_use: true, max_context_tokens: 262_144 }, - })); + const configured = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap', 'main-model': 'the main model' } }), stubFlag(true), stubSwarmCatalog(), stubCallerProfile({ modelAlias: 'main-model' })); expect(configured.description).toContain('Available models (pass via model):'); + expect(configured.description).toContain('- provider/fast [default]: fast and cheap'); + // The caller's alias is a normal pool entry; the primary line stays distinct. + expect(configured.description).toContain('- main-model [main model]: the main model'); expect(configured.description).toContain( - '- secondary: provider/secondary (default) — the configured secondary model; prefer it for routine subagent tasks; capabilities: image_in, thinking, tool_use', - ); - expect(configured.description).toContain( - '- primary: main-model — the main model you are running on; use it for hard, quality-sensitive subagent tasks; capabilities: tool_use', + '- primary (main-model): the main model you are running on, bound with your current thinking level', ); - const unconfigured = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile({ modelAlias: 'main-model' }), stubModelCatalog()); + const unconfigured = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile({ modelAlias: 'main-model' })); expect(unconfigured.description).not.toContain('Available models'); }); - it('reads secondary capabilities from the derived entry when the recipe carries patch fields', async () => { - const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ model: 'provider/secondary', defaultEffort: 'low' }), stubFlag(true), stubSwarmCatalog(), stubCallerProfile({ modelAlias: 'main-model' }), stubModelCatalog({ - [SECONDARY_DERIVED_MODEL_ID]: { image_in: false, video_in: false, audio_in: false, thinking: true, tool_use: true, max_context_tokens: 131_072 }, - 'main-model': { image_in: true, video_in: false, audio_in: false, thinking: false, tool_use: true, max_context_tokens: 262_144 }, - })); - - expect(tool.description).toContain( - '- secondary: provider/secondary (default) — the configured secondary model; prefer it for routine subagent tasks; capabilities: thinking, tool_use', - ); - expect(tool.description).toContain('capabilities: image_in, tool_use'); - }); - - it('omits the capabilities suffix for models the catalog cannot resolve', async () => { - const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ model: 'provider/secondary' }), stubFlag(true), stubSwarmCatalog(), stubCallerProfile({ modelAlias: 'main-model' }), stubModelCatalog()); - - expect(tool.description).toContain('- secondary: provider/secondary (default)'); - expect(tool.description).toContain('- primary: main-model'); - expect(tool.description).not.toContain('capabilities:'); - }); - it('omits resume hint when incomplete subagents have no agent ids', async () => { const host = mockSwarmHost({ run: vi.fn().mockImplementation(async ({ tasks }) => [ @@ -1152,7 +1104,7 @@ describe('AgentSwarmTool', () => { }, ]), }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile(), stubModelCatalog()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile()); const result = await executeTool( tool, @@ -1199,7 +1151,7 @@ describe('AgentSwarmTool', () => { }, ]), }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile(), stubModelCatalog()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile()); const result = await executeTool( tool, diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts index 0f5d0a5bde..20ba75fc9e 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts @@ -306,6 +306,51 @@ describe('AgentToolExecutorService', () => { }); }); + it('recompiles the cached args validator when a tool advertises a different schema object', async () => { + const inner = new TestTool('dynamic'); + let currentSchema: Record = { + type: 'object', + properties: { value: { type: 'number' } }, + required: ['value'], + additionalProperties: false, + }; + const tool: ExecutableTool> = { + name: inner.name, + description: inner.description, + get parameters() { + return currentSchema; + }, + resolveExecution: (args) => inner.resolveExecution(args), + }; + registry.register(tool); + + const rejected = await execute([ + toolCall('call_strict', 'dynamic', { value: 1, model: 'fast' }), + ]); + + expect(rejected).toEqual([ + expect.objectContaining({ + output: expect.stringContaining('Invalid args for tool "dynamic"'), + isError: true, + }), + ]); + expect(inner.calls).toEqual([]); + + currentSchema = { + type: 'object', + properties: { value: { type: 'number' }, model: { type: 'string' } }, + required: ['value'], + additionalProperties: false, + }; + const accepted = await execute([ + toolCall('call_open', 'dynamic', { value: 1, model: 'fast' }), + ]); + + expect(accepted).toEqual([expect.objectContaining({ stopTurn: false })]); + expect(inner.calls).toHaveLength(1); + expect(inner.calls[0]?.args).toEqual({ value: 1, model: 'fast' }); + }); + it('routes malformed JSON args through schema validation', async () => { const tool = new TestTool('strict', { parameters: { diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index 4db3d17adf..aa00d63d95 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -19,6 +19,7 @@ import { normalizeAgentProfile } from '#/app/agentProfileCatalog/agentProfileCat import { Error2, ErrorCodes, + isError2, resetUnexpectedErrorHandler, setUnexpectedErrorHandler, toErrorPayload, @@ -42,7 +43,6 @@ import { } from '#/app/config/config'; import { ConfigRegistry, ConfigService } from '#/app/config/configService'; import { ConfigSectionContribution } from '#/app/config/configSectionContributions'; -import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; import '#/app/cron/configSection'; import type { CronConfig } from '#/app/cron/configSection'; import '#/app/skillCatalog/configSection'; @@ -73,9 +73,6 @@ import { DEFAULT_MODEL_SECTION, MODELS_SECTION, PROVIDERS_SECTION, - SECONDARY_MODEL_EFFORT_ENV, - SECONDARY_MODEL_ENV, - SECONDARY_MODEL_SECTION, THINKING_SECTION, } from '#/app/kosongConfig/configSection'; import { type ThinkingConfig } from '#/kosong/model/thinking'; @@ -90,15 +87,17 @@ import { applyPrintModeConfigDefaults } from '#/agent/task/printDefaults'; import '#/session/subagent/configSection'; import { DEFAULT_SUBAGENT_TIMEOUT_MS, - resolveSecondaryModel, resolveSubagentBinding, + resolveSubagentModelPool, resolveSubagentTimeoutMs, + SECONDARY_MODEL_SECTION, SUBAGENT_SECTION, SUBAGENT_TIMEOUT_ENV, - subagentDisplayModel, + type SecondaryModelConfig, type SubagentConfig, wrapSubagentModelError, } from '#/session/subagent/configSection'; +import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; import { SERVICES_SECTION, WEB_FETCH_API_KEY_ENV, @@ -107,8 +106,6 @@ import { WEB_SEARCH_BASE_URL_ENV, type ServicesConfig, } from '#/app/auth/configSection'; -import { SECONDARY_DERIVED_MODEL_ID } from '#/app/kosongConfig/secondaryModelOverlay'; -import { type SecondaryModelConfig } from '#/app/kosongConfig/configSection'; import '#/app/mcpConfig/configSection'; import { MCP_SECTION, @@ -123,8 +120,12 @@ import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { IAtomicTomlDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { TomlAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; import { stubBootstrap } from '../bootstrap/stubs'; -import { stubFlag } from '../flag/stubs'; import { stubLog } from '../../_base/log/stubs'; +import { stubFlag } from '../flag/stubs'; + +function secondaryModelFlags(enabled = true) { + return stubFlag((id) => enabled && id === SECONDARY_MODEL_FLAG_ID); +} const TEST_OS_ENV = { osKind: 'Linux', @@ -134,10 +135,6 @@ const TEST_OS_ENV = { shellPath: '/bin/bash', } as const; -function secondaryModelFlags(enabled = true) { - return stubFlag((id) => enabled && id === SECONDARY_MODEL_FLAG_ID); -} - describe('Agent config', () => { let ctx: TestAgentContext; let profile: IAgentProfileService; @@ -1211,6 +1208,31 @@ describe('config deprecations', () => { disposables.dispose(); }); + it('warns and ignores the legacy [subagent] pool keys, which moved to [secondary_model]', async () => { + const { config, disposables } = await createConfig( + {}, + '[subagent]\ndefault_model = "provider/fast"\n\n[subagent.models]\n"provider/fast" = "fast and cheap"\n', + ); + + // The old values no longer apply — the pool only resolves from + // [secondary_model] now. + expect(resolveSubagentModelPool(config)).toBeUndefined(); + expect(config.diagnostics()).toContainEqual({ + domain: SUBAGENT_SECTION, + severity: 'warning', + message: + "[subagent] 'default_model' is deprecated and no longer used; rename it to 'secondary_model.default_model'. Run /update-config to fix it.", + }); + expect(config.diagnostics()).toContainEqual({ + domain: SUBAGENT_SECTION, + severity: 'warning', + message: + "[subagent] 'models' is deprecated and no longer used; rename it to 'secondary_model.models'. Run /update-config to fix it.", + }); + + disposables.dispose(); + }); + it('lets the replacement key win when both are present, still warning', async () => { const { config, disposables } = await createConfig( {}, @@ -1729,109 +1751,279 @@ describe('subagent config section', () => { disposables.dispose(); }); - it('resolves the spawn binding: secondary by default, primary on request, inherit otherwise', async () => { + it('reads default_model and [secondary_model.models] from config.toml', async () => { + const { config, disposables } = await createConfig( + {}, + '[secondary_model]\ndefault_model = "provider/fast"\n\n[secondary_model.models]\n"provider/fast" = "fast and cheap"\n"provider/smart" = ""\n', + ); + + expect(config.get(SECONDARY_MODEL_SECTION)).toEqual({ + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast and cheap', 'provider/smart': '' }, + }); + + disposables.dispose(); + }); + + it('resolves the spawn binding: pool default, explicit alias, primary opt-in, inherit without pool', async () => { const own = { modelAlias: 'provider/main', thinkingLevel: 'medium' }; - const noModel = await createConfig({}); - expect(resolveSubagentBinding(noModel.config, secondaryModelFlags(), own)).toEqual({ + const noPool = await createConfig({}); + expect(resolveSubagentBinding(noPool.config, secondaryModelFlags(), own)).toEqual({ model: 'provider/main', thinking: 'medium', - displayModel: 'provider/main', }); - expect(resolveSubagentBinding(noModel.config, secondaryModelFlags(), own, 'secondary')).toEqual({ + expect(resolveSubagentBinding(noPool.config, secondaryModelFlags(), own, 'primary')).toEqual({ model: 'provider/main', thinking: 'medium', - displayModel: 'provider/main', }); - noModel.disposables.dispose(); + noPool.disposables.dispose(); - const withModel = await createConfig({}, '[secondary_model]\nmodel = "provider/secondary"\n'); - expect(resolveSubagentBinding(withModel.config, secondaryModelFlags(), own)).toEqual({ - model: 'provider/secondary', + const pool = await createConfig( + {}, + '[secondary_model]\ndefault_model = "provider/fast"\n\n[secondary_model.models]\n"provider/fast" = "fast and cheap"\n"provider/smart" = "hard tasks"\n', + ); + // An omitted model falls back to the pool default; pool bindings carry no + // explicit thinking (the subagent resolves thinking naturally). + expect(resolveSubagentBinding(pool.config, secondaryModelFlags(), own)).toEqual({ + model: 'provider/fast', + thinking: undefined, + }); + // A pool alias binds directly. + expect(resolveSubagentBinding(pool.config, secondaryModelFlags(), own, 'provider/smart')).toEqual({ + model: 'provider/smart', thinking: undefined, - displayModel: 'provider/secondary', }); - expect(resolveSubagentBinding(withModel.config, secondaryModelFlags(), own, 'primary')).toEqual({ + // "primary" always inherits the caller. + expect(resolveSubagentBinding(pool.config, secondaryModelFlags(), own, 'primary')).toEqual({ model: 'provider/main', thinking: 'medium', - displayModel: 'provider/main', }); - withModel.disposables.dispose(); + pool.disposables.dispose(); + }); - const withEffort = await createConfig( + it('keeps the pool inert while the secondary-model experiment is off', async () => { + const own = { modelAlias: 'provider/main', thinkingLevel: 'medium' }; + const { config, disposables } = await createConfig( {}, - '[secondary_model]\nmodel = "provider/secondary"\ndefault_effort = "low"\n', + '[secondary_model]\ndefault_model = "provider/fast"\nforce = true\n\n[secondary_model.models]\n"provider/fast" = "fast and cheap"\n', ); - expect(resolveSubagentBinding(withEffort.config, secondaryModelFlags(), own)).toEqual({ - model: SECONDARY_DERIVED_MODEL_ID, - thinking: 'low', - displayModel: 'provider/secondary', + + // Flag off: the pool (and force) are ignored — spawns inherit the caller, + // and an explicit choice fails like the no-pool case. + expect(resolveSubagentBinding(config, secondaryModelFlags(false), own)).toEqual({ + model: 'provider/main', + thinking: 'medium', + }); + expect(() => + resolveSubagentBinding(config, secondaryModelFlags(false), own, 'provider/fast'), + ).toThrow(/no \[secondary_model\.models\] pool is configured/); + + disposables.dispose(); + }); + + it('treats a pool-less default_model as an implicit single-entry pool', async () => { + const own = { modelAlias: 'provider/main', thinkingLevel: 'medium' }; + const { config, disposables } = await createConfig( + {}, + '[secondary_model]\ndefault_model = "provider/fast"\n', + ); + + // An omitted model falls back to the default; pool bindings carry no + // explicit thinking. + expect(resolveSubagentBinding(config, secondaryModelFlags(), own)).toEqual({ + model: 'provider/fast', + thinking: undefined, }); - expect(resolveSubagentBinding(withEffort.config, secondaryModelFlags(), own, 'primary')).toEqual({ + // The only other choice is "primary". + expect(resolveSubagentBinding(config, secondaryModelFlags(), own, 'primary')).toEqual({ model: 'provider/main', thinking: 'medium', - displayModel: 'provider/main', }); - withEffort.disposables.dispose(); + expect(() => resolveSubagentBinding(config, secondaryModelFlags(), own, 'provider/smart')).toThrow( + /Invalid model "provider\/smart"\. Available models: provider\/fast, primary\./, + ); + + disposables.dispose(); + }); - const withFactPatch = await createConfig( + it('falls back to the legacy model key when no pool keys are set', async () => { + const own = { modelAlias: 'provider/main', thinkingLevel: 'medium' }; + const { config, disposables } = await createConfig( {}, - '[secondary_model]\nmodel = "provider/secondary"\nmax_output_size = 8192\n', + '[secondary_model]\nmodel = "provider/fast"\ndefault_effort = "low"\n', ); - expect(resolveSubagentBinding(withFactPatch.config, secondaryModelFlags(), own)).toEqual({ - model: SECONDARY_DERIVED_MODEL_ID, + + // Recipe patch fields have no pool counterpart: the schema keeps them + // (so config writes round-trip losslessly for the v1 engine) but pool + // resolution ignores them; the lone legacy key forms the implicit + // single-entry pool. + expect(config.get(SECONDARY_MODEL_SECTION)).toEqual({ + model: 'provider/fast', + defaultEffort: 'low', + }); + expect(resolveSubagentModelPool(config)).toEqual({ + defaultModel: 'provider/fast', + models: { 'provider/fast': '' }, + }); + expect(resolveSubagentBinding(config, secondaryModelFlags(), own)).toEqual({ + model: 'provider/fast', thinking: undefined, - displayModel: 'provider/secondary', }); - withFactPatch.disposables.dispose(); + expect(() => resolveSubagentBinding(config, secondaryModelFlags(), own, 'provider/smart')).toThrow( + /Invalid model "provider\/smart"\. Available models: provider\/fast, primary\./, + ); + + disposables.dispose(); }); - it('inherits the caller binding when the secondary-model experiment is disabled', async () => { + it('lets default_model win over the legacy model key', async () => { const own = { modelAlias: 'provider/main', thinkingLevel: 'medium' }; const { config, disposables } = await createConfig( {}, - '[secondary_model]\nmodel = "provider/secondary"\ndefault_effort = "low"\n', + '[secondary_model]\nmodel = "provider/slow"\ndefault_model = "provider/fast"\n', ); - expect(resolveSubagentBinding(config, secondaryModelFlags(false), own)).toEqual({ - model: 'provider/main', - thinking: 'medium', - displayModel: 'provider/main', + expect(resolveSubagentBinding(config, secondaryModelFlags(), own)).toEqual({ + model: 'provider/fast', + thinking: undefined, }); disposables.dispose(); }); - it('normalizes the derived entry to the recipe base alias regardless of the flag', async () => { - const withRecipe = await createConfig( + it('does not let the legacy model key substitute for a pool table default_model', async () => { + const own = { modelAlias: 'provider/main', thinkingLevel: 'medium' }; + const { config, disposables } = await createConfig( {}, - '[secondary_model]\nmodel = "provider/secondary"\ndefault_effort = "low"\n', + '[secondary_model]\nmodel = "provider/fast"\n\n[secondary_model.models]\n"provider/fast" = "fast and cheap"\n', ); - expect(subagentDisplayModel(withRecipe.config, SECONDARY_DERIVED_MODEL_ID)).toBe( - 'provider/secondary', + + expect(() => resolveSubagentBinding(config, secondaryModelFlags(), own)).toThrow( + '[secondary_model].default_model is required when [secondary_model.models] is configured', + ); + + disposables.dispose(); + }); + + it('lets force pin the legacy model fallback when no default_model is set', async () => { + const own = { modelAlias: 'provider/main', thinkingLevel: 'medium' }; + const { config, disposables } = await createConfig( + {}, + '[secondary_model]\nmodel = "provider/fast"\nforce = true\n', ); - expect(subagentDisplayModel(withRecipe.config, 'provider/main')).toBe('provider/main'); - withRecipe.disposables.dispose(); - const bare = await createConfig({}); - expect(subagentDisplayModel(bare.config, SECONDARY_DERIVED_MODEL_ID)).toBe( - SECONDARY_DERIVED_MODEL_ID, + expect(resolveSubagentBinding(config, secondaryModelFlags(), own)).toEqual({ + model: 'provider/fast', + thinking: undefined, + }); + expect(() => resolveSubagentBinding(config, secondaryModelFlags(), own, 'primary')).toThrow( + /Invalid model "primary": \[secondary_model\]\.force is set/, ); - bare.disposables.dispose(); + + disposables.dispose(); }); - it('normalizes an inherited derived alias on the caller-fallback branch', async () => { - const withRecipe = await createConfig({}, '[secondary_model]\nmodel = "provider/secondary"\n'); - const own = { modelAlias: SECONDARY_DERIVED_MODEL_ID, thinkingLevel: 'medium' }; - expect(resolveSubagentBinding(withRecipe.config, secondaryModelFlags(false), own)).toEqual({ - model: SECONDARY_DERIVED_MODEL_ID, - thinking: 'medium', - displayModel: 'provider/secondary', + it('round-trips legacy recipe patch fields the pool resolution ignores', async () => { + const { config, disposables } = await createConfig( + {}, + '[secondary_model]\nmodel = "provider/fast"\ndefault_effort = "low"\nmax_output_size = 8192\n', + ); + + expect(config.get(SECONDARY_MODEL_SECTION)).toEqual({ + model: 'provider/fast', + defaultEffort: 'low', + maxOutputSize: 8192, }); - withRecipe.disposables.dispose(); + expect(resolveSubagentModelPool(config)).toEqual({ + defaultModel: 'provider/fast', + models: { 'provider/fast': '' }, + }); + + // A v2 write validates before persisting — the patch fields must survive. + await config.set(SECONDARY_MODEL_SECTION, { defaultModel: 'provider/fast' }); + const after = config.get(SECONDARY_MODEL_SECTION); + expect(after?.defaultEffort).toBe('low'); + expect(after?.maxOutputSize).toBe(8192); + + disposables.dispose(); + }); + + it('binds every spawn to the forced default_model, rejecting even "primary"', async () => { + const own = { modelAlias: 'provider/main', thinkingLevel: 'medium' }; + const { config, disposables } = await createConfig( + {}, + '[secondary_model]\ndefault_model = "provider/fast"\nforce = true\n', + ); + + expect(config.get(SECONDARY_MODEL_SECTION)).toEqual({ + defaultModel: 'provider/fast', + force: true, + }); + // An omitted model binds the forced default, with no thinking inheritance. + expect(resolveSubagentBinding(config, secondaryModelFlags(), own)).toEqual({ + model: 'provider/fast', + thinking: undefined, + }); + // Any explicit choice — "primary" included — is rejected. + expect(() => resolveSubagentBinding(config, secondaryModelFlags(), own, 'primary')).toThrow( + /Invalid model "primary": \[secondary_model\]\.force is set/, + ); + + disposables.dispose(); + }); + + it('rejects force combined with a models table at spawn resolution, matching startup validation', async () => { + const own = { modelAlias: 'provider/main', thinkingLevel: 'medium' }; + const { config, disposables } = await createConfig( + {}, + '[secondary_model]\ndefault_model = "provider/fast"\nforce = true\n\n[secondary_model.models]\n"provider/fast" = "fast and cheap"\n', + ); + + // A live session can reach this state through a deep-merged config patch + // that adds force without clearing the pool table; spawn resolution must + // fail the same way the startup pre-flight does. + expect(() => resolveSubagentBinding(config, secondaryModelFlags(), own)).toThrow( + /\[secondary_model\]\.force cannot be combined with \[secondary_model\.models\]/, + ); + + disposables.dispose(); + }); + + it('rejects an alias outside the pool, listing the available models', async () => { + const own = { modelAlias: 'provider/main', thinkingLevel: 'medium' }; + const { config, disposables } = await createConfig( + {}, + '[secondary_model]\ndefault_model = "provider/fast"\n\n[secondary_model.models]\n"provider/fast" = "fast and cheap"\n"provider/smart" = "hard tasks"\n', + ); + + let caught: unknown; + try { + resolveSubagentBinding(config, secondaryModelFlags(), own, 'provider/typo'); + } catch (error) { + caught = error; + } + expect(isError2(caught)).toBe(true); + expect((caught as Error2).code).toBe(ErrorCodes.CONFIG_INVALID); + expect((caught as Error2).message).toBe( + 'Invalid model "provider/typo". Available models: provider/fast, provider/smart, primary.', + ); + + disposables.dispose(); + }); + + it('rejects a stray model choice when no pool is configured', async () => { + const own = { modelAlias: 'provider/main', thinkingLevel: 'medium' }; + const { config, disposables } = await createConfig({}); + + expect(() => resolveSubagentBinding(config, secondaryModelFlags(), own, 'provider/fast')).toThrow( + /Invalid model "provider\/fast": no \[secondary_model\.models\] pool is configured/, + ); + + disposables.dispose(); }); - it('preserves the coded error contract when adding secondary-model guidance', () => { + it('preserves the coded error contract when adding subagent-model guidance', () => { const cause = new Error2( ErrorCodes.CONFIG_INVALID, 'Model "provider/bad" is not configured in config.toml.', @@ -1842,13 +2034,12 @@ describe('subagent config section', () => { expect(toErrorPayload(result)).toMatchObject({ code: ErrorCodes.CONFIG_INVALID, - message: expect.stringContaining('comes from [secondary_model].model / KIMI_SECONDARY_MODEL'), + message: expect.stringContaining('comes from [secondary_model.models]'), details: { model: 'provider/bad', - secondaryModel: 'provider/bad', - secondaryModelConfig: { - section: 'secondaryModel.model', - environment: SECONDARY_MODEL_ENV, + subagentModel: 'provider/bad', + subagentModelConfig: { + section: 'secondary_model.models', }, }, cause: { @@ -1861,96 +2052,16 @@ describe('subagent config section', () => { it('passes through config-invalid failures that are not a missing bound alias', () => { const malformed = new Error2( ErrorCodes.CONFIG_INVALID, - 'Model "provider/secondary" must declare a wire protocol (config: models..protocol).', + 'Model "provider/pool" must declare a wire protocol (config: models..protocol).', ); - expect(wrapSubagentModelError(malformed, 'provider/secondary', 'provider/main')).toBe(malformed); + expect(wrapSubagentModelError(malformed, 'provider/pool', 'provider/main')).toBe(malformed); const unrelated = new Error2( ErrorCodes.CONFIG_INVALID, 'Model "provider/other" is not configured in config.toml.', { details: { model: 'provider/other' } }, ); - expect(wrapSubagentModelError(unrelated, 'provider/secondary', 'provider/main')).toBe(unrelated); - }); -}); - -describe('secondaryModel config section', () => { - async function createConfig(env: Record, toml?: string) { - const disposables = new DisposableStore(); - const ix = disposables.add(new TestInstantiationService()); - const storage = new InMemoryStorageService(); - if (toml !== undefined) { - await storage.write('', 'config.toml', new TextEncoder().encode(toml)); - } - ix.stub(ILogService, stubLog()); - ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg', env)); - ix.stub(IFileSystemStorageService, storage); - ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); - ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); - ix.set(IConfigService, new SyncDescriptor(ConfigService)); - const config = ix.get(IConfigService); - await config.ready; - return { config, disposables }; - } - - it('reads model/default_effort from config.toml and lets the env vars win', async () => { - const env: Record = {}; - const { config, disposables } = await createConfig( - env, - '[secondary_model]\nmodel = "provider/secondary"\ndefault_effort = "low"\n', - ); - expect(resolveSecondaryModel(config, secondaryModelFlags())?.model).toBe('provider/secondary'); - expect(resolveSecondaryModel(config, secondaryModelFlags())?.defaultEffort).toBe('low'); - - env[SECONDARY_MODEL_ENV] = 'provider/env-secondary'; - env[SECONDARY_MODEL_EFFORT_ENV] = 'high'; - expect(resolveSecondaryModel(config, secondaryModelFlags())?.model).toBe('provider/env-secondary'); - expect(resolveSecondaryModel(config, secondaryModelFlags())?.defaultEffort).toBe('high'); - - env[SECONDARY_MODEL_ENV] = ' '; - expect(resolveSecondaryModel(config, secondaryModelFlags())?.model).toBe('provider/secondary'); - - disposables.dispose(); - }); - - it('restores the env-owned model to the raw value on set() while the env var is set', async () => { - const env: Record = { [SECONDARY_MODEL_ENV]: 'provider/env-secondary' }; - const { config, disposables } = await createConfig( - env, - '[secondary_model]\nmodel = "provider/raw-secondary"\n', - ); - - await config.set(SECONDARY_MODEL_SECTION, { model: 'provider/env-secondary' }); - - expect(resolveSecondaryModel(config, secondaryModelFlags())?.model).toBe('provider/env-secondary'); - expect(config.inspect(SECONDARY_MODEL_SECTION).userValue).toEqual({ - model: 'provider/raw-secondary', - }); - - disposables.dispose(); - }); - - it('propagates overlay-induced models changes to section events on runtime set', async () => { - const { config, disposables } = await createConfig( - {}, - '[models.k2]\nprovider = "kimi"\nmodel = "kimi-k2"\n', - ); - const domains: string[] = []; - config.onDidSectionChange((e) => domains.push(e.domain)); - - await config.set(SECONDARY_MODEL_SECTION, { model: 'k2', maxOutputSize: 8192 }); - const models = config.get>(MODELS_SECTION) ?? {}; - expect(models[SECONDARY_DERIVED_MODEL_ID]).toBeDefined(); - expect(domains).toContain(SECONDARY_MODEL_SECTION); - expect(domains).toContain(MODELS_SECTION); - - domains.length = 0; - await config.replace(SECONDARY_MODEL_SECTION, { model: 'k2' }); - const after = config.get>(MODELS_SECTION) ?? {}; - expect(after[SECONDARY_DERIVED_MODEL_ID]).toBeUndefined(); - expect(domains).toContain(MODELS_SECTION); - - disposables.dispose(); + expect(wrapSubagentModelError(unrelated, 'provider/pool', 'provider/main')).toBe(unrelated); }); }); diff --git a/packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts b/packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts index 717a9d1ab1..a12c89dfa8 100644 --- a/packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts +++ b/packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts @@ -446,6 +446,87 @@ describe('refreshProviderModels write behavior', () => { } }); + it('clears the subagent model pool when a refresh drops its default alias', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('KIMI_CODE_BASE_URL', baseUrl); + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + data: [{ id: 'kimi-k3', context_length: 1048576, supports_reasoning: true }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const { host, config, discovery } = await createHost({ + providers: { + 'my-kimi': { type: 'kimi', baseUrl, apiKey: 'sk-distributed-key' }, + }, + models: { + 'my-kimi/kimi-k2': { provider: 'my-kimi', model: 'kimi-k2', maxContextSize: 262144 }, + }, + secondaryModel: { + defaultModel: 'my-kimi/kimi-k2', + models: { 'my-kimi/kimi-k2': 'fast and cheap' }, + }, + }); + try { + const result = await discovery.refreshProviderModels({ scope: 'all' }); + + expect(result.changed).toEqual([ + { provider_id: 'my-kimi', provider_name: 'my-kimi', added: 1, removed: 1 }, + ]); + expect(config.get('secondaryModel')).toBeUndefined(); + } finally { + host.dispose(); + } + }); + + it('filters pool entries a refresh dropped while keeping a surviving default', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('KIMI_CODE_BASE_URL', baseUrl); + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + data: [{ id: 'kimi-k3', context_length: 1048576, supports_reasoning: true }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const { host, config, discovery } = await createHost({ + providers: { + ...staticProviders, + 'my-kimi': { type: 'kimi', baseUrl, apiKey: 'sk-distributed-key' }, + }, + models: { + ...staticModels, + 'my-kimi/kimi-k2': { provider: 'my-kimi', model: 'kimi-k2', maxContextSize: 262144 }, + }, + secondaryModel: { + defaultModel: 's1', + models: { s1: 'static fallback', 'my-kimi/kimi-k2': 'managed' }, + }, + }); + try { + const result = await discovery.refreshProviderModels({ scope: 'all' }); + + expect(result.changed).toEqual([ + { provider_id: 'my-kimi', provider_name: 'my-kimi', added: 1, removed: 1 }, + ]); + expect(config.get('secondaryModel')).toEqual({ + defaultModel: 's1', + models: { s1: 'static fallback' }, + }); + } finally { + host.dispose(); + } + }); + it('never exposes a halfway-removed catalog: the registries stay untouched until the single atomic write', async () => { const fetchMock = vi.fn( async () => diff --git a/packages/agent-core-v2/test/app/kosongConfig/modelsDevImport.test.ts b/packages/agent-core-v2/test/app/kosongConfig/modelsDevImport.test.ts index 5f4dbaf8e9..5f6500f17f 100644 --- a/packages/agent-core-v2/test/app/kosongConfig/modelsDevImport.test.ts +++ b/packages/agent-core-v2/test/app/kosongConfig/modelsDevImport.test.ts @@ -254,6 +254,62 @@ describe('IModelsDevImportService', () => { expect(config.get('defaultModel')).toBe('k2'); }); + it('filters pool entries a catalog import drops, keeping a surviving default', async () => { + setModelsDevUpstreamForTest({ fetchImpl: fetchJson(CATALOG) }); + const { config, imports } = createHost({ + providers: { openai: { type: 'openai', apiKey: 'sk-old' } }, + models: { + 'openai/gpt-4o': { provider: 'openai', model: 'gpt-4o', maxContextSize: 128000 }, + k2: { provider: 'kimi', model: 'kimi-k2', maxContextSize: 131072 }, + }, + secondaryModel: { + defaultModel: 'k2', + models: { k2: 'fast', 'openai/gpt-4o': 'smart' }, + }, + }); + + await imports.importModelsDevProvider({ catalogId: 'openai' }); + + // The import rebuilt openai's alias set (gpt-4o → gpt-4.1): the dropped + // alias leaves the pool, the surviving default stays. + expect(config.get('secondaryModel')).toEqual({ + defaultModel: 'k2', + models: { k2: 'fast' }, + }); + }); + + it('clears the pool when a catalog import orphans its default', async () => { + setModelsDevUpstreamForTest({ fetchImpl: fetchJson(CATALOG) }); + const { config, imports } = createHost({ + providers: { openai: { type: 'openai', apiKey: 'sk-old' } }, + models: { + 'openai/gpt-4o': { provider: 'openai', model: 'gpt-4o', maxContextSize: 128000 }, + }, + secondaryModel: { defaultModel: 'openai/gpt-4o' }, + }); + + await imports.importModelsDevProvider({ catalogId: 'openai' }); + + expect(config.get('secondaryModel')).toBeUndefined(); + }); + + it('cascades the pool on custom-registry imports too', async () => { + setModelsDevUpstreamForTest({ fetchImpl: fetchJson(REGISTRY_DOC) }); + const { config, imports } = createHost({ + providers: { 'acme-gpt': { type: 'openai', apiKey: 'sk-old' } }, + models: { + 'acme-gpt/gpt-old': { provider: 'acme-gpt', model: 'gpt-old', maxContextSize: 64000 }, + }, + secondaryModel: { defaultModel: 'acme-gpt/gpt-old' }, + }); + + await imports.importCustomRegistry({ url: REGISTRY_URL }); + + // The registry rebuild replaced acme-gpt's only alias (gpt-old → gpt-x), + // orphaning the pool default. + expect(config.get('secondaryModel')).toBeUndefined(); + }); + it('seeds default_model from the first imported model only when none is configured', async () => { setModelsDevUpstreamForTest({ fetchImpl: fetchJson(CATALOG) }); const { config, imports } = createHost({ providers: {}, models: {} }); diff --git a/packages/agent-core-v2/test/app/kosongConfig/secondaryModelOverlay.test.ts b/packages/agent-core-v2/test/app/kosongConfig/secondaryModelOverlay.test.ts deleted file mode 100644 index f575fa5f46..0000000000 --- a/packages/agent-core-v2/test/app/kosongConfig/secondaryModelOverlay.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * `app/kosongConfig` secondaryModelOverlay tests — the `[secondary_model]` - * derived-entry synthesis: - * - * - a recipe with patch fields synthesizes `SECONDARY_DERIVED_MODEL_ID` - * (base copy, patch merged into `overrides` with patch winning conflicts, - * `aliases` dropped); a pointer-only recipe, a missing pointer, and a - * dangling pointer synthesize nothing; - * - `strip` keeps the synthesized entry out of `config.toml`. - */ - -import { describe, expect, it } from 'vitest'; - -import { - MODELS_SECTION, - SECONDARY_MODEL_SECTION, -} from '#/app/kosongConfig/configSection'; -import { - SECONDARY_DERIVED_MODEL_ID, - secondaryModelOverlay, -} from '#/app/kosongConfig/secondaryModelOverlay'; - -function apply(effective: Record): readonly string[] { - return secondaryModelOverlay.apply(effective, () => undefined, (_domain, value) => value); -} - -const baseEntry = { - provider: 'kimi', - model: 'kimi-k2', - maxContextSize: 262144, - aliases: ['k2-latest'], - overrides: { defaultEffort: 'medium', supportEfforts: ['low', 'medium', 'high'] }, -}; - -describe('secondaryModelOverlay.apply', () => { - it('does nothing when no secondary model is configured', () => { - const effective: Record = { [MODELS_SECTION]: { k2: baseEntry } }; - expect(apply(effective)).toEqual([]); - expect(effective[MODELS_SECTION]).toEqual({ k2: baseEntry }); - }); - - it('does nothing for a pointer-only recipe (no patch fields)', () => { - const effective: Record = { - [MODELS_SECTION]: { k2: baseEntry }, - [SECONDARY_MODEL_SECTION]: { model: 'k2' }, - }; - expect(apply(effective)).toEqual([]); - expect(effective[MODELS_SECTION]).toEqual({ k2: baseEntry }); - }); - - it('synthesizes the derived entry: base copy, patch wins overrides conflicts, aliases dropped', () => { - const effective: Record = { - [MODELS_SECTION]: { k2: baseEntry }, - [SECONDARY_MODEL_SECTION]: { model: 'k2', defaultEffort: 'low', maxOutputSize: 8192 }, - }; - expect(apply(effective)).toEqual([MODELS_SECTION]); - const models = effective[MODELS_SECTION] as Record; - expect(models[SECONDARY_DERIVED_MODEL_ID]).toEqual({ - provider: 'kimi', - model: 'kimi-k2', - maxContextSize: 262144, - overrides: { - defaultEffort: 'low', - supportEfforts: ['low', 'medium', 'high'], - maxOutputSize: 8192, - }, - }); - expect(models['k2']).toEqual(baseEntry); - }); - - it('does nothing when the pointed entry does not exist', () => { - const effective: Record = { - [MODELS_SECTION]: { k2: baseEntry }, - [SECONDARY_MODEL_SECTION]: { model: 'nope', maxOutputSize: 8192 }, - }; - expect(apply(effective)).toEqual([]); - expect(effective[MODELS_SECTION]).toEqual({ k2: baseEntry }); - }); - - it('never derives from the derived id itself', () => { - const effective: Record = { - [MODELS_SECTION]: { [SECONDARY_DERIVED_MODEL_ID]: baseEntry }, - [SECONDARY_MODEL_SECTION]: { model: SECONDARY_DERIVED_MODEL_ID, maxOutputSize: 1 }, - }; - expect(apply(effective)).toEqual([]); - }); -}); - -describe('secondaryModelOverlay.strip', () => { - const strip = secondaryModelOverlay.strip!; - - it('removes the derived entry from models writes and leaves other domains alone', () => { - const models = { k2: baseEntry, [SECONDARY_DERIVED_MODEL_ID]: { ...baseEntry } }; - expect(strip(MODELS_SECTION, models, {})).toEqual({ k2: baseEntry }); - expect(strip('thinking', { effort: 'low' }, {})).toEqual({ effort: 'low' }); - }); - - it('leaves a models section without the derived entry untouched', () => { - const models = { k2: baseEntry }; - expect(strip(MODELS_SECTION, models, {})).toBe(models); - }); - - it('rolls back a defaultModel pointer set to the derived id', () => { - expect(strip('defaultModel', 'k2', {})).toBe('k2'); - expect(strip('defaultModel', SECONDARY_DERIVED_MODEL_ID, { default_model: 'k2' })).toBe('k2'); - expect(strip('defaultModel', SECONDARY_DERIVED_MODEL_ID, {})).toBeUndefined(); - }); -}); diff --git a/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts b/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts index d2bf1ce452..d1b46fc822 100644 --- a/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts @@ -10,6 +10,11 @@ import { Event } from '#/_base/event'; import { ILogService } from '#/_base/log/log'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; +import { IModelCatalog } from '#/kosong/model/catalog'; +import { IModelService } from '#/kosong/model/model'; +import { IProviderService } from '#/kosong/provider/provider'; +import { stubProviderService } from '../provider/stubs'; +import { IFlagService } from '#/app/flag/flag'; import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence'; import { IEventService } from '#/app/event/event'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; @@ -50,6 +55,7 @@ import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceT import { WorkspaceToolPolicyService } from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService'; import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs'; import { stubLog } from '../../_base/log/stubs'; +import { stubFlag } from '../../app/flag/stubs'; import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; import { WorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycleService'; @@ -326,6 +332,13 @@ describe('WorkspaceLifecycleService', () => { stubPair(ISessionIndex, sessionIndexStub()), stubPair(ISessionIndexMirror, sessionIndexMirrorStub()), stubPair(IConfigService, { get: () => undefined } as unknown as IConfigService), + stubPair(IModelCatalog, { _serviceBrand: undefined } as unknown as IModelCatalog), + stubPair(IModelService, { + _serviceBrand: undefined, + ready: Promise.resolve(), + } as unknown as IModelService), + stubPair(IProviderService, stubProviderService()), + stubPair(IFlagService, stubFlag(() => false)), stubPair(IAppendLogStore, { _serviceBrand: undefined, append: () => {}, diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 2e4fad5c4a..27bda7076b 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -166,7 +166,6 @@ import { MODELS_SECTION, PROVIDERS_SECTION, } from '#/app/kosongConfig/configSection'; -import { secondaryModelOverlay } from '#/app/kosongConfig/secondaryModelOverlay'; import { IModelCatalog, type Model } from '#/kosong/model/catalog'; import { ModelCatalog } from '#/kosong/model/catalogService'; import { IModelOAuthTokens } from '#/kosong/model/modelOAuth'; @@ -2325,11 +2324,7 @@ function applyTestAgentOptionsToConfig(config: KimiConfig, options: TestAgentOpt } function configService(readConfig: () => KimiConfig): IConfigService { - const effectiveConfig = () => { - const effective = { ...configWithEnvOverrides(readConfig()) } as Record; - secondaryModelOverlay.apply(effective, () => undefined, (_domain, value) => value); - return effective as unknown as KimiConfig; - }; + const effectiveConfig = () => configWithEnvOverrides(readConfig()); const memory = new Map(); const sectionEmitter = new Emitter<{ readonly domain: string; diff --git a/packages/agent-core-v2/test/kosong/stubs.ts b/packages/agent-core-v2/test/kosong/stubs.ts index 2bd232b363..2840e96e5a 100644 --- a/packages/agent-core-v2/test/kosong/stubs.ts +++ b/packages/agent-core-v2/test/kosong/stubs.ts @@ -63,7 +63,7 @@ export class StubConfigService implements IConfigService { replace(domain: string, value: unknown): Promise { const previousValue = this._values.get(domain); - if (value === undefined) { + if (value === undefined || value === null) { this._values.delete(domain); } else { this._values.set(domain, value); @@ -75,7 +75,7 @@ export class StubConfigService implements IConfigService { replaceSections(sections: Readonly>): Promise { for (const [domain, value] of Object.entries(sections)) { const previousValue = this._values.get(domain); - if (value === undefined) { + if (value === undefined || value === null) { this._values.delete(domain); } else { this._values.set(domain, value); diff --git a/packages/agent-core-v2/test/session/subagent/secondaryModelWarning.test.ts b/packages/agent-core-v2/test/session/subagent/secondaryModelWarning.test.ts deleted file mode 100644 index 8d840ac7a9..0000000000 --- a/packages/agent-core-v2/test/session/subagent/secondaryModelWarning.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { SyncDescriptor } from '#/_base/di/descriptors'; -import { DisposableStore } from '#/_base/di/lifecycle'; -import { LifecycleScope } from '#/app/scopes'; -import { type IAgentScopeHandle } from '#/_base/di/scope'; -import { TestInstantiationService } from '#/_base/di/test'; -import { Emitter } from '#/_base/event'; -import { IConfigService } from '#/app/config/config'; -import { IEventBus, type DomainEvent } from '#/app/event/eventBus'; -import { IFlagService } from '#/app/flag/flag'; -import { SECONDARY_MODEL_SECTION } from '#/app/kosongConfig/configSection'; -import { ErrorCodes, Error2 } from '#/errors'; -import { IModelCatalog, type Model } from '#/kosong/model/catalog'; -import { - IAgentLifecycleService, - MAIN_AGENT_ID, -} from '#/session/agentLifecycle/agentLifecycle'; -import { - ISessionSecondaryModelWarningService, - SECONDARY_MODEL_EFFORT_WARNING_CODE, - SECONDARY_MODEL_INVALID_WARNING_CODE, -} from '#/session/subagent/secondaryModelWarning'; -import { SessionSecondaryModelWarningService } from '#/session/subagent/secondaryModelWarningService'; -import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; - -import { stubFlag } from '../../app/flag/stubs'; -import { StubConfigService } from '../../kosong/stubs'; - -describe('SessionSecondaryModelWarningService', () => { - let disposables: DisposableStore; - let ix: TestInstantiationService; - let onDidCreate: Emitter; - let handles: Map; - let published: DomainEvent[]; - let modelIds: Record; - let config: StubConfigService; - - beforeEach(() => { - disposables = new DisposableStore(); - ix = disposables.add(new TestInstantiationService()); - onDidCreate = disposables.add(new Emitter()); - handles = new Map(); - published = []; - modelIds = {}; - }); - afterEach(() => { - disposables.dispose(); - }); - - function setup(configValues: Record, flagEnabled = true): void { - ix.stub(IAgentLifecycleService, { - _serviceBrand: undefined, - onDidCreate: onDidCreate.event, - get: (agentId: string) => handles.get(agentId), - } as unknown as IAgentLifecycleService); - config = new StubConfigService(configValues); - ix.stub(IConfigService, config); - ix.stub( - IFlagService, - stubFlag((id) => flagEnabled && id === SECONDARY_MODEL_FLAG_ID), - ); - ix.stub(IModelCatalog, { - _serviceBrand: undefined, - get: (id: string) => { - const model = modelIds[id]; - if (model === undefined) { - throw new Error2(ErrorCodes.CONFIG_INVALID, `Model "${id}" is not configured in config.toml.`, { - details: { model: id }, - }); - } - return model; - }, - } as unknown as IModelCatalog); - ix.set( - ISessionSecondaryModelWarningService, - new SyncDescriptor(SessionSecondaryModelWarningService), - ); - } - - function createMain(): IAgentScopeHandle { - const handle = agentHandle(MAIN_AGENT_ID, published); - handles.set(MAIN_AGENT_ID, handle); - onDidCreate.fire(handle); - return handle; - } - - it('stays silent when no secondary model is configured', () => { - setup({}); - const svc = ix.get(ISessionSecondaryModelWarningService); - createMain(); - expect(svc.getSecondaryModelWarning()).toBeUndefined(); - expect(published).toHaveLength(0); - }); - - it('stays silent when the secondary-model experiment is disabled', () => { - setup({ [SECONDARY_MODEL_SECTION]: { model: 'provider/typo' } }, false); - const svc = ix.get(ISessionSecondaryModelWarningService); - createMain(); - expect(svc.getSecondaryModelWarning()).toBeUndefined(); - expect(published).toHaveLength(0); - }); - - it('warns when the configured secondary model does not resolve', () => { - setup({ [SECONDARY_MODEL_SECTION]: { model: 'provider/typo' } }); - const svc = ix.get(ISessionSecondaryModelWarningService); - createMain(); - const warning = svc.getSecondaryModelWarning(); - expect(warning?.code).toBe(SECONDARY_MODEL_INVALID_WARNING_CODE); - expect(warning?.message).toContain('"provider/typo"'); - expect(warning?.message).toContain('KIMI_SECONDARY_MODEL'); - expect(warning?.message).toContain('not configured'); - expect(published).toEqual([ - { type: 'warning', code: warning?.code, message: warning?.message }, - ]); - }); - - it('warns when the configured default effort is not listed by the resolved model', () => { - modelIds['provider/secondary'] = modelStub({ supportEfforts: ['low', 'high'] }); - setup({ [SECONDARY_MODEL_SECTION]: { model: 'provider/secondary', defaultEffort: 'hihg' } }); - const svc = ix.get(ISessionSecondaryModelWarningService); - createMain(); - const warning = svc.getSecondaryModelWarning(); - expect(warning?.code).toBe(SECONDARY_MODEL_EFFORT_WARNING_CODE); - expect(warning?.message).toContain('"hihg"'); - expect(warning?.message).toContain('low, high'); - expect(warning?.message).toContain('KIMI_SECONDARY_EFFORT'); - }); - - it.each([ - { secondary: { model: 'provider/secondary', defaultEffort: 'high' }, label: 'a listed effort' }, - { secondary: { model: 'provider/secondary', defaultEffort: 'off' }, label: '"off"' }, - { secondary: { model: 'provider/secondary', defaultEffort: 'on' }, label: '"on"' }, - { secondary: { model: 'provider/secondary' }, label: 'no effort' }, - ])('stays silent for $label', ({ secondary }) => { - modelIds['provider/secondary'] = modelStub({ supportEfforts: ['low', 'high'] }); - setup({ [SECONDARY_MODEL_SECTION]: secondary }); - const svc = ix.get(ISessionSecondaryModelWarningService); - createMain(); - expect(svc.getSecondaryModelWarning()).toBeUndefined(); - expect(published).toHaveLength(0); - }); - - it('checks the effort against the patched supportEfforts of the derived entry', () => { - modelIds['provider/secondary'] = modelStub({ supportEfforts: ['low', 'high'] }); - setup({ - [SECONDARY_MODEL_SECTION]: { - model: 'provider/secondary', - supportEfforts: ['low'], - defaultEffort: 'high', - }, - }); - const svc = ix.get(ISessionSecondaryModelWarningService); - createMain(); - const warning = svc.getSecondaryModelWarning(); - expect(warning?.code).toBe(SECONDARY_MODEL_EFFORT_WARNING_CODE); - expect(warning?.message).toContain('"high"'); - expect(warning?.message).toContain('known: low'); - }); - - it('stays silent when the patched supportEfforts lists the default effort', () => { - modelIds['provider/secondary'] = modelStub({ supportEfforts: ['high'] }); - setup({ - [SECONDARY_MODEL_SECTION]: { - model: 'provider/secondary', - supportEfforts: ['low'], - defaultEffort: 'low', - }, - }); - const svc = ix.get(ISessionSecondaryModelWarningService); - createMain(); - expect(svc.getSecondaryModelWarning()).toBeUndefined(); - expect(published).toHaveLength(0); - }); - - it('stays silent for any effort when the model lists none', () => { - modelIds['provider/freeform'] = modelStub({}); - setup({ [SECONDARY_MODEL_SECTION]: { model: 'provider/freeform', defaultEffort: 'whatever' } }); - const svc = ix.get(ISessionSecondaryModelWarningService); - createMain(); - expect(svc.getSecondaryModelWarning()).toBeUndefined(); - expect(published).toHaveLength(0); - }); - - it('ignores created agents that are not the main agent', () => { - setup({ [SECONDARY_MODEL_SECTION]: { model: 'provider/typo' } }); - const svc = ix.get(ISessionSecondaryModelWarningService); - onDidCreate.fire(agentHandle('agent-1', published)); - expect(svc.getSecondaryModelWarning()).toBeUndefined(); - expect(published).toHaveLength(0); - }); - - it('checks a main agent that already exists at construction', () => { - setup({ [SECONDARY_MODEL_SECTION]: { model: 'provider/typo' } }); - handles.set(MAIN_AGENT_ID, agentHandle(MAIN_AGENT_ID, published)); - const svc = ix.get(ISessionSecondaryModelWarningService); - expect(svc.getSecondaryModelWarning()?.code).toBe(SECONDARY_MODEL_INVALID_WARNING_CODE); - expect(published).toHaveLength(1); - }); - - it('publishes at most once when both trigger paths fire', () => { - setup({ [SECONDARY_MODEL_SECTION]: { model: 'provider/typo' } }); - handles.set(MAIN_AGENT_ID, agentHandle(MAIN_AGENT_ID, published)); - const svc = ix.get(ISessionSecondaryModelWarningService); - createMain(); - expect(svc.getSecondaryModelWarning()?.code).toBe(SECONDARY_MODEL_INVALID_WARNING_CODE); - expect(published).toHaveLength(1); - }); - - it('recheck publishes a newly broken recipe once and stays quiet while it is unchanged', async () => { - modelIds['provider/secondary'] = modelStub({}); - setup({ [SECONDARY_MODEL_SECTION]: { model: 'provider/secondary' } }); - const svc = ix.get(ISessionSecondaryModelWarningService); - createMain(); - expect(svc.getSecondaryModelWarning()).toBeUndefined(); - - await config.replace(SECONDARY_MODEL_SECTION, { model: 'provider/typo' }); - const warning = svc.recheckSecondaryModelWarning(); - expect(warning?.code).toBe(SECONDARY_MODEL_INVALID_WARNING_CODE); - expect(svc.getSecondaryModelWarning()).toEqual(warning); - expect(published).toEqual([{ type: 'warning', code: warning?.code, message: warning?.message }]); - - expect(svc.recheckSecondaryModelWarning()).toEqual(warning); - expect(published).toHaveLength(1); - }); - - it('recheck clears the cached warning when the recipe is fixed or removed', async () => { - setup({ [SECONDARY_MODEL_SECTION]: { model: 'provider/typo' } }); - const svc = ix.get(ISessionSecondaryModelWarningService); - createMain(); - expect(svc.getSecondaryModelWarning()?.code).toBe(SECONDARY_MODEL_INVALID_WARNING_CODE); - expect(published).toHaveLength(1); - - modelIds['provider/secondary'] = modelStub({}); - await config.replace(SECONDARY_MODEL_SECTION, { model: 'provider/secondary' }); - expect(svc.recheckSecondaryModelWarning()).toBeUndefined(); - expect(svc.getSecondaryModelWarning()).toBeUndefined(); - - await config.replace(SECONDARY_MODEL_SECTION, { model: 'provider/typo' }); - expect(svc.recheckSecondaryModelWarning()?.code).toBe(SECONDARY_MODEL_INVALID_WARNING_CODE); - await config.replace(SECONDARY_MODEL_SECTION, undefined); - expect(svc.recheckSecondaryModelWarning()).toBeUndefined(); - expect(published).toHaveLength(2); - }); - - it('recheck before the main agent exists caches silently; the initial check still publishes', async () => { - setup({ [SECONDARY_MODEL_SECTION]: { model: 'provider/typo' } }); - const svc = ix.get(ISessionSecondaryModelWarningService); - expect(svc.recheckSecondaryModelWarning()?.code).toBe(SECONDARY_MODEL_INVALID_WARNING_CODE); - expect(published).toHaveLength(0); - createMain(); - expect(published).toHaveLength(1); - }); -}); - -function agentHandle(id: string, published: DomainEvent[]): IAgentScopeHandle { - const bus: IEventBus = { - _serviceBrand: undefined, - publish: vi.fn((event: DomainEvent) => { - published.push(event); - }), - subscribe: vi.fn(() => ({ dispose: () => {} })) as IEventBus['subscribe'], - }; - return { - id, - kind: LifecycleScope.Agent, - accessor: { - get: ((serviceId: unknown) => { - if (serviceId === IEventBus) return bus; - throw new Error('unexpected service resolution'); - }) as IAgentScopeHandle['accessor']['get'], - }, - dispose: () => {}, - }; -} - -function modelStub(overrides: Partial): Model { - return { - id: 'provider/secondary', - name: 'secondary', - aliases: [], - protocol: 'openai', - headers: {}, - capabilities: {}, - maxContextSize: 100000, - alwaysThinking: false, - providerName: 'provider', - authProvider: { getAuth: () => Promise.resolve({}) }, - ...overrides, - } as unknown as Model; -} diff --git a/packages/agent-core-v2/test/session/subagent/subagentModelsValidation.test.ts b/packages/agent-core-v2/test/session/subagent/subagentModelsValidation.test.ts new file mode 100644 index 0000000000..b31a61a90a --- /dev/null +++ b/packages/agent-core-v2/test/session/subagent/subagentModelsValidation.test.ts @@ -0,0 +1,248 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IConfigService } from '#/app/config/config'; +import { IFlagService } from '#/app/flag/flag'; +import { ErrorCodes, Error2, isError2 } from '#/errors'; +import { IModelCatalog, type Model } from '#/kosong/model/catalog'; +import { + SECONDARY_MODEL_SECTION, + SUBAGENT_SECTION, +} from '#/session/subagent/configSection'; +import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; +import { ISessionSubagentModelsValidationService } from '#/session/subagent/subagentModelsValidation'; +import { SessionSubagentModelsValidationService } from '#/session/subagent/subagentModelsValidationService'; + +import { StubConfigService } from '../../kosong/stubs'; +import { stubFlag } from '../../app/flag/stubs'; + +describe('SessionSubagentModelsValidationService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + let modelIds: Set; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + modelIds = new Set(); + }); + afterEach(() => { + disposables.dispose(); + }); + + function setup(configValues: Record, flagEnabled = true): void { + ix.stub(IConfigService, new StubConfigService(configValues)); + ix.stub(IFlagService, stubFlag((id) => flagEnabled && id === SECONDARY_MODEL_FLAG_ID)); + ix.stub(IModelCatalog, { + _serviceBrand: undefined, + get: (id: string) => { + if (!modelIds.has(id)) { + throw new Error2( + ErrorCodes.CONFIG_INVALID, + `Model "${id}" is not configured in config.toml.`, + { details: { model: id } }, + ); + } + return { id } as Model; + }, + } as unknown as IModelCatalog); + ix.set( + ISessionSubagentModelsValidationService, + new SyncDescriptor(SessionSubagentModelsValidationService), + ); + } + + function resolve(): unknown { + try { + ix.get(ISessionSubagentModelsValidationService); + return undefined; + } catch (error) { + return error; + } + } + + it('is a no-op when no secondary_model section is configured', () => { + setup({}); + expect(resolve()).toBeUndefined(); + }); + + it('is a no-op when only the [subagent] timeout is configured', () => { + setup({ [SUBAGENT_SECTION]: { timeoutMs: 5000 } }); + expect(resolve()).toBeUndefined(); + }); + + it('is a no-op for a broken pool while the secondary-model experiment is off', () => { + setup({ [SECONDARY_MODEL_SECTION]: { defaultModel: 'provider/typo' } }, false); + expect(resolve()).toBeUndefined(); + }); + + it('constructs fine when default_model alone forms an implicit single-entry pool', () => { + modelIds.add('provider/fast'); + setup({ [SECONDARY_MODEL_SECTION]: { defaultModel: 'provider/fast' } }); + expect(resolve()).toBeUndefined(); + }); + + it('constructs fine when the legacy model key alone forms the fallback pool', () => { + modelIds.add('provider/fast'); + setup({ [SECONDARY_MODEL_SECTION]: { model: 'provider/fast' } }); + expect(resolve()).toBeUndefined(); + }); + + it('fails session creation when the legacy model fallback does not resolve', () => { + setup({ [SECONDARY_MODEL_SECTION]: { model: 'provider/typo' } }); + const error = resolve(); + expect(isError2(error)).toBe(true); + expect((error as Error2).code).toBe(ErrorCodes.CONFIG_INVALID); + expect((error as Error2).message).toContain( + '[secondary_model.models] entry "provider/typo" could not be resolved', + ); + }); + + it('constructs fine when force pins the legacy model fallback', () => { + modelIds.add('provider/fast'); + setup({ [SECONDARY_MODEL_SECTION]: { model: 'provider/fast', force: true } }); + expect(resolve()).toBeUndefined(); + }); + + it('fails session creation when a pool table relies on the legacy model key for its default', () => { + modelIds.add('provider/fast'); + setup({ + [SECONDARY_MODEL_SECTION]: { + model: 'provider/fast', + models: { 'provider/fast': 'fast and cheap' }, + }, + }); + const error = resolve(); + expect(isError2(error)).toBe(true); + expect((error as Error2).code).toBe(ErrorCodes.CONFIG_INVALID); + expect((error as Error2).message).toContain( + '[secondary_model].default_model is required when [secondary_model.models] is configured', + ); + }); + + it('fails session creation when a pool-less default_model does not resolve', () => { + setup({ [SECONDARY_MODEL_SECTION]: { defaultModel: 'provider/typo' } }); + const error = resolve(); + expect(isError2(error)).toBe(true); + expect((error as Error2).code).toBe(ErrorCodes.CONFIG_INVALID); + expect((error as Error2).message).toContain( + '[secondary_model.models] entry "provider/typo" could not be resolved', + ); + }); + + it('constructs fine for a valid pool', () => { + modelIds.add('provider/fast').add('provider/smart'); + setup({ + [SECONDARY_MODEL_SECTION]: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast and cheap', 'provider/smart': 'hard tasks' }, + }, + }); + expect(resolve()).toBeUndefined(); + }); + + it('fails session creation when the pool has no default_model', () => { + modelIds.add('provider/fast'); + setup({ [SECONDARY_MODEL_SECTION]: { models: { 'provider/fast': 'fast and cheap' } } }); + const error = resolve(); + expect(isError2(error)).toBe(true); + expect((error as Error2).code).toBe(ErrorCodes.CONFIG_INVALID); + expect((error as Error2).message).toContain( + '[secondary_model].default_model is required when [secondary_model.models] is configured', + ); + }); + + it('fails session creation when default_model is not a pool key, listing the pool', () => { + modelIds.add('provider/fast').add('provider/smart'); + setup({ + [SECONDARY_MODEL_SECTION]: { + defaultModel: 'provider/typo', + models: { 'provider/fast': 'fast and cheap', 'provider/smart': 'hard tasks' }, + }, + }); + const error = resolve(); + expect(isError2(error)).toBe(true); + expect((error as Error2).code).toBe(ErrorCodes.CONFIG_INVALID); + expect((error as Error2).message).toContain('"provider/typo"'); + expect((error as Error2).message).toContain( + 'Available models: provider/fast, provider/smart.', + ); + }); + + it('fails session creation when a pool key uses the reserved "primary" alias', () => { + modelIds.add('primary').add('provider/fast'); + setup({ + [SECONDARY_MODEL_SECTION]: { + defaultModel: 'provider/fast', + models: { primary: 'looks like a model', 'provider/fast': 'fast and cheap' }, + }, + }); + const error = resolve(); + expect(isError2(error)).toBe(true); + expect((error as Error2).code).toBe(ErrorCodes.CONFIG_INVALID); + expect((error as Error2).message).toContain( + '[secondary_model.models] key "primary" is reserved', + ); + }); + + it('fails session creation when a pool key does not resolve, naming the key', () => { + modelIds.add('provider/fast'); + setup({ + [SECONDARY_MODEL_SECTION]: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast and cheap', 'provider/typo': 'hard tasks' }, + }, + }); + const error = resolve(); + expect(isError2(error)).toBe(true); + expect((error as Error2).code).toBe(ErrorCodes.CONFIG_INVALID); + expect((error as Error2).message).toContain( + '[secondary_model.models] entry "provider/typo" could not be resolved', + ); + expect((error as Error2).message).toContain('"provider/typo" is not configured'); + expect(isError2((error as Error2).cause)).toBe(true); + }); + + it('constructs fine when force pins a resolvable default_model', () => { + modelIds.add('provider/fast'); + setup({ [SECONDARY_MODEL_SECTION]: { defaultModel: 'provider/fast', force: true } }); + expect(resolve()).toBeUndefined(); + }); + + it('fails session creation when force is set without default_model', () => { + setup({ [SECONDARY_MODEL_SECTION]: { force: true } }); + const error = resolve(); + expect(isError2(error)).toBe(true); + expect((error as Error2).code).toBe(ErrorCodes.CONFIG_INVALID); + expect((error as Error2).message).toContain( + '[secondary_model].default_model is required when [secondary_model].force is set', + ); + }); + + it('fails session creation when force is combined with a models table', () => { + modelIds.add('provider/fast'); + setup({ + [SECONDARY_MODEL_SECTION]: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast and cheap' }, + force: true, + }, + }); + const error = resolve(); + expect(isError2(error)).toBe(true); + expect((error as Error2).code).toBe(ErrorCodes.CONFIG_INVALID); + expect((error as Error2).message).toContain( + '[secondary_model].force cannot be combined with [secondary_model.models]', + ); + }); + + it('fails session creation when the forced default_model does not resolve', () => { + setup({ [SECONDARY_MODEL_SECTION]: { defaultModel: 'provider/typo', force: true } }); + const error = resolve(); + expect(isError2(error)).toBe(true); + expect((error as Error2).code).toBe(ErrorCodes.CONFIG_INVALID); + expect((error as Error2).message).toContain('"provider/typo"'); + }); +}); diff --git a/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts b/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts index 1a28612dac..4bf6d38217 100644 --- a/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts +++ b/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts @@ -15,8 +15,6 @@ import { IAgentUserToolService } from '#/agent/userTool/userTool'; import { IEventBus, type DomainEvent } from '#/app/event/eventBus'; import { IConfigService } from '#/app/config/config'; import { IFlagService } from '#/app/flag/flag'; -import { SECONDARY_MODEL_SECTION } from '#/app/kosongConfig/configSection'; -import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; import { normalizeAgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { APIProviderRateLimitError } from '#/kosong/contract/errors'; @@ -1153,7 +1151,7 @@ describe('SessionSwarmService metadata compatibility', () => { const spawnTask: SessionSwarmSpawnTask = { ...spawnSessionTask('src/a.ts'), kind: 'spawn', - binding: { model: 'provider/secondary', thinking: 'low' }, + binding: { model: 'provider/pool', thinking: 'low' }, }; await expect( @@ -1167,7 +1165,7 @@ describe('SessionSwarmService metadata compatibility', () => { expect.objectContaining({ binding: { profile: 'coder', - model: 'provider/secondary', + model: 'provider/pool', thinking: 'low', }, }), @@ -1176,45 +1174,13 @@ describe('SessionSwarmService metadata compatibility', () => { expect.objectContaining({ type: 'subagent.spawned', subagentId: 'agent-new', - model: 'provider/secondary', + model: 'provider/pool', thinkingEffort: 'low', }), ); }); - it('emits the recipe base alias (never the derived entry id) as the spawned display model', async () => { - ix.stub( - IConfigService, - new StubConfigService({ - [SECONDARY_MODEL_SECTION]: { model: 'provider/base', defaultEffort: 'low' }, - }), - ); - ix.stub(IFlagService, stubFlag((id) => id === SECONDARY_MODEL_FLAG_ID)); - const service = ix.get(ISessionSwarmService); - const spawnTask: SessionSwarmSpawnTask = { - ...spawnSessionTask('src/a.ts'), - kind: 'spawn', - binding: { model: '__secondary__', thinking: 'low' }, - }; - - await expect( - service.run({ - callerAgentId: 'main', - tasks: [spawnTask], - }), - ).resolves.toMatchObject([{ status: 'completed', agentId: 'agent-new' }]); - - expect(eventBus.publish).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'subagent.spawned', - subagentId: 'agent-new', - model: 'provider/base', - thinkingEffort: 'low', - }), - ); - }); - - it('points at the secondary model config when a spawn task binding is invalid', async () => { + it('points at the [secondary_model.models] config when a spawn task binding is invalid', async () => { const service = ix.get(ISessionSwarmService); const spawnTask: SessionSwarmSpawnTask = { ...spawnSessionTask('src/a.ts'), @@ -1230,7 +1196,7 @@ describe('SessionSwarmService metadata compatibility', () => { ).resolves.toMatchObject([ { status: 'failed', - error: expect.stringContaining('comes from [secondary_model].model / KIMI_SECONDARY_MODEL'), + error: expect.stringContaining('comes from [secondary_model.models]'), }, ]); expect(createAgent).not.toHaveBeenCalled(); diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 300b96ca1d..930b13e96a 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -6,19 +6,12 @@ import { LifecycleScope } from '#/app/scopes'; import { type IAgentScopeHandle } from '#/_base/di/scope'; import { Event, type Event as KimiEvent } from '#/_base/event'; import { ILogService } from '#/_base/log/log'; -import { IFlagService } from '#/app/flag/flag'; -import { MASTER_ENV } from '#/app/flag/flagService'; import { toInputJsonSchema } from '#/tool/input-schema'; import { userCancellationReason } from '#/_base/utils/abort'; import { createHooks } from '#/hooks'; import type { ToolCall } from '#/kosong/contract/message'; import type { TokenUsage } from '#/kosong/contract/usage'; import { IModelCatalog, type Model } from '#/kosong/model/catalog'; -import { SECONDARY_DERIVED_MODEL_ID } from '#/app/kosongConfig/secondaryModelOverlay'; -import { - SECONDARY_MODEL_FLAG_ENV, - SECONDARY_MODEL_FLAG_ID, -} from '#/session/subagent/flag'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; @@ -40,7 +33,8 @@ import { SubagentToolInputSchema, type SubagentToolInput, } from '#/agent/tools/agent/agent'; -import { DEFAULT_SUBAGENT_TIMEOUT_MS } from '#/session/subagent/configSection'; +import { DEFAULT_SUBAGENT_TIMEOUT_MS, SECONDARY_MODEL_SECTION, SUBAGENT_SECTION } from '#/session/subagent/configSection'; +import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; import { Error2, ErrorCodes } from '#/errors'; import { runAgentTurn } from '#/session/subagent/runAgentTurn'; import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/subagent/mirrorAgentRun'; @@ -53,6 +47,8 @@ import { type RunAgentOptions, } from '#/session/subagent/subagent'; import { IEventBus, type DomainEvent } from '#/app/event/eventBus'; +import { IConfigService } from '#/app/config/config'; +import { IFlagService } from '#/app/flag/flag'; import { normalizeAgentProfile, type AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry'; import { ISessionCronService } from '#/session/cron/sessionCronService'; @@ -66,11 +62,13 @@ import type { import type { IProcess, ISessionProcessRunner } from '#/session/process/processRunner'; import { IWireService } from '#/wire/wire'; import { createFakeProcessRunner } from '../tools/fixtures/fake-exec'; +import { StubConfigService } from '../kosong/stubs'; +import { stubFlag } from '../app/flag/stubs'; import { + appService, configServices, createCommandRunner, createTestAgent, - appService, execEnvServices, externalHookServices, homeDirServices, @@ -82,7 +80,6 @@ import { type TestAgentServiceOverride, } from '../harness'; import { executeTool } from '../tools/fixtures/execute-tool'; -import { stubFlag } from '../app/flag/stubs'; const signal = new AbortController().signal; @@ -108,6 +105,16 @@ function agentSwarmSchemaProperties(): Record { const BACKGROUND_AGENT_NEXT_STEP = 'next_step: The completion arrives automatically in a later turn — do NOT wait, poll, or call TaskOutput on it; continue with other work or hand back to the user. (If you have nothing to do until it finishes, run such tasks in the foreground next time.)'; +/** + * Model entries backing the `[secondary_model.models]` pools used below: the harness + * creates a real session scope, so the startup pool validation resolves every + * pool alias through the real catalog unless a stub catalog is injected. + */ +const POOL_MODEL_ENTRIES = { + 'provider/fast': { provider: 'test-provider', model: 'fast-model', maxContextSize: 262_144 }, + 'provider/smart': { provider: 'test-provider', model: 'smart-model', maxContextSize: 262_144 }, +}; + function deferred(): { readonly promise: Promise; resolve(value: T): void; @@ -164,34 +171,6 @@ function noopDisposable() { return { dispose: () => {} }; } -function profileCatalogWithPreference( - profileName: string, - modelPreference: 'primary' | 'secondary', -): ISessionAgentProfileCatalog { - const main: AgentProfile = normalizeAgentProfile({ - name: 'agent', - description: 'Main agent', - systemPrompt: () => 'main', - }); - const target: AgentProfile = normalizeAgentProfile({ - name: profileName, - description: `${profileName} agent`, - modelPreference, - systemPrompt: () => profileName, - }); - return { - _serviceBrand: undefined, - ready: Promise.resolve(), - onDidChange: Event.None as ISessionAgentProfileCatalog['onDidChange'], - get: (name) => [main, target].find((profile) => profile.name === name), - getDefault: () => main, - list: () => [target], - inspect: () => undefined, - load: async () => {}, - reload: async () => {}, - }; -} - function modelCatalogResolving(...aliases: readonly string[]): IModelCatalog { return { _serviceBrand: undefined, @@ -490,11 +469,12 @@ describe('SubagentToolInputSchema', () => { expect(properties).not.toHaveProperty('timeout'); }); - it('exposes the model choice parameter in the JSON schema', () => { - const properties = agentSchemaProperties<{ description?: string; enum?: string[] }>(); + it('exposes the model parameter as a free-form string in the JSON schema', () => { + const properties = agentSchemaProperties<{ description?: string; type?: string; enum?: string[] }>(); - expect(properties['model']?.enum).toEqual(['secondary', 'primary']); - expect(properties['model']?.description).toContain('secondary model'); + expect(properties['model']?.type).toBe('string'); + expect(properties['model']?.enum).toBeUndefined(); + expect(properties['model']?.description).toContain('Available models'); }); it('normalizes the default subagent type into tool args', () => { @@ -794,81 +774,96 @@ describe('Agent tool description', () => { ); }); - it('shows the model preference for an agent type when the experiment is enabled', () => { - ctx = createTestAgent( - secondaryModelFlags(), - sessionService( - ISessionAgentProfileCatalog, - profileCatalogWithPreference('coder', 'primary'), - ), - ); - - expect(agentDescription()).toContain('- coder: coder agent\n Model preference: primary'); - }); - - it('hides model preferences when the experiment is disabled', () => { - ctx = createTestAgent( - secondaryModelFlags(false), - sessionService( - ISessionAgentProfileCatalog, - profileCatalogWithPreference('coder', 'primary'), - ), - ); - - expect(agentDescription()).not.toContain('Model preference:'); - }); - - it('omits the models section when no secondary model is configured', () => { + it('omits the models section when no [secondary_model.models] pool is configured', () => { ctx = createTestAgent(); expect(agentDescription()).not.toContain('Available models'); }); - it('lists both selectable models when the secondary-model env flag is enabled', () => { - vi.stubEnv(MASTER_ENV, '0'); - vi.stubEnv(SECONDARY_MODEL_FLAG_ENV, '1'); - ctx = createTestAgent({ - initialConfig: { secondaryModel: { model: 'provider/secondary' } }, + it('renders the pool in config order with the default first and a generic primary line', () => { + ctx = createTestAgent(secondaryModelFlags(), { + initialConfig: { + secondaryModel: { + defaultModel: 'provider/fast', + models: { + 'provider/fast': 'fast and cheap', + 'provider/smart': 'hard tasks', + }, + }, + models: POOL_MODEL_ENTRIES, + }, }); const description = agentDescription(); expect(description).toContain('Available models (pass via model):'); - expect(description).toContain('- secondary: provider/secondary (default)'); - expect(description).toContain('- primary: mock-model'); + // The caller's own model is not in the pool: generic primary hint last. + const defaultIndex = description.indexOf('- provider/fast [default]: fast and cheap'); + const smartIndex = description.indexOf('- provider/smart: hard tasks'); + const primaryIndex = description.indexOf( + '- primary: the main model you are running on, bound with your current thinking level; use it for hard, quality-sensitive subagent tasks', + ); + expect(defaultIndex).toBeGreaterThanOrEqual(0); + expect(smartIndex).toBeGreaterThan(defaultIndex); + expect(primaryIndex).toBeGreaterThan(smartIndex); }); - it('advertises the resolved capability flags for each selectable model', () => { + it('lists the caller-in-pool alias with a [main model] marker and renders empty descriptions bare', () => { ctx = createTestAgent(secondaryModelFlags(), { initialConfig: { - secondaryModel: { model: 'secondary-model' }, - models: { - 'secondary-model': { - provider: 'test-provider', - model: 'secondary-model', - maxContextSize: 262_144, - capabilities: ['image_in', 'thinking'], + secondaryModel: { + defaultModel: 'provider/fast', + models: { + 'provider/fast': 'fast and cheap', + 'mock-model': 'the main model, great at hard things', + 'provider/smart': '', }, }, + models: POOL_MODEL_ENTRIES, }, }); const description = agentDescription(); + expect(description).toContain('- provider/fast [default]: fast and cheap'); + // The caller's own alias is a normal pool entry: a pool binding carries no + // thinking, while the `primary` line below binds the same model WITH the + // caller's thinking level — both choices stay visible. + expect(description).toContain('- mock-model [main model]: the main model, great at hard things'); + // An empty-string description renders a bare alias line. + expect(description).toContain('- provider/smart\n'); expect(description).toContain( - '- secondary: secondary-model (default) — the configured secondary model; prefer it for routine subagent tasks; capabilities: image_in, thinking', - ); - expect(description).toContain( - '- primary: mock-model — the main model you are running on; use it for hard, quality-sensitive subagent tasks; capabilities: none', + '- primary (mock-model): the main model you are running on, bound with your current thinking level; use it for hard, quality-sensitive subagent tasks', ); }); - it('omits the models section when configured but the experiment is disabled', () => { - ctx = createTestAgent(secondaryModelFlags(false), { - initialConfig: { secondaryModel: { model: 'provider/secondary' } }, + it('marks the caller-as-default alias with both [default] and [main model]', () => { + ctx = createTestAgent(secondaryModelFlags(), { + initialConfig: { + secondaryModel: { + defaultModel: 'mock-model', + models: { + 'mock-model': 'the main model, great at hard things', + 'provider/fast': 'fast and cheap', + }, + }, + models: POOL_MODEL_ENTRIES, + }, }); - expect(agentDescription()).not.toContain('Available models'); + const description = agentDescription(); + + // The caller IS the default: the marker pair sits on its pool line, which + // still leads the list. + const defaultIndex = description.indexOf( + '- mock-model [default] [main model]: the main model, great at hard things', + ); + const fastIndex = description.indexOf('- provider/fast: fast and cheap'); + expect(defaultIndex).toBeGreaterThanOrEqual(0); + expect(fastIndex).toBeGreaterThan(defaultIndex); + expect(description).toContain( + '- primary (mock-model): the main model you are running on, bound with your current thinking level', + ); }); function agentParameters(): Record { @@ -877,10 +872,8 @@ describe('Agent tool description', () => { return tool!.parameters!; } - it('strips the model parameter from the advertised schema when the experiment is disabled', () => { - ctx = createTestAgent(secondaryModelFlags(false), { - initialConfig: { secondaryModel: { model: 'provider/secondary' } }, - }); + it('strips the model parameter from the advertised schema when no pool is configured', () => { + ctx = createTestAgent(); const properties = agentParameters()['properties'] as Record; @@ -888,14 +881,71 @@ describe('Agent tool description', () => { expect(properties).toHaveProperty('prompt'); }); - it('advertises the model parameter when the experiment is enabled', () => { + it('advertises the model parameter when a pool is configured', () => { + ctx = createTestAgent(secondaryModelFlags(), { + initialConfig: { + secondaryModel: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast and cheap' }, + }, + models: POOL_MODEL_ENTRIES, + }, + }); + + const properties = agentParameters()['properties'] as Record< + string, + { type?: string; enum?: unknown } + >; + + expect(properties['model']?.type).toBe('string'); + expect(properties['model']?.enum).toBeUndefined(); + }); + + it('strips the model parameter and pool description while the experiment is off', () => { + ctx = createTestAgent(secondaryModelFlags(false), { + initialConfig: { + secondaryModel: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast and cheap' }, + }, + models: POOL_MODEL_ENTRIES, + }, + }); + + const properties = agentParameters()['properties'] as Record; + expect(properties).not.toHaveProperty('model'); + expect(agentDescription()).not.toContain('Available models'); + }); + + it('treats a pool-less default_model as an implicit single-entry pool', () => { ctx = createTestAgent(secondaryModelFlags(), { - initialConfig: { secondaryModel: { model: 'provider/secondary' } }, + initialConfig: { + secondaryModel: { defaultModel: 'provider/fast' }, + models: POOL_MODEL_ENTRIES, + }, }); - const properties = agentParameters()['properties'] as Record; + const properties = agentParameters()['properties'] as Record; + expect(properties).toHaveProperty('model'); + + const description = agentDescription(); + expect(description).toContain('- provider/fast [default]\n'); + expect(description).toContain( + '- primary: the main model you are running on, bound with your current thinking level; use it for hard, quality-sensitive subagent tasks', + ); + }); + + it('hides the model parameter and the pool description when force is set', () => { + ctx = createTestAgent(secondaryModelFlags(), { + initialConfig: { + secondaryModel: { defaultModel: 'provider/fast', force: true }, + models: POOL_MODEL_ENTRIES, + }, + }); - expect(properties['model']?.enum).toEqual(['secondary', 'primary']); + const properties = agentParameters()['properties'] as Record; + expect(properties).not.toHaveProperty('model'); + expect(agentDescription()).not.toContain('Available models'); }); }); @@ -917,7 +967,7 @@ describe('Agent tool execution contract', () => { sessionService(ISessionSubagentService, lifecycle), sessionService(ISessionCronService, cronStub), modelProviderServices( - modelCatalogResolving('mock-model', 'provider/secondary', SECONDARY_DERIVED_MODEL_ID), + modelCatalogResolving('mock-model', 'provider/fast', 'provider/smart'), ), ...extra, ); @@ -1101,121 +1151,162 @@ describe('Agent tool execution contract', () => { expect(result.output).toContain('child result'); }); - it('spawns the subagent on the configured secondary model by default', async () => { + it('spawns the subagent on the pool default model when the tool call omits model', async () => { const lifecycle = createAgentLifecycleStub({ createAgentIds: ['agent-child'] }); - const context = createAgentToolContext( - lifecycle, - secondaryModelFlags(), - { - initialConfig: { - secondaryModel: { model: 'provider/secondary', defaultEffort: 'low' }, + const context = createAgentToolContext(lifecycle, secondaryModelFlags(), { + initialConfig: { + secondaryModel: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast and cheap', 'provider/smart': 'hard tasks' }, }, }, - ); + }); await executeAgentTool(context, { prompt: 'Investigate', description: 'Find cause', }); + // Pool bindings carry no explicit thinking: the subagent resolves thinking + // naturally instead of inheriting the caller's level. expect(lifecycle.create).toHaveBeenCalledWith( expect.objectContaining({ binding: expect.objectContaining({ - model: SECONDARY_DERIVED_MODEL_ID, - thinking: 'low', + model: 'provider/fast', + thinking: undefined, }), }), ); + expect(lifecycle.publishedEvents).toContainEqual( + expect.objectContaining({ + type: 'subagent.spawned', + subagentId: 'agent-child', + model: 'provider/fast', + }), + ); }); - it('reports the display-normalized model on the spawned signal', async () => { + it('spawns on the caller model when the tool call opts into "primary"', async () => { const lifecycle = createAgentLifecycleStub({ createAgentIds: ['agent-child'] }); - const context = createAgentToolContext( - lifecycle, - secondaryModelFlags(), - { - initialConfig: { - secondaryModel: { model: 'provider/secondary', defaultEffort: 'low' }, + const context = createAgentToolContext(lifecycle, secondaryModelFlags(), { + initialConfig: { + secondaryModel: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast and cheap' }, }, }, - ); + }); await executeAgentTool(context, { prompt: 'Investigate', description: 'Find cause', + model: 'primary', }); - expect(lifecycle.publishedEvents).toContainEqual( + expect(lifecycle.create).toHaveBeenCalledWith( expect.objectContaining({ - type: 'subagent.spawned', - subagentId: 'agent-child', - model: 'provider/secondary', + binding: expect.objectContaining({ + model: 'mock-model', + thinking: 'off', + }), }), ); }); - it('binds the pointed entry directly with natural thinking when the recipe has no patch', async () => { - const lifecycle = createAgentLifecycleStub({ createAgentIds: ['agent-child'] }); + it('binds the caller-in-pool alias without thinking, unlike "primary"', async () => { + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child', 'agent-child-2'], + }); const context = createAgentToolContext(lifecycle, secondaryModelFlags(), { - initialConfig: { secondaryModel: { model: 'provider/secondary' } }, + initialConfig: { + secondaryModel: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast and cheap', 'mock-model': 'the main model' }, + }, + }, }); + // Same model, two bindings: the pool alias resolves thinking naturally, + // "primary" inherits the caller's level. await executeAgentTool(context, { prompt: 'Investigate', description: 'Find cause', + model: 'mock-model', + }); + await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + model: 'primary', }); - expect(lifecycle.create).toHaveBeenCalledWith( + expect(lifecycle.create).toHaveBeenNthCalledWith( + 1, expect.objectContaining({ - binding: expect.objectContaining({ - model: 'provider/secondary', - thinking: undefined, - }), + binding: expect.objectContaining({ model: 'mock-model', thinking: undefined }), + }), + ); + expect(lifecycle.create).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + binding: expect.objectContaining({ model: 'mock-model', thinking: 'off' }), }), ); }); - it('spawns on the caller model when the tool call opts into "primary"', async () => { + it('spawns on the pool alias chosen via the model parameter', async () => { const lifecycle = createAgentLifecycleStub({ createAgentIds: ['agent-child'] }); - const context = createAgentToolContext( - lifecycle, - secondaryModelFlags(), - { - initialConfig: { - secondaryModel: { model: 'provider/secondary', defaultEffort: 'low' }, + const context = createAgentToolContext(lifecycle, secondaryModelFlags(), { + initialConfig: { + secondaryModel: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast and cheap', 'provider/smart': 'hard tasks' }, }, }, - ); + }); await executeAgentTool(context, { prompt: 'Investigate', description: 'Find cause', - model: 'primary', + model: 'provider/smart', }); expect(lifecycle.create).toHaveBeenCalledWith( expect.objectContaining({ binding: expect.objectContaining({ - model: 'mock-model', - thinking: 'off', + model: 'provider/smart', + thinking: undefined, }), }), ); }); - it('uses the target profile model preference when the tool call omits model', async () => { + it('rejects a model choice outside the pool, listing the available models', async () => { const lifecycle = createAgentLifecycleStub({ createAgentIds: ['agent-child'] }); - const context = createAgentToolContext( - lifecycle, - sessionService( - ISessionAgentProfileCatalog, - profileCatalogWithPreference('coder', 'primary'), - ), - secondaryModelFlags(), - { - initialConfig: { secondaryModel: { model: 'provider/secondary', defaultEffort: 'low' } }, + const context = createAgentToolContext(lifecycle, secondaryModelFlags(), { + initialConfig: { + secondaryModel: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast and cheap', 'provider/smart': 'hard tasks' }, + }, }, + }); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + model: 'provider/typo', + }); + + expect(result.isError).toBe(true); + expect(result.output).toContain( + 'Invalid model "provider/typo". Available models: provider/fast, provider/smart, primary.', ); + expect(lifecycle.create).not.toHaveBeenCalled(); + }); + + it('inherits the caller model when no pool is configured', async () => { + const lifecycle = createAgentLifecycleStub({ createAgentIds: ['agent-child'] }); + const context = createAgentToolContext(lifecycle); await executeAgentTool(context, { prompt: 'Investigate', @@ -1232,61 +1323,82 @@ describe('Agent tool execution contract', () => { ); }); - it('lets an explicit model override the target profile preference', async () => { + it('binds the forced default_model and rejects any explicit choice, "primary" included', async () => { const lifecycle = createAgentLifecycleStub({ createAgentIds: ['agent-child'] }); - const context = createAgentToolContext( - lifecycle, - sessionService( - ISessionAgentProfileCatalog, - profileCatalogWithPreference('coder', 'primary'), - ), - secondaryModelFlags(), - { - initialConfig: { secondaryModel: { model: 'provider/secondary', defaultEffort: 'low' } }, + const context = createAgentToolContext(lifecycle, secondaryModelFlags(), { + initialConfig: { + secondaryModel: { defaultModel: 'provider/fast', force: true }, }, - ); + }); - await executeAgentTool(context, { + // The model parameter is not advertised under force; a stray choice is + // rejected instead of binding anything. + const rejected = await executeAgentTool(context, { prompt: 'Investigate', description: 'Find cause', - model: 'secondary', + model: 'primary', }); + expect(rejected.isError).toBe(true); + expect(rejected.output).toContain('[secondary_model].force is set'); + expect(lifecycle.create).not.toHaveBeenCalled(); + await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); expect(lifecycle.create).toHaveBeenCalledWith( expect.objectContaining({ - binding: expect.objectContaining({ - model: SECONDARY_DERIVED_MODEL_ID, - thinking: 'low', - }), + binding: expect.objectContaining({ model: 'provider/fast', thinking: undefined }), }), ); }); - it('inherits the caller model when no secondary model is configured', async () => { + it('rejects a pool that gained the reserved "primary" key through a runtime config edit', async () => { const lifecycle = createAgentLifecycleStub({ createAgentIds: ['agent-child'] }); - const context = createAgentToolContext(lifecycle); + const context = createAgentToolContext(lifecycle, secondaryModelFlags(), { + initialConfig: { + secondaryModel: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast and cheap' }, + }, + }, + }); + // The startup validation already passed; now the pool breaks at runtime. + await (context.get(IConfigService) as StubConfigService).replace(SECONDARY_MODEL_SECTION, { + defaultModel: 'primary', + models: { primary: 'reserved word' }, + }); - await executeAgentTool(context, { + const result = await executeAgentTool(context, { prompt: 'Investigate', description: 'Find cause', - model: 'secondary', }); - expect(lifecycle.create).toHaveBeenCalledWith( - expect.objectContaining({ - binding: expect.objectContaining({ - model: 'mock-model', - thinking: 'off', - }), - }), - ); + expect(result.isError).toBe(true); + expect(result.output).toContain('[secondary_model.models] key "primary" is reserved'); + expect(lifecycle.create).not.toHaveBeenCalled(); }); - it('points at the secondary model config when the configured alias is invalid', async () => { - const lifecycle = createAgentLifecycleStub(); - const context = createAgentToolContext(lifecycle, secondaryModelFlags(), { - initialConfig: { secondaryModel: { model: 'provider/bad' } }, + it('points at the [secondary_model.models] config when the bound alias stops resolving', async () => { + // The pool validates at session creation, but a later config edit (or a + // catalog refresh) can still leave the bound alias dangling at spawn time. + const lifecycle = createAgentLifecycleStub({ + createError: new Error2( + ErrorCodes.CONFIG_INVALID, + 'Model "provider/bad" is not configured in config.toml.', + { details: { model: 'provider/bad' } }, + ), }); + const context = createAgentToolContext( + lifecycle, + modelProviderServices(modelCatalogResolving('mock-model', 'provider/bad')), + secondaryModelFlags(), + { + initialConfig: { + secondaryModel: { defaultModel: 'provider/bad', models: { 'provider/bad': 'broken' } }, + }, + }, + ); const result = await executeAgentTool(context, { prompt: 'Investigate', @@ -1295,16 +1407,17 @@ describe('Agent tool execution contract', () => { expect(result.isError).toBe(true); expect(result.output).toContain('Model "provider/bad" is not configured in config.toml.'); - expect(result.output).toContain('comes from [secondary_model].model / KIMI_SECONDARY_MODEL'); - expect(lifecycle.create).not.toHaveBeenCalled(); + expect(result.output).toContain('comes from [secondary_model.models]'); }); it('does not rewrite spawn failures unrelated to the model config', async () => { const lifecycle = createAgentLifecycleStub({ createError: new Error('MCP server failed to start'), }); - const context = createAgentToolContext(lifecycle, { - initialConfig: { secondaryModel: { model: 'provider/secondary' } }, + const context = createAgentToolContext(lifecycle, secondaryModelFlags(), { + initialConfig: { + secondaryModel: { defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap' } }, + }, }); const result = await executeAgentTool(context, { @@ -1315,7 +1428,7 @@ describe('Agent tool execution contract', () => { expect(result.isError).toBe(true); expect(result.output).toContain('MCP server failed to start'); - expect(result.output).not.toContain('KIMI_SECONDARY_MODEL'); + expect(result.output).not.toContain('[secondary_model.models]'); }); it('mirrors v1-compatible subagent lifecycle event fields', async () => { @@ -2164,7 +2277,7 @@ describe('AgentSwarmToolInputSchema', () => { expect(properties['subagent_type']?.description).toContain('defaults to coder'); expect(properties['resume_agent_ids']?.description).toContain('Map of existing subagent'); - expect(properties['model']?.description).toContain('secondary model'); + expect(properties['model']?.description).toContain('Available models'); expect(properties).not.toHaveProperty('run_in_background'); expect(properties).not.toHaveProperty('timeout'); }); @@ -2202,22 +2315,31 @@ describe('AgentSwarm tool description', () => { ); }); - it('omits the models section when no secondary model is configured', () => { + it('omits the models section when no [secondary_model.models] pool is configured', () => { ctx = createTestAgent(); expect(agentSwarmDescription()).not.toContain('Available models'); }); - it('lists both selectable models when a secondary model is configured', () => { + it('renders the configured pool with the default marker and a generic primary line', () => { ctx = createTestAgent(secondaryModelFlags(), { - initialConfig: { secondaryModel: { model: 'provider/secondary' } }, + initialConfig: { + secondaryModel: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast and cheap', 'provider/smart': 'hard tasks' }, + }, + models: POOL_MODEL_ENTRIES, + }, }); const description = agentSwarmDescription(); expect(description).toContain('Available models (pass via model):'); - expect(description).toContain('- secondary: provider/secondary (default)'); - expect(description).toContain('- primary: mock-model'); + expect(description).toContain('- provider/fast [default]: fast and cheap'); + expect(description).toContain('- provider/smart: hard tasks'); + expect(description).toContain( + '- primary: the main model you are running on, bound with your current thinking level; use it for hard, quality-sensitive subagent tasks', + ); }); function agentSwarmParameters(): Record { @@ -2226,10 +2348,8 @@ describe('AgentSwarm tool description', () => { return tool!.parameters!; } - it('strips the model parameter from the advertised schema when the experiment is disabled', () => { - ctx = createTestAgent(secondaryModelFlags(false), { - initialConfig: { secondaryModel: { model: 'provider/secondary' } }, - }); + it('strips the model parameter from the advertised schema when no pool is configured', () => { + ctx = createTestAgent(); const properties = agentSwarmParameters()['properties'] as Record; @@ -2237,14 +2357,24 @@ describe('AgentSwarm tool description', () => { expect(properties).toHaveProperty('prompt_template'); }); - it('advertises the model parameter when the experiment is enabled', () => { + it('advertises the model parameter when a pool is configured', () => { ctx = createTestAgent(secondaryModelFlags(), { - initialConfig: { secondaryModel: { model: 'provider/secondary' } }, + initialConfig: { + secondaryModel: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast and cheap' }, + }, + models: POOL_MODEL_ENTRIES, + }, }); - const properties = agentSwarmParameters()['properties'] as Record; + const properties = agentSwarmParameters()['properties'] as Record< + string, + { type?: string; enum?: unknown } + >; - expect(properties['model']?.enum).toEqual(['secondary', 'primary']); + expect(properties['model']?.type).toBe('string'); + expect(properties['model']?.enum).toBeUndefined(); }); }); @@ -2331,7 +2461,7 @@ describe('AgentSwarm tool execution contract', () => { expect(result.isError).toBeUndefined(); }); - it('threads the configured secondary model into spawn task bindings', async () => { + it('threads the pool default model into spawn task bindings', async () => { const runSwarm = vi.fn( async ( args: SessionSwarmRunArgs, @@ -2355,7 +2485,11 @@ describe('AgentSwarm tool execution contract', () => { secondaryModelFlags(), { initialConfig: { - secondaryModel: { model: 'provider/secondary', defaultEffort: 'low' }, + secondaryModel: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast and cheap', 'provider/smart': 'hard tasks' }, + }, + models: POOL_MODEL_ENTRIES, }, }, ); @@ -2377,67 +2511,17 @@ describe('AgentSwarm tool execution contract', () => { tasks: [ expect.objectContaining({ kind: 'spawn', - binding: { model: SECONDARY_DERIVED_MODEL_ID, thinking: 'low' }, + binding: { model: 'provider/fast', thinking: undefined }, }), expect.objectContaining({ kind: 'spawn', - binding: { model: SECONDARY_DERIVED_MODEL_ID, thinking: 'low' }, + binding: { model: 'provider/fast', thinking: undefined }, }), ], }), ); }); - it('uses the target profile model preference for item-based spawns', async () => { - const runSwarm = vi.fn( - async (args: SessionSwarmRunArgs): Promise => - args.tasks.map((task, index) => ({ - task, - agentId: `agent-explore-${String(index + 1)}`, - status: 'completed' as const, - result: 'ok', - })), - ); - const swarmService: ISessionSwarmService = { - _serviceBrand: undefined, - getSwarmItem: async () => undefined, - run: runSwarm as ISessionSwarmService['run'], - cancel: () => {}, - }; - ctx = createTestAgent( - swarmServices(swarmService), - sessionService( - ISessionAgentProfileCatalog, - profileCatalogWithPreference('explore', 'primary'), - ), - secondaryModelFlags(), - { - initialConfig: { secondaryModel: { model: 'provider/secondary', defaultEffort: 'low' } }, - }, - ); - - await executeTool(agentSwarmTool(ctx), { - turnId: 0, - toolCallId: 'call_swarm', - args: { - description: 'Review files', - prompt_template: 'Review {{item}}', - items: ['src/a.ts', 'src/b.ts'], - subagent_type: 'explore', - }, - signal, - }); - - expect(runSwarm).toHaveBeenCalledWith( - expect.objectContaining({ - tasks: [ - expect.objectContaining({ binding: { model: 'mock-model', thinking: 'off' } }), - expect.objectContaining({ binding: { model: 'mock-model', thinking: 'off' } }), - ], - }), - ); - }); - it('threads the caller model into spawn task bindings when the tool call opts into "primary"', async () => { const runSwarm = vi.fn( async ( @@ -2462,7 +2546,11 @@ describe('AgentSwarm tool execution contract', () => { secondaryModelFlags(), { initialConfig: { - secondaryModel: { model: 'provider/secondary', defaultEffort: 'low' }, + secondaryModel: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast and cheap' }, + }, + models: POOL_MODEL_ENTRIES, }, }, ); diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts index d8810934ac..b0da2dce2f 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts @@ -18,6 +18,7 @@ import { ILogService } from '#/_base/log/log'; import type { Hooks } from '#/hooks'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; +import { IFlagService } from '#/app/flag/flag'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; @@ -41,7 +42,12 @@ import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; import { IAgentPlanService } from '#/features/plan/plan'; import { ISessionCronService } from '#/session/cron/sessionCronService'; -import { ISessionSecondaryModelWarningService } from '#/session/subagent/secondaryModelWarning'; +import { ISessionSubagentModelsValidationService } from '#/session/subagent/subagentModelsValidation'; +import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; +import { IModelCatalog } from '#/kosong/model/catalog'; +import { IModelService } from '#/kosong/model/model'; +import { IProviderService } from '#/kosong/provider/provider'; +import { stubProviderService } from '../../app/provider/stubs'; import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence'; import { CRON_SESSION_TAG, type CronTask } from '#/app/cron/cronTask'; import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; @@ -81,6 +87,7 @@ import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { Error2, ErrorCodes } from '#/errors'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; +import { stubFlag } from '../../app/flag/stubs'; import { stubLog } from '../../_base/log/stubs'; function bootstrapStub(): IBootstrapService { @@ -428,6 +435,33 @@ function configStub(values: Record = {}): IConfigService { } as unknown as IConfigService; } +function modelCatalogStub(knownIds: readonly string[] = []): IModelCatalog { + return { + _serviceBrand: undefined, + get: (id: string) => { + if (!knownIds.includes(id)) { + throw new Error2( + ErrorCodes.CONFIG_INVALID, + `Model "${id}" is not configured in config.toml.`, + { details: { model: id } }, + ); + } + return { id }; + }, + } as unknown as IModelCatalog; +} + +function modelServiceStub(ready: Promise = Promise.resolve()): IModelService { + return { + _serviceBrand: undefined, + ready, + } as unknown as IModelService; +} + +function secondaryModelFlagStub(enabled: boolean): IFlagService { + return stubFlag((id) => enabled && id === SECONDARY_MODEL_FLAG_ID); +} + function agentLifecycleCapturingPlanSpy(opts: { mainPreexists?: boolean } = {}): { lifecycle: IAgentLifecycleService; enter: ReturnType; @@ -599,11 +633,14 @@ describe('SessionLifecycleService', () => { stubPair(IAgentLifecycleService, agentLifecycleStub()), stubPair(IWorkspaceMcpService, workspaceMcpServiceStub()), stubPair(IConfigService, configStub()), + stubPair(IModelCatalog, modelCatalogStub()), + stubPair(IModelService, modelServiceStub()), + stubPair(IProviderService, stubProviderService()), + stubPair(IFlagService, secondaryModelFlagStub(false)), stubPair(ISessionCronService, { _serviceBrand: undefined } as unknown as ISessionCronService), - stubPair(ISessionSecondaryModelWarningService, { + stubPair(ISessionSubagentModelsValidationService, { _serviceBrand: undefined, - getSecondaryModelWarning: () => undefined, - } as ISessionSecondaryModelWarningService), + } as ISessionSubagentModelsValidationService), stubPair(IProjectLocalConfigService, projectLocalConfigStub()), stubPair(IHostFsWatchService, { _serviceBrand: undefined, @@ -667,6 +704,157 @@ describe('SessionLifecycleService', () => { expect(svc.get('s2')).toBeDefined(); }); + it('rejects create with CONFIG_INVALID for a broken subagent model pool before registering anything', async () => { + const svc = await build([ + stubPair( + IConfigService, + configStub({ secondaryModel: { models: { 'provider/fast': 'fast and cheap' } } }), + ), + stubPair(IModelCatalog, modelCatalogStub(['provider/fast'])), + stubPair(IFlagService, secondaryModelFlagStub(true)), + ]); + + await expect(svc.create({ sessionId: 's-broken', workDir: '/tmp/proj' })).rejects.toMatchObject( + { + code: ErrorCodes.CONFIG_INVALID, + message: expect.stringContaining('[secondary_model].default_model is required'), + }, + ); + expect(svc.get('s-broken')).toBeUndefined(); + }); + + it('creates a session when the subagent model pool is valid', async () => { + const svc = await build([ + stubPair( + IConfigService, + configStub({ + secondaryModel: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast and cheap' }, + }, + }), + ), + stubPair(IModelCatalog, modelCatalogStub(['provider/fast'])), + stubPair(IFlagService, secondaryModelFlagStub(true)), + ]); + + const h = await svc.create({ sessionId: 's-pool', workDir: '/tmp/proj' }); + expect(svc.get('s-pool')).toBe(h); + }); + + it('waits for the model/provider registries before validating the subagent model pool', async () => { + let releaseRegistries!: () => void; + const registriesReady = new Promise((resolve) => { + releaseRegistries = resolve; + }); + let registriesReleased = false; + const coldRegistryCatalog = { + _serviceBrand: undefined, + get: (id: string) => { + if (!registriesReleased) { + throw new Error2( + ErrorCodes.CONFIG_INVALID, + `Model "${id}" is not configured in config.toml.`, + { details: { model: id } }, + ); + } + return { id }; + }, + } as unknown as IModelCatalog; + const svc = await build([ + stubPair( + IConfigService, + configStub({ + secondaryModel: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast and cheap' }, + }, + }), + ), + stubPair(IModelCatalog, coldRegistryCatalog), + stubPair(IModelService, modelServiceStub(registriesReady)), + stubPair(IProviderService, stubProviderService({}, registriesReady)), + stubPair(IFlagService, secondaryModelFlagStub(true)), + ]); + + // A cold bootstrap can reach create before the kosong registries finish + // hydrating: the pre-flight must hold, not fail the valid pool against an + // empty registry. + let settled = false; + const pending = svc.create({ sessionId: 's-race', workDir: '/tmp/proj' }).then((created) => { + settled = true; + return created; + }); + await tick(); + expect(settled).toBe(false); + + registriesReleased = true; + releaseRegistries(); + const h = await pending; + expect(svc.get('s-race')).toBe(h); + }); + + it('rejects create with CONFIG_INVALID when force is set without default_model', async () => { + const svc = await build([ + stubPair(IConfigService, configStub({ secondaryModel: { force: true } })), + stubPair(IModelCatalog, modelCatalogStub(['provider/fast'])), + stubPair(IFlagService, secondaryModelFlagStub(true)), + ]); + + await expect(svc.create({ sessionId: 's-force', workDir: '/tmp/proj' })).rejects.toMatchObject( + { + code: ErrorCodes.CONFIG_INVALID, + message: expect.stringContaining('[secondary_model].default_model is required'), + }, + ); + expect(svc.get('s-force')).toBeUndefined(); + }); + + it('creates a session with a broken pool while the secondary-model experiment is off', async () => { + const svc = await build([ + stubPair( + IConfigService, + configStub({ secondaryModel: { models: { 'provider/fast': 'fast and cheap' } } }), + ), + stubPair(IModelCatalog, modelCatalogStub(['provider/fast'])), + ]); + + const h = await svc.create({ sessionId: 's-inert', workDir: '/tmp/proj' }); + expect(svc.get('s-inert')).toBe(h); + }); + + it('rejects fork with CONFIG_INVALID for a broken pool before copying any files', async () => { + const root = await makeTmpRoot(); + const sections: Record = { + secondaryModel: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast and cheap' }, + }, + }; + const svc = await build([ + stubPair(IBootstrapService, tmpBootstrapStub(root)), + stubPair(IConfigService, { + get: (domain: string) => sections[domain], + getAll: () => ({ ...sections }), + onDidChangeConfiguration: () => ({ dispose: () => {} }), + onDidSectionChange: () => ({ dispose: () => {} }), + } as unknown as IConfigService), + stubPair(IModelCatalog, modelCatalogStub(['provider/fast'])), + stubPair(IFlagService, secondaryModelFlagStub(true)), + ]); + await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); + const srcDir = join(root, 'sessions', 'wd_stub', 'src'); + await mkdir(srcDir, { recursive: true }); + await writeFile(join(srcDir, 'marker'), 'src'); + sections['secondaryModel'] = { models: { 'provider/fast': 'fast and cheap' } }; + + await expect(svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' })).rejects.toMatchObject({ + code: ErrorCodes.CONFIG_INVALID, + }); + expect(svc.get('dst')).toBeUndefined(); + await expect(stat(join(root, 'sessions', 'wd_stub', 'dst'))).rejects.toThrow(); + }); + it('create appends the session to the shared session_index.jsonl', async () => { const appended: unknown[] = []; const svc = await build([ diff --git a/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentFile.test.ts b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentFile.test.ts index bafe856312..b2d5457db2 100644 --- a/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentFile.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceAgentProfileLoader/agentFile.test.ts @@ -58,7 +58,6 @@ describe('parseAgentFileText', () => { const def = parse('---\nname: solo\ndescription: d\n---\n\nbody\n'); expect(def.override).toBe(false); - expect(def.modelPreference).toBeUndefined(); expect(def.tools).toBeUndefined(); expect(def.disallowedTools).toBeUndefined(); expect(def.subagents).toBeUndefined(); @@ -66,22 +65,6 @@ describe('parseAgentFileText', () => { expect(def.prompt).toBe('body'); }); - it('parses a symbolic model preference', () => { - const def = parse( - '---\nname: solo\ndescription: d\nmodel_preference: primary\n---\n\nbody\n', - ); - - expect(def.modelPreference).toBe('primary'); - }); - - it('rejects an unsupported model preference', () => { - expect(() => - parse( - '---\nname: solo\ndescription: d\nmodel_preference: provider/model\n---\n\nbody\n', - ), - ).toThrow(/"model_preference"/); - }); - it('rejects missing frontmatter', () => { expect(() => parse('no frontmatter here')).toThrow(AgentFileParseError); }); @@ -327,12 +310,6 @@ describe('agentProfileFromFile', () => { expect(profile.subagents).toEqual(['explore']); }); - it('passes the model preference through', () => { - const profile = agentProfileFromFile({ ...base, modelPreference: 'secondary' }, basePrompt); - - expect(profile.modelPreference).toBe('secondary'); - }); - it('treats an explicit file as an override intent', () => { const profile = agentProfileFromFile({ ...base, source: 'explicit' }, basePrompt); diff --git a/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts b/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts index e818bf63fb..326ad9caf1 100644 --- a/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts @@ -35,6 +35,11 @@ import { Event } from '#/_base/event'; import { ILogService } from '#/_base/log/log'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; +import { IModelCatalog } from '#/kosong/model/catalog'; +import { IModelService } from '#/kosong/model/model'; +import { IProviderService } from '#/kosong/provider/provider'; +import { stubProviderService } from '../../app/provider/stubs'; +import { IFlagService } from '#/app/flag/flag'; import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence'; import { IEventService } from '#/app/event/event'; import { @@ -86,6 +91,7 @@ import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; import { stubLog } from '../../_base/log/stubs'; +import { stubFlag } from '../../app/flag/stubs'; function workspaceCatalogStub(): IWorkspaceService { const workspaces = new Map(); @@ -296,6 +302,13 @@ describe('workspace add-dir (handler chain)', () => { get: () => undefined, onDidSectionChange: () => ({ dispose: () => {} }), } as unknown as IConfigService), + stubPair(IModelCatalog, { _serviceBrand: undefined } as unknown as IModelCatalog), + stubPair(IModelService, { + _serviceBrand: undefined, + ready: Promise.resolve(), + } as unknown as IModelService), + stubPair(IProviderService, stubProviderService()), + stubPair(IFlagService, stubFlag(() => false)), stubPair(ITelemetryService, noopTelemetryService), stubPair(IWorkspaceService, workspaceCatalogStub()), stubPair(ISessionIndex, { diff --git a/packages/agent-core-v2/test/workspace/workspaceResources.test.ts b/packages/agent-core-v2/test/workspace/workspaceResources.test.ts index ede234fc43..882cf2951b 100644 --- a/packages/agent-core-v2/test/workspace/workspaceResources.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceResources.test.ts @@ -37,6 +37,11 @@ import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoad import { PluginAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService'; import { IBootstrapService, resolveHostArgs } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; +import { IModelCatalog } from '#/kosong/model/catalog'; +import { IModelService } from '#/kosong/model/model'; +import { IProviderService } from '#/kosong/provider/provider'; +import { stubProviderService } from '../app/provider/stubs'; +import { IFlagService } from '#/app/flag/flag'; import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence'; import { IEventService } from '#/app/event/event'; import { IPluginService } from '#/app/plugin/plugin'; @@ -103,6 +108,7 @@ import { IPluginSkillSource, PluginSkillSource } from '#/workspace/workspaceSkil import { IWorkspaceRootSkillSource, WorkspaceRootSkillSource } from '#/workspace/workspaceSkillCatalog/rootFileSkillSource'; import { stubLog } from '../_base/log/stubs'; +import { stubFlag } from '../app/flag/stubs'; import { stubSkill } from '../app/skillCatalog/stubs'; import { stdioFixture } from '../mcpCore/stubs'; @@ -296,6 +302,13 @@ describe('workspace resource sharing (handler chain)', () => { get: () => undefined, onDidSectionChange: () => ({ dispose: () => {} }), } as unknown as IConfigService), + stubPair(IModelCatalog, { _serviceBrand: undefined } as unknown as IModelCatalog), + stubPair(IModelService, { + _serviceBrand: undefined, + ready: Promise.resolve(), + } as unknown as IModelService), + stubPair(IProviderService, stubProviderService()), + stubPair(IFlagService, stubFlag(() => false)), stubPair(ITelemetryService, noopTelemetryService), stubPair(ISkillDiscovery, discovery), stubPair(IPluginService, pluginStub()), diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 92033add57..c54bd0896b 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -123,7 +123,7 @@ export class Agent { /** * 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) + * session can push live config updates (e.g. a `/secondary-model` switch) * to already-instantiated agents. */ kimiConfig?: KimiConfig; diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 0e4b7bcaa7..62770fde97 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -99,9 +99,17 @@ export type ModelAlias = z.infer; * materialized into a synthesized derived model entry at runtime (see * `config/secondary-model.ts`). `default_effort` doubles as the subagent * thinking effort. + * + * The section is shared with the v2 engine's subagent model pool, whose keys + * (`default_model`, `[secondary_model.models]`, `force`) are declared here so + * the config write path round-trips them; the default engine never consumes + * them, and `secondaryModelPatch` excludes them from the recipe patch. */ export const SecondaryModelConfigSchema = ModelAliasOverrideSchema.extend({ model: z.string().min(1).optional(), + defaultModel: z.string().min(1).optional(), + models: z.record(z.string(), z.string()).optional(), + force: z.boolean().optional(), }); export type SecondaryModelConfig = z.infer; diff --git a/packages/agent-core/src/config/secondary-model.ts b/packages/agent-core/src/config/secondary-model.ts index 507e36b71c..e353fbcd0d 100644 --- a/packages/agent-core/src/config/secondary-model.ts +++ b/packages/agent-core/src/config/secondary-model.ts @@ -37,15 +37,23 @@ function trimmed(value: string | undefined): string | undefined { } /** - * 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. + * The patch half of the recipe: every field except `model` and the v2 pool + * keys (`defaultModel` / `models` / `force`, which live in the same section + * but are not model overrides). 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 { + model: _model, + defaultModel: _defaultModel, + models: _models, + force: _force, + ...rawPatch + } = secondary; const patch = Object.fromEntries( Object.entries(rawPatch).filter(([, value]) => value !== undefined), ) as ModelAliasOverrides; @@ -101,7 +109,7 @@ export function applySecondaryModelConfig(config: KimiConfig, env: Env = process * 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 + * — e.g. a `/secondary-model` pick made under `KIMI_SECONDARY_MODEL` — does * reach the disk, mirroring the pointer check in `stripEnvModelConfig`). */ export function stripSecondaryModelConfig( diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 50762884f8..b9648b660f 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -810,7 +810,7 @@ export class Session { * `[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 + * 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 diff --git a/packages/agent-core/test/config/secondary-model.test.ts b/packages/agent-core/test/config/secondary-model.test.ts index 723cc1d9dc..424690559f 100644 --- a/packages/agent-core/test/config/secondary-model.test.ts +++ b/packages/agent-core/test/config/secondary-model.test.ts @@ -53,6 +53,25 @@ describe('secondaryModelPatch', () => { secondaryModelPatch({ model: 'cheap', maxContextSize: 1024, defaultEffort: 'low' }), ).toEqual({ maxContextSize: 1024, defaultEffort: 'low' }); }); + + it('excludes the v2 pool keys (defaultModel / models / force) from the patch', () => { + expect( + secondaryModelPatch({ + model: 'cheap', + defaultModel: 'fast', + models: { fast: 'fast and cheap' }, + force: true, + }), + ).toBeUndefined(); + expect( + secondaryModelPatch({ + model: 'cheap', + defaultModel: 'fast', + force: true, + maxOutputSize: 8192, + }), + ).toEqual({ maxOutputSize: 8192 }); + }); }); describe('applySecondaryModelConfig', () => { @@ -155,7 +174,7 @@ describe('stripSecondaryModelConfig', () => { }); it('keeps a genuinely new selection that differs from the env values', () => { - // `/secondary_model` under KIMI_SECONDARY_MODEL: the picked recipe must + // `/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'), diff --git a/packages/kap-server/src/error-handler.ts b/packages/kap-server/src/error-handler.ts index 010eb6a05d..bea7c92467 100644 --- a/packages/kap-server/src/error-handler.ts +++ b/packages/kap-server/src/error-handler.ts @@ -9,7 +9,11 @@ * - `data: null`. * * Validation failures are handled by route-level middleware as 40001 - * `validation.failed`; this handler remains the catch-all unknown-exception path. + * `validation.failed`; this handler remains the catch-all unknown-exception + * path, with one coded exception: an `Error2(config.invalid)` escaping a + * route (e.g. a session resume that fails the subagent model-pool check + * outside any route-level mapper) maps to 40001 as well — a broken user + * config is a client error, not a server fault. * * The handler logs `err` + the resolved `request_id` so operators can * correlate log lines with the envelope returned to the client. This is the @@ -17,6 +21,8 @@ * we never bleed it into the JSON response. */ +import { ErrorCodes, isError2 } from '@moonshot-ai/agent-core-v2'; + import { errEnvelope } from './envelope'; import { ErrorCode } from './protocol/error-codes'; import type { FastifyError } from 'fastify'; @@ -40,6 +46,12 @@ interface ErrorHandlerHost { export function installErrorHandler(app: ErrorHandlerHost): void { app.setErrorHandler((err, req, reply) => { const requestId = req.id; + if (isError2(err) && err.code === ErrorCodes.CONFIG_INVALID) { + reply + .status(200) + .send(errEnvelope(ErrorCode.VALIDATION_FAILED, err.message, requestId, err.stack)); + return; + } req.log.error({ err, request_id: requestId }, 'unhandled error'); reply.status(200).send( errEnvelope( diff --git a/packages/kap-server/src/protocol/rest-config.ts b/packages/kap-server/src/protocol/rest-config.ts index ef2b478c43..f52b268d4d 100644 --- a/packages/kap-server/src/protocol/rest-config.ts +++ b/packages/kap-server/src/protocol/rest-config.ts @@ -13,7 +13,6 @@ export const configResponseSchema = z.object({ default_provider: z.string().optional(), default_model: z.string().optional(), models: z.record(z.string(), z.unknown()).optional(), - secondary_model: z.unknown().optional(), thinking: z.unknown().optional(), plan_mode: z.boolean().optional(), yolo: z.boolean().optional(), @@ -26,6 +25,8 @@ export const configResponseSchema = z.object({ extra_skill_dirs: z.array(z.string()).optional(), loop_control: z.unknown().optional(), background: z.unknown().optional(), + subagent: z.unknown().optional(), + secondary_model: z.unknown().optional(), experimental: z.record(z.string(), z.boolean()).optional(), telemetry: z.boolean().optional(), raw: z.record(z.string(), z.unknown()).optional(), @@ -37,7 +38,6 @@ export const patchConfigRequestSchema = z.object({ default_provider: z.string().optional(), default_model: z.string().optional(), models: z.record(z.string(), z.unknown()).optional(), - secondary_model: z.unknown().optional(), thinking: z.unknown().optional(), plan_mode: z.boolean().optional(), yolo: z.boolean().optional(), @@ -50,6 +50,8 @@ export const patchConfigRequestSchema = z.object({ extra_skill_dirs: z.array(z.string()).optional(), loop_control: z.unknown().optional(), background: z.unknown().optional(), + subagent: z.unknown().optional(), + secondary_model: z.unknown().optional(), experimental: z.record(z.string(), z.boolean()).optional(), telemetry: z.boolean().optional(), }); diff --git a/packages/kap-server/src/routes/config.ts b/packages/kap-server/src/routes/config.ts index caa94c6cde..52e321996d 100644 --- a/packages/kap-server/src/routes/config.ts +++ b/packages/kap-server/src/routes/config.ts @@ -14,8 +14,7 @@ * that: * - projects `getAll()` (camelCase resolved config) into the snake_case * `ConfigResponse`, redacting provider credentials to `has_api_key` - * (mirrors v1 `toConfigResponse`) and hiding the synthesized - * `__secondary__` derived entry from `models` (mirrors `GET /models`); + * (mirrors v1 `toConfigResponse`); * - splits v1's flat multi-domain `POST /config` patch into per-domain * `IConfigService.set(domain, value)` calls (snake_case → camelCase); * - republishes the change as a v2 `DomainEvent` on `IEventService`. @@ -30,7 +29,6 @@ import { IConfigService, IEventService, - SECONDARY_DERIVED_MODEL_ID, type Scope, } from '@moonshot-ai/agent-core-v2'; @@ -132,20 +130,14 @@ export function registerConfigRoutes(app: ConfigRouteHost, core: Scope): void { // Edge facade — project the v2 resolved config into the v1 `ConfigResponse` // wire shape. Top-level domain keys are mapped camelCase→snake_case generically, // so this route does not enumerate the config domains; values pass through -// unchanged except `providers`, whose credentials are redacted to `has_api_key`, -// and `models`, which drops the internal `__secondary__` derived entry (the -// only domain-specific transforms). Pure projection: no service calls. +// unchanged except `providers`, whose credentials are redacted to `has_api_key` +// (the only domain-specific transform). Pure projection: no service calls. // --------------------------------------------------------------------------- function toConfigResponse(resolved: Record): ConfigResponse { const wire: Record = {}; for (const [domain, value] of Object.entries(resolved)) { - wire[camelToSnake(domain)] = - domain === 'providers' - ? toProviderResponses(value) - : domain === 'models' - ? withoutDerivedSecondaryEntry(value) - : value; + wire[camelToSnake(domain)] = domain === 'providers' ? toProviderResponses(value) : value; } // v1 wire echo: surface `yolo` as a derived boolean of the effective default // permission mode. `yolo` is not a config domain; it is computed here so the @@ -169,20 +161,6 @@ interface ProviderLike { readonly oauth?: unknown; } -/** - * The `models` effective view carries the synthesized `__secondary__` derived - * entry whenever `[secondary_model]` has patch fields. It is an internal - * routing artifact (hidden from the `GET /models` picker the same way) and - * can never persist — the overlay's `strip` removes it from `models` writes — - * so keep it off the wire here too. - */ -function withoutDerivedSecondaryEntry(value: unknown): unknown { - if (!isPlainObject(value) || !(SECONDARY_DERIVED_MODEL_ID in value)) return value; - const out: Record = { ...value }; - delete out[SECONDARY_DERIVED_MODEL_ID]; - return out; -} - function toProviderResponses(value: unknown): Record { const result: Record = {}; if (!isPlainObject(value)) return result; @@ -214,14 +192,29 @@ function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function convertKeysSnakeToCamel(obj: unknown): unknown { +/** + * Config properties whose values are maps keyed by user-defined identifiers + * (provider ids, model aliases, subagent pool aliases, flag names). Those keys + * are data, not field names — snake→camel conversion must pass them through + * untouched (`fast_model` must not become `fastModel`), while the map *values* + * (e.g. a provider's `api_key`) still convert. Preserve mode therefore only + * engages from a normal field-name level: an entry key that happens to match + * the list (a provider literally named `models`) must not keep its own + * children preserved. + */ +const MAP_VALUED_CONFIG_KEYS = new Set(['providers', 'models', 'experimental', 'raw']); + +function convertKeysSnakeToCamel(obj: unknown, preserveKeys = false): unknown { if (Array.isArray(obj)) { - return obj.map(convertKeysSnakeToCamel); + return obj.map((item) => convertKeysSnakeToCamel(item)); } if (isPlainObject(obj)) { const result: Record = {}; for (const [key, value] of Object.entries(obj)) { - result[snakeToCamel(key)] = convertKeysSnakeToCamel(value); + result[preserveKeys ? key : snakeToCamel(key)] = convertKeysSnakeToCamel( + value, + !preserveKeys && MAP_VALUED_CONFIG_KEYS.has(key), + ); } return result; } diff --git a/packages/kap-server/src/routes/modelCatalog.ts b/packages/kap-server/src/routes/modelCatalog.ts index 7388233141..1be704ed63 100644 --- a/packages/kap-server/src/routes/modelCatalog.ts +++ b/packages/kap-server/src/routes/modelCatalog.ts @@ -42,7 +42,11 @@ * transform's `setDefined` drops those). The kosong * persistence bridge then pushes the change into the registries, which is * also what invalidates the catalog cache. Multi-step sequences are - * serialized through `enqueueProviderWrite`. + * serialized through `enqueueProviderWrite`. Replace and delete additionally + * cascade into the `[secondary_model]` subagent pool (repointing renamed + * aliases, filtering entries whose model alias disappeared, clearing the + * section when its default dangles) so the engine's create/resume pool + * validation never meets a dangling pool. */ import { @@ -54,7 +58,6 @@ import { IModelsDevImportService, isError2, ModelsDevImportErrors, - SECONDARY_DERIVED_MODEL_ID, type ModelRecord, type ModelsSection, type ProviderConfig, @@ -69,6 +72,11 @@ import { MODELS_SECTION, PROVIDERS_SECTION, } from '@moonshot-ai/agent-core-v2/app/kosongConfig/configSection'; +import { + SECONDARY_MODEL_SECTION, + cascadeSubagentModelPool, + type SecondaryModelConfig, +} from '@moonshot-ai/agent-core-v2/session/subagent/configSection'; import { z } from 'zod'; import { errEnvelope, okEnvelope } from '../envelope'; @@ -230,16 +238,7 @@ export function registerModelCatalogRoutes(app: ModelCatalogRouteHost, core: Sco }, async (req, reply) => { const items = await (await loadCatalog(core)).listModels(); - // Presentation filter: the secondary-model derived entry is synthesized - // runtime state, not a configured alias — keep it out of pickers (the - // catalog still resolves it by id, and the overlay's strip keeps any - // default-model pointer to it out of config.toml). - reply.send( - okEnvelope( - { items: items.filter((item) => item.model !== SECONDARY_DERIVED_MODEL_ID) }, - req.id, - ), - ); + reply.send(okEnvelope({ items }, req.id)); }, ); app.get( @@ -566,6 +565,24 @@ export function registerModelCatalogRoutes(app: ModelCatalogRouteHost, core: Sco } } + const renamedAliases = new Map(); + if (newId !== provider_id) { + for (const oldAlias of previousAliasIds) { + const bare = models[oldAlias]?.model; + const renamed = bare === undefined ? undefined : `${newId}/${bare}`; + if (renamed !== undefined && nextModels[renamed] !== undefined) { + renamedAliases.set(oldAlias, renamed); + } + } + } + const secondaryModel = config.inspect( + SECONDARY_MODEL_SECTION, + ).userValue; + const cascadedPool = cascadeSubagentModelPool(secondaryModel, nextModels, renamedAliases); + if (cascadedPool !== undefined) { + await config.replace(SECONDARY_MODEL_SECTION, cascadedPool); + } + const saved = await core.accessor.get(IModelCatalog).getProvider(newId); reply.send(okEnvelope({ provider: saved }, req.id)); }); @@ -775,6 +792,13 @@ export function registerModelCatalogRoutes(app: ModelCatalogRouteHost, core: Sco if (Object.keys(restModels).length !== Object.keys(models).length) { await config.replace(MODELS_SECTION, restModels); } + const secondaryModel = config.inspect( + SECONDARY_MODEL_SECTION, + ).userValue; + const cascadedPool = cascadeSubagentModelPool(secondaryModel, restModels); + if (cascadedPool !== undefined) { + await config.replace(SECONDARY_MODEL_SECTION, cascadedPool); + } (reply as unknown as StatusReply).code(204).send(); }); }, diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index 5fc4235e2c..b059c281aa 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -40,10 +40,7 @@ * `GET /sessions/{id}/warnings` surfaces session-level notices in the v1 * `{ code, message, severity }` wire shape: the `agents-md-oversized` warning * (projected from the main agent's `IAgentProfileService.getAgentsMdWarning()` - * — computed and cached when the agent binds a profile) and the - * secondary-model early-validation warning (projected from the Session-scope - * `ISessionSecondaryModelWarningService` — computed and cached when the main - * agent is created). An unbound main agent or a valid/unset secondary model + * — computed and cached when the agent binds a profile). An unbound main agent * yields an empty list, matching v1's "no warning" case. * * **Wire fidelity**: mirrors v1's `toProtocolSession` @@ -89,7 +86,6 @@ import { ISessionIndex, ISessionMetadata, ISessionLegacyService, - ISessionSecondaryModelWarningService, IEventService, IWorkspaceAliases, ISessionLifecycleService, @@ -1051,18 +1047,12 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void try { // Surface v2 notices in the v1 wire shape. The agents-md warning is // computed (and cached) by `IAgentProfileService` when the main agent - // binds a profile; the secondary-model warning is computed (and - // cached) by `ISessionSecondaryModelWarningService` when the main - // agent is created. An unbound main agent / unset secondary model - // yields `undefined` → that entry drops out, matching v1's "no - // warning" case. + // binds a profile; an unbound main agent yields `undefined` → the + // entry drops out, matching v1's "no warning" case. const agent = await ensureMainAgent(session); const agentsMdWarning = agent.accessor.get(IAgentProfileService).getAgentsMdWarning(); - const secondaryModelWarning = session.accessor - .get(ISessionSecondaryModelWarningService) - .getSecondaryModelWarning(); - const warnings = [ - ...(agentsMdWarning === undefined + const warnings = + agentsMdWarning === undefined ? [] : [ { @@ -1070,17 +1060,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void message: agentsMdWarning, severity: 'warning' as const, }, - ]), - ...(secondaryModelWarning === undefined - ? [] - : [ - { - code: secondaryModelWarning.code, - message: secondaryModelWarning.message, - severity: 'warning' as const, - }, - ]), - ]; + ]; reply.send(okEnvelope({ warnings }, req.id)); } catch (error) { sendMappedError(reply, req, error); @@ -1322,6 +1302,7 @@ function sendMappedError( return; case 'request.invalid': case 'validation.failed': + case ErrorCodes.CONFIG_INVALID: reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, err.message, requestId, err.stack)); return; } diff --git a/packages/kap-server/src/services/legacyStatus/legacyStatus.ts b/packages/kap-server/src/services/legacyStatus/legacyStatus.ts index 13c8684d9e..e4e1184535 100644 --- a/packages/kap-server/src/services/legacyStatus/legacyStatus.ts +++ b/packages/kap-server/src/services/legacyStatus/legacyStatus.ts @@ -21,7 +21,6 @@ import { IAgentUsageService, IModelCatalog, IModelService, - SECONDARY_DERIVED_MODEL_ID, type IAgentScopeHandle, type UsageStatus, } from '@moonshot-ai/agent-core-v2'; @@ -132,7 +131,7 @@ export function readLegacyStatus(agent: IAgentScopeHandle): LegacyStatusSnapshot // (`ISessionLegacyService.status`), so the push and REST agree. maxContextTokens = defaultModelContextTokens(agent) ?? 0; } - const model = displayModelAlias(agent, profile.getModel()); + const model = profile.getModel(); return { usage, contextTokens, @@ -160,25 +159,6 @@ function defaultModelContextTokens(agent: IAgentScopeHandle): number | undefined } } -/** - * The wire `model` is normally the bound alias, which clients resolve against - * the model listing into a display name. The secondary-model derived entry is - * synthesized runtime state hidden from that listing, so resolve it here to - * the pointed entry's display string (the client's own - * `displayName ?? wireName` priority) instead of leaking the reserved id. - */ -function displayModelAlias(agent: IAgentScopeHandle, alias: string): string { - if (alias !== SECONDARY_DERIVED_MODEL_ID) return alias; - const catalog = agent.accessor.get(IModelCatalog) as IModelCatalog | undefined; - if (catalog === undefined) return alias; - try { - const model = catalog.get(alias); - return model.displayName ?? model.name; - } catch { - return alias; - } -} - /** * Map the native v2 `AgentActivityState` to the legacy v1 `AgentPhase` * (`agent.status.updated` payload). Pure function — kept at the kap-server diff --git a/packages/kap-server/src/transport/errors.ts b/packages/kap-server/src/transport/errors.ts index f20c63e97e..5994cee19e 100644 --- a/packages/kap-server/src/transport/errors.ts +++ b/packages/kap-server/src/transport/errors.ts @@ -35,6 +35,7 @@ const KIMI_TO_PROTOCOL: Record = { [ErrorCodes.AGENT_NOT_FOUND]: ErrorCode.SESSION_NOT_FOUND, [ErrorCodes.SESSION_UNDO_UNAVAILABLE]: ErrorCode.SESSION_UNDO_UNAVAILABLE, [ErrorCodes.REQUEST_INVALID]: ErrorCode.VALIDATION_FAILED, + [ErrorCodes.CONFIG_INVALID]: ErrorCode.VALIDATION_FAILED, [ErrorCodes.NOT_IMPLEMENTED]: ErrorCode.INTERNAL_ERROR, [ErrorCodes.PROMPT_NOT_FOUND]: ErrorCode.PROMPT_NOT_FOUND, [ErrorCodes.FS_PATH_NOT_FOUND]: ErrorCode.FS_PATH_NOT_FOUND, diff --git a/packages/kap-server/test/config.test.ts b/packages/kap-server/test/config.test.ts index 54b601ad53..bb9a07bc86 100644 --- a/packages/kap-server/test/config.test.ts +++ b/packages/kap-server/test/config.test.ts @@ -1,8 +1,9 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { configResponseSchema, type ConfigResponse } from '../src/protocol/rest-config'; +import { ErrorCode } from '../src/protocol/error-codes'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; @@ -99,36 +100,77 @@ describe('server-v2 /api/v1/config', () => { expect(after.yolo).toBe(false); }); - it('POST secondary_model persists [secondary_model] and echoes it on GET', async () => { + it('POST { secondary_model } persists the subagent model pool and GET echoes it', async () => { await boot(); const cfg = await patchConfig({ - secondary_model: { model: 'k2-test', default_effort: 'high' }, + secondary_model: { + default_model: 'provider/fast', + models: { 'provider/fast': 'fast and cheap' }, + }, }); - expect(cfg.secondary_model).toEqual({ model: 'k2-test', defaultEffort: 'high' }); + expect(cfg.secondary_model).toMatchObject({ defaultModel: 'provider/fast' }); const after = await getConfig(); - expect(after.secondary_model).toEqual({ model: 'k2-test', defaultEffort: 'high' }); + expect(after.secondary_model).toMatchObject({ + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast and cheap' }, + }); + }); + + it('POST { secondary_model } preserves pool alias keys containing underscores', async () => { + await boot(); + await patchConfig({ + secondary_model: { default_model: 'provider/fast_model', models: { 'provider/fast_model': '' } }, + }); - const toml = await readFile(join(home as string, 'config.toml'), 'utf-8'); - expect(toml).toContain('[secondary_model]'); - expect(toml).toContain('model = "k2-test"'); - expect(toml).toContain('default_effort = "high"'); + const after = await getConfig(); + expect(after.secondary_model).toMatchObject({ + defaultModel: 'provider/fast_model', + models: { 'provider/fast_model': '' }, + }); + expect( + Object.keys((after.secondary_model as { models: Record }).models), + ).not.toContain('provider/fastModel'); }); - it('GET hides the synthesized __secondary__ derived entry from models', async () => { - await boot('[models.k2-test]\nprovider = "example"\nmodel = "example-model"\n'); - // `default_effort` is a patch field, so the overlay synthesizes the - // `__secondary__` derived entry into the effective `models` view. - const cfg = await patchConfig({ - secondary_model: { model: 'k2-test', default_effort: 'high' }, + it('POST { providers } converts fields of a provider id colliding with a map-valued key', async () => { + await boot(); + await patchConfig({ + providers: { + models: { type: 'openai', base_url: 'https://example.test', api_key: 'sk-test' }, + }, }); - const models = cfg.models as Record; - expect(models['k2-test']).toBeDefined(); - expect(models['__secondary__']).toBeUndefined(); const after = await getConfig(); - const afterModels = after.models as Record; - expect(afterModels['k2-test']).toBeDefined(); - expect(afterModels['__secondary__']).toBeUndefined(); + expect(after.providers['models']).toMatchObject({ + type: 'openai', + base_url: 'https://example.test', + has_api_key: true, + }); + }); + + it('session create with a broken subagent model pool fails with VALIDATION_FAILED', async () => { + await boot( + '[experimental]\n"secondary-model" = true\n\n[secondary_model.models]\n"provider/fast" = "fast and cheap"\n', + ); + const res = await authedFetch(server as RunningServer, base, '/api/v1/sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ metadata: { cwd: home as string } }), + }); + const body = (await res.json()) as Envelope; + expect(body.code).toBe(ErrorCode.VALIDATION_FAILED); + expect(body.msg).toContain('[secondary_model].default_model is required'); + }); + + it('session create with a broken subagent model pool succeeds while the experiment is off', async () => { + await boot('[secondary_model.models]\n"provider/fast" = "fast and cheap"\n'); + const res = await authedFetch(server as RunningServer, base, '/api/v1/sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ metadata: { cwd: home as string } }), + }); + const body = (await res.json()) as Envelope<{ id: string }>; + expect(body.code).toBe(0); }); }); diff --git a/packages/kap-server/test/meta.test.ts b/packages/kap-server/test/meta.test.ts index 95e5e61934..948ee16ab3 100644 --- a/packages/kap-server/test/meta.test.ts +++ b/packages/kap-server/test/meta.test.ts @@ -31,7 +31,7 @@ describe('/api/v1/meta experimental_flags', () => { // only forces ON), but the per-flag env must be fully ABSENT — an // explicit '0' is an env override that outranks the config section. vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0'); - vi.stubEnv('KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', undefined); + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_TOOL_SELECT', undefined); }); afterEach(async () => { @@ -75,7 +75,7 @@ describe('/api/v1/meta experimental_flags', () => { it('reports registered flags as off by default', async () => { const base = await boot(); const flags = await getMetaFlags(base); - expect(flags['secondary-model']).toBe(false); + expect(flags['tool-select']).toBe(false); }); it('reports a config-enabled flag from the very first response', async () => { @@ -83,44 +83,44 @@ describe('/api/v1/meta experimental_flags', () => { // section from a config that loads asynchronously, so the handler awaits // IConfigService.ready before snapshotting — a persisted flag must be // visible even to the earliest request. - const base = await boot('[experimental]\nsecondary-model = true\n'); + const base = await boot('[experimental]\ntool-select = true\n'); const flags = await getMetaFlags(base); - expect(flags['secondary-model']).toBe(true); + expect(flags['tool-select']).toBe(true); }); it('reflects a flag enabled via its KIMI_CODE_EXPERIMENTAL_* env var', async () => { - vi.stubEnv('KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', '1'); + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_TOOL_SELECT', '1'); const base = await boot(); const flags = await getMetaFlags(base); - expect(flags['secondary-model']).toBe(true); + expect(flags['tool-select']).toBe(true); }); it('flips live when the [experimental] config section is written via POST /config', async () => { const base = await boot(); - expect((await getMetaFlags(base))['secondary-model']).toBe(false); + expect((await getMetaFlags(base))['tool-select']).toBe(false); const res = await authedFetch(server as RunningServer, base, '/api/v1/config', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ experimental: { 'secondary-model': true } }), + body: JSON.stringify({ experimental: { 'tool-select': true } }), }); expect(res.status).toBe(200); - expect((await getMetaFlags(base))['secondary-model']).toBe(true); + expect((await getMetaFlags(base))['tool-select']).toBe(true); }); it('keeps an env-forced flag on when the config section disables it', async () => { - vi.stubEnv('KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', '1'); + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_TOOL_SELECT', '1'); const base = await boot(); const res = await authedFetch(server as RunningServer, base, '/api/v1/config', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ experimental: { 'secondary-model': false } }), + body: JSON.stringify({ experimental: { 'tool-select': false } }), }); expect(res.status).toBe(200); // Env outranks the config section in FlagService resolution. - expect((await getMetaFlags(base))['secondary-model']).toBe(true); + expect((await getMetaFlags(base))['tool-select']).toBe(true); }); }); diff --git a/packages/kap-server/test/modelCatalog.test.ts b/packages/kap-server/test/modelCatalog.test.ts index e214f62339..837351a154 100644 --- a/packages/kap-server/test/modelCatalog.test.ts +++ b/packages/kap-server/test/modelCatalog.test.ts @@ -149,15 +149,6 @@ describe('server-v2 /api/v1 model/provider catalog', () => { ]); }); - it('hides the synthesized secondary-model derived entry from /models', async () => { - await boot( - `${CATALOG_TOML}\n[secondary_model]\nmodel = "turbo"\nmax_output_size = 8192\n`, - ); - const { status, body } = await getJson<{ items: { model: string }[] }>('/api/v1/models'); - expect(status).toBe(200); - expect(body.data.items.map((item) => item.model)).toEqual(['k2', 'turbo', 'gpt4o']); - }); - it('lists models without refreshing providers', async () => { const refreshProviderModels = vi.fn(async () => ({ changed: [], diff --git a/packages/kap-server/test/modelCatalogProviderWrite.test.ts b/packages/kap-server/test/modelCatalogProviderWrite.test.ts index 3ec70ae6eb..2da210d229 100644 --- a/packages/kap-server/test/modelCatalogProviderWrite.test.ts +++ b/packages/kap-server/test/modelCatalogProviderWrite.test.ts @@ -58,6 +58,21 @@ const DANGLING_DEFAULT_TOML = [ '', ].join('\n'); +/** Subagent pool whose default survives an openai deletion; one entry dangles. */ +const POOL_TOML = [ + DEFAULTED_TOML, + '[secondary_model]', + 'default_model = "k2"', + '', + '[secondary_model.models]', + 'k2 = "fast"', + 'gpt4o = "smart"', + '', +].join('\n'); + +/** Subagent pool whose effective default belongs to the deleted provider. */ +const POOL_DANGLING_DEFAULT_TOML = POOL_TOML.replace('default_model = "k2"', 'default_model = "gpt4o"'); + const MANAGED_TOML = [ '[providers."managed:kimi-code"]', 'type = "kimi"', @@ -453,6 +468,29 @@ describe('server-v2 /api/v1 provider write endpoints', () => { }); }); + it('filters secondary_model pool entries whose provider was deleted', async () => { + await boot(POOL_TOML); + const { status } = await deleteJson('/api/v1/providers/openai'); + expect(status).toBe(204); + + const onDisk = await readConfigToml(); + expect(onDisk['secondary_model']).toEqual({ + default_model: 'k2', + models: { k2: 'fast' }, + }); + }); + + it('drops the secondary_model section when its default dangles after deletion', async () => { + await boot(POOL_DANGLING_DEFAULT_TOML); + const { status } = await deleteJson('/api/v1/providers/openai'); + expect(status).toBe(204); + + // A leftover pool table without its default would fail the engine's pool + // validation on every session create — the whole section goes instead. + const onDisk = await readConfigToml(); + expect(onDisk['secondary_model']).toBeUndefined(); + }); + it('round-trips a created provider: delete removes every trace from config.toml', async () => { await boot(); const created = await postJson('/api/v1/providers', CREATE_BODY); @@ -715,6 +753,43 @@ describe('server-v2 /api/v1 provider write endpoints', () => { expect(onDisk['default_model']).toBe('gpt4o'); }); + it('repoints secondary_model pool entries on provider rename', async () => { + await boot(POOL_TOML); + const { status } = await putJson('/api/v1/providers/openai', { + type: 'openai', + new_id: 'my-openai', + models: [{ model: 'gpt-4o', max_context_size: 128000 }], + }); + expect(status).toBe(200); + + const onDisk = await readConfigToml(); + expect(onDisk['secondary_model']).toEqual({ + default_model: 'k2', + models: { k2: 'fast', 'my-openai/gpt-4o': 'smart' }, + }); + }); + + it('filters secondary_model pool entries dropped by a provider edit', async () => { + await boot(POOL_TOML); + const { status } = await putJson('/api/v1/providers/openai', REPLACE_BODY); + expect(status).toBe(200); + + const onDisk = await readConfigToml(); + expect(onDisk['secondary_model']).toEqual({ + default_model: 'k2', + models: { k2: 'fast' }, + }); + }); + + it('drops the secondary_model section when a provider edit orphans its default', async () => { + await boot(POOL_DANGLING_DEFAULT_TOML); + const { status } = await putJson('/api/v1/providers/openai', REPLACE_BODY); + expect(status).toBe(200); + + const onDisk = await readConfigToml(); + expect(onDisk['secondary_model']).toBeUndefined(); + }); + it('rejects a rename to an existing provider id with 40921', async () => { await boot(KEEP_DEFAULT_TOML); const { status, body } = await putJson('/api/v1/providers/openai', { diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 9d5fd631d3..88408732d4 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -32,7 +32,6 @@ import { ISessionLifecycleService, IWorkspaceLifecycleService, MAIN_AGENT_ID, - SECONDARY_DERIVED_MODEL_ID, SessionInteractionService, StateRegistry, } from '@moonshot-ai/agent-core-v2'; @@ -597,49 +596,6 @@ describe('SessionEventBroadcaster', () => { }); }); - it('resolves the secondary derived model id to a display string in status events', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - main.set(IAgentTokenCountingService, { statusSize: () => 10 }); - main.set(IAgentProfileService, { - getModel: () => SECONDARY_DERIVED_MODEL_ID, - getModelCapabilities: () => ({ max_context_tokens: 128_000 }), - }); - main.set(IAgentUsageService, { status: () => ({}) }); - main.set(IModelCatalog, { - get: (id: string) => { - expect(id).toBe(SECONDARY_DERIVED_MODEL_ID); - return { id, name: 'kimi-k2-wire', displayName: 'Kimi K2' }; - }, - }); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - main.bus.emit(agentEvent('agent.status.updated', {})); - // Without a displayName the pointed entry's wire name is shown. - main.set(IModelCatalog, { - get: (id: string) => ({ id, name: 'kimi-k2-wire' }), - }); - main.bus.emit(agentEvent('agent.status.updated', {})); - // A resolution failure falls back to the raw alias. - main.set(IModelCatalog, { - get: () => { - throw new Error('unknown model'); - }, - }); - main.bus.emit(agentEvent('agent.status.updated', {})); - await bc.getCursor('s1'); - - const statuses = envelopes.filter((envelope) => envelope.type === 'agent.status.updated'); - expect(statuses).toHaveLength(3); - expect(statuses.map((envelope) => envelope.payload)).toMatchObject([ - { model: 'Kimi K2' }, - { model: 'kimi-k2-wire' }, - { model: SECONDARY_DERIVED_MODEL_ID }, - ]); - }); - it('publishes the input cap as the status context limit when declared', async () => { const lc = new FakeLifecycle(); const main = lc.addAgent('main'); diff --git a/packages/kap-server/test/transport-errors.test.ts b/packages/kap-server/test/transport-errors.test.ts index 238a6963b0..9eb6747c20 100644 --- a/packages/kap-server/test/transport-errors.test.ts +++ b/packages/kap-server/test/transport-errors.test.ts @@ -9,6 +9,7 @@ import { ErrorCode } from '../src/protocol/error-codes'; import { describe, expect, it } from 'vitest'; import { mapError } from '../src/transport/errors'; +import { installErrorHandler } from '../src/error-handler'; describe('/api/v1/debug transport mapError', () => { it.each([ @@ -19,6 +20,7 @@ describe('/api/v1/debug transport mapError', () => { [ErrorCodes.OS_FS_PERMISSION_DENIED, ErrorCode.FS_PERMISSION_DENIED], [ErrorCodes.STORAGE_IO_FAILED, ErrorCode.PERSISTENCE_FAILURE], [ErrorCodes.STORAGE_LOCKED, ErrorCode.PERSISTENCE_FAILURE], + [ErrorCodes.CONFIG_INVALID, ErrorCode.VALIDATION_FAILED], [ErrorCodes.GOAL_UNSUPPORTED_AGENT, ErrorCode.GOAL_UNSUPPORTED_AGENT], ])('maps domain code %s to its wire equivalent', (code, wire) => { const env = mapError(new Error2(code, 'boom'), 'req-1'); @@ -30,3 +32,37 @@ describe('/api/v1/debug transport mapError', () => { expect(env.code).toBe(ErrorCode.INTERNAL_ERROR); }); }); + +describe('installErrorHandler (catch-all)', () => { + function run(err: unknown): { code: number; msg: string } { + let installed: unknown; + installErrorHandler({ + setErrorHandler: (h) => { + installed = h; + return undefined; + }, + }); + const handler = installed as ( + e: unknown, + req: { id: string; log: { error: () => void } }, + reply: { status: (code: number) => { send: (p: unknown) => void } }, + ) => void; + let payload: { code: number; msg: string } | undefined; + handler( + err, + { id: 'req-1', log: { error: () => {} } }, + { status: () => ({ send: (p: unknown) => void (payload = p as typeof payload) }) }, + ); + return payload!; + } + + it('maps an escaped config.invalid to VALIDATION_FAILED', () => { + const env = run(new Error2(ErrorCodes.CONFIG_INVALID, 'broken pool')); + expect(env.code).toBe(ErrorCode.VALIDATION_FAILED); + expect(env.msg).toContain('broken pool'); + }); + + it('keeps unknown exceptions at INTERNAL_ERROR', () => { + expect(run(new Error('boom')).code).toBe(ErrorCode.INTERNAL_ERROR); + }); +}); diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 19b0f08a57..a2e10b9417 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -78,6 +78,14 @@ 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'; +// Reserved key of the v2 engine's subagent model pool: it always binds the +// caller's own model, so hosts must not offer a user alias named `primary` +// as the subagent default model. +export { PRIMARY_SUBAGENT_MODEL_CHOICE } from '@moonshot-ai/agent-core-v2/session/subagent/configSection'; +// Pool cascade for writes that rebuild the `[models]` table: hosts staging a +// provider overwrite (remove-then-re-add) use it to restore the still-valid +// pool entries against the final alias set. +export { cascadeSubagentModelPool } from '@moonshot-ai/agent-core-v2/session/subagent/configSection'; // 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/rpc.ts b/packages/node-sdk/src/rpc.ts index b78f77c6b8..a73c5ca964 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -494,11 +494,6 @@ 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/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 24c78e1ca2..01a14b2519 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -118,13 +118,6 @@ * injection point — see the session-lifecycle section header), and * `toolCall` keeps the base class's "not supported" answer, which the * interaction bridge already relies on. - * - `applyPersistedSecondaryModel` → the reload + loud validations + warning - * refresh of v1's contract, rebuilt over the live `IConfigService` recipe - * and `ISessionSecondaryModelWarningService.recheckSecondaryModelWarning` - * (the v2 spawn binding resolves the secondary model at spawn time, so - * there is no session snapshot to push). `getSessionWarnings` also - * surfaces the v2 secondary-model warning next to the AGENTS.md one, - * matching v1's aggregate. */ import { randomUUID } from 'node:crypto'; import { readdir } from 'node:fs/promises'; @@ -151,9 +144,7 @@ import { type BeginAuthorizationResult, } from '@moonshot-ai/agent-core-v2/mcpCore/oauth/service'; import { createMcpOAuthStore } from '@moonshot-ai/agent-core-v2/app/mcpConfig/oauthStore'; -import { SECONDARY_MODEL_SECTION } from '@moonshot-ai/agent-core-v2/app/kosongConfig/configSection'; import { IAtomicDocumentStore } from '@moonshot-ai/agent-core-v2/persistence/interface/atomicDocumentStore'; -import { wrapSubagentModelError } from '@moonshot-ai/agent-core-v2/session/subagent/configSection'; import { loadMcpServers } from '@moonshot-ai/agent-core-v2/workspace/workspaceMcpConfig/internal/config-loader'; import type { McpServerConfig as WorkspaceMcpServerConfig } from '@moonshot-ai/agent-core-v2/mcpCore/config-schema'; import { @@ -185,7 +176,6 @@ import { IEventService, IHostEnvironment, IHostFileSystem, - IModelCatalog, IModelService, IProviderService, ISessionBtwService, @@ -197,7 +187,6 @@ import { ISessionInitService, ISessionMcpHandle, ISessionMetadata, - ISessionSecondaryModelWarningService, ISessionSkillCatalog, ISessionWorkspaceContext, ITelemetryService, @@ -234,7 +223,6 @@ import { type IDisposable, type ISessionScopeHandle, type Scope, - type SecondaryModelConfig, type ServicesAccessor, type SessionSummary as V2SessionSummary, } from '@moonshot-ai/agent-core-v2'; @@ -661,26 +649,30 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { /** * v1's removal cascades: the provider entry, every model pointing at it, - * and the default pointers when they dangle. The engine's own - * `kosong.removeProvider` only clears the default-provider pointer, so the - * full v1 cascade is computed from the user-layer values (see - * `planProviderRemoval`) and persisted as ONE atomic multi-section replace — - * the same single-write shape as v1's `removeKimiProvider`, so a process - * exit can never leave the file in a halfway-cascaded state. + * the default pointers when they dangle, and the `[secondary_model]` + * subagent pool entries (the section itself when its default dangles). + * The engine's own `kosong.removeProvider` only clears the + * default-provider pointer, so the full v1 cascade is computed from the + * user-layer values (see `planProviderRemoval`) and persisted as ONE + * atomic multi-section replace — the same single-write shape as v1's + * `removeKimiProvider`, so a process exit can never leave the file in a + * halfway-cascaded state. */ override async removeProvider(providerId: string): Promise { await this.configReady; - const [providers, models, defaultModel, defaultProvider] = await Promise.all([ + const [providers, models, defaultModel, defaultProvider, secondaryModel] = await Promise.all([ this.klient.global.config.inspect>('providers'), this.klient.global.config.inspect>>('models'), this.klient.global.config.inspect('defaultModel'), this.klient.global.config.inspect('defaultProvider'), + this.klient.global.config.inspect>('secondaryModel'), ]); const plan = planProviderRemoval({ providers: providers.userValue, models: models.userValue, defaultModel: defaultModel.userValue, defaultProvider: defaultProvider.userValue, + secondaryModel: secondaryModel.userValue, providerId, }); const sections: Record = { @@ -693,6 +685,11 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { if (plan.clearDefaultProvider) { sections['defaultProvider'] = undefined; } + if (plan.secondaryModel !== undefined) { + // `null` clears the whole section; a replacement object folds the + // filtered pool into the same atomic write. + sections['secondaryModel'] = plan.secondaryModel ?? undefined; + } await this.klient.global.config.replaceSections({ sections }); return this.getConfig(); } @@ -1466,43 +1463,6 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { agent.accessor.get(IAgentProfileService).setThinking(input.effort); } - /** - * v1 reloads the core config and pushes the resolved snapshot into the - * session: the spawn binding, the tool descriptions, and the cached - * startup warning all read that snapshot. The v2 engine resolves the - * secondary model live against `IConfigService` at spawn time - * (`resolveSubagentBinding`) and rebuilds the tool description on every - * read, so the preceding `setConfig` write already took effect - * session-wide — what remains of v1's contract is the reload (the recipe - * may have been persisted through another channel), the same loud - * validations, and the warning-cache refresh. The recipe read is NOT - * flag-gated, mirroring v1's `setSecondaryModelConfig` (the experiment - * gate lives at the spawn binding on both engines). - */ - override async applyPersistedSecondaryModel(input: SessionIdRpcInput): Promise { - const session = this.requireLiveSession(input.sessionId); - await this.klient.global.config.reload(); - await this.configReady; - await this.modelReady; - const secondary = this.engineAccessor - .get(IConfigService) - .get(SECONDARY_MODEL_SECTION); - 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.engineAccessor.get(IModelCatalog).get(secondary.model); - } catch (error) { - throw wrapSubagentModelError(error, secondary.model, undefined); - } - session.accessor - .get(ISessionSecondaryModelWarningService) - .recheckSecondaryModelWarning(); - } - override async setPermission(input: SetSessionPermissionRpcInput): Promise { const agent = await this.agentFacade(input.sessionId); return agent.setPermission(input.mode); @@ -1804,14 +1764,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { * cache is empty — v1 recomputes on demand whenever no warning is cached, * so an AGENTS.md that outgrows the budget mid-session surfaces on both * engines. The single warning shape (`agents-md-oversized`, severity - * `warning`) mirrors v1's assembly. The secondary-model half comes from the - * session scope's `ISessionSecondaryModelWarningService` (v1's - * `computeSecondaryModelWarnings`): v1 computes it from the session's - * config snapshot while v2 caches the live-config check at main-agent - * creation, so the two agree on recipes applied through - * `applyPersistedSecondaryModel` (which refreshes the v2 cache) and on - * recipes present at session creation; a recipe persisted but never - * applied surfaces only on v2 (live config vs v1's snapshot). + * `warning`) mirrors v1's assembly. */ override async getSessionWarnings(input: SessionIdRpcInput) { const agent = await this.agentScope(input.sessionId); @@ -1829,17 +1782,9 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { ); warning = prepared.agentsMdWarning; } - const warnings: { code: string; message: string; severity: 'warning' }[] = - warning === undefined - ? [] - : [{ code: 'agents-md-oversized', message: warning, severity: 'warning' as const }]; - const secondary = this.requireLiveSession(input.sessionId) - .accessor.get(ISessionSecondaryModelWarningService) - .getSecondaryModelWarning(); - if (secondary !== undefined) { - warnings.push({ code: secondary.code, message: secondary.message, severity: 'warning' }); - } - return warnings; + return warning === undefined + ? [] + : [{ code: 'agents-md-oversized', message: warning, severity: 'warning' as const }]; } /** diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 4a3325fdcc..ae3ab30ded 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -233,18 +233,6 @@ 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/v2/config-mapper.ts b/packages/node-sdk/src/v2/config-mapper.ts index ba47f90846..7d476a838f 100644 --- a/packages/node-sdk/src/v2/config-mapper.ts +++ b/packages/node-sdk/src/v2/config-mapper.ts @@ -38,6 +38,7 @@ const KIMI_CONFIG_DOMAINS = [ 'loopControl', 'background', 'subagent', + 'secondaryModel', 'mcp', 'image', 'modelCatalog', @@ -49,7 +50,7 @@ const KIMI_CONFIG_DOMAINS = [ * Pick the v1-shaped fields out of the v2 engine's resolved config * (`config.getAll()` — the effective view: file values plus env overlays * plus registered section defaults). Domains v2 knows but v1 does not - * (`cron`, `tools`, `secondaryModel`, `extraAgentDirs`, ...) are dropped, + * (`cron`, `tools`, `extraAgentDirs`, ...) are dropped, * mirroring how v1's schema strips unknown top-level keys. */ export function resolvedConfigToKimiConfig(resolved: Record): KimiConfig { @@ -88,6 +89,14 @@ export interface ProviderRemovalPlan { readonly models: Record; readonly clearDefaultModel: boolean; readonly clearDefaultProvider: boolean; + /** + * Cascade for the `[secondary_model]` subagent pool / legacy recipe: + * `undefined` = unchanged, `null` = drop the whole section (its effective + * default dangles, so the section can no longer validate), otherwise the + * replacement section with pool entries pointing at removed models + * filtered out. + */ + readonly secondaryModel: Record | null | undefined; } /** @@ -97,12 +106,19 @@ export interface ProviderRemovalPlan { * only clears the default-provider pointer, so the SDK replays the full v1 * cascade through the config facade. Inputs are the USER-layer values * (`inspect().userValue`), matching v1's disk-config write base. + * + * The `[secondary_model]` section cascades too: pool entries that name a + * removed model alias are filtered out, and when the effective default + * (`defaultModel`, or the legacy recipe's `model` fallback) dangles the + * whole section is dropped — a surviving `[secondary_model.models]` table + * without its default would fail pool validation on every session create. */ export function planProviderRemoval(input: { readonly providers: Record | undefined; readonly models: Record> | undefined; readonly defaultModel: string | undefined; readonly defaultProvider: string | undefined; + readonly secondaryModel?: Record; readonly providerId: string; }): ProviderRemovalPlan { const providers = { ...input.providers }; @@ -123,9 +139,36 @@ export function planProviderRemoval(input: { models, clearDefaultModel: removedDefault, clearDefaultProvider: input.defaultProvider === input.providerId, + secondaryModel: planSecondaryModelCascade(input.secondaryModel, models), }; } +/** + * Cascade the provider removal into the `[secondary_model]` section against + * the surviving model-alias table. See `ProviderRemovalPlan.secondaryModel` + * for the tri-state result. + */ +function planSecondaryModelCascade( + secondaryModel: Record | undefined, + survivingModels: Record, +): Record | null | undefined { + if (secondaryModel === undefined) return undefined; + + const defaultAlias = secondaryModel['defaultModel'] ?? secondaryModel['model']; + if (typeof defaultAlias === 'string' && !(defaultAlias in survivingModels)) { + return null; + } + + const pool = secondaryModel['models']; + if (pool === undefined || typeof pool !== 'object' || pool === null) { + return undefined; + } + const entries = Object.entries(pool as Record); + const surviving = entries.filter(([alias]) => alias in survivingModels); + if (surviving.length === entries.length) return undefined; + return { ...secondaryModel, models: Object.fromEntries(surviving) }; +} + /** * Apply the v1 remove-provider cascade to a whole `KimiConfig` in memory (no * persistence): drop the provider entry, every model pointing at it, and the @@ -140,6 +183,7 @@ export function removeProviderFromConfig(config: KimiConfig, providerId: string) models: config.models as Record> | undefined, defaultModel: config.defaultModel, defaultProvider: config.defaultProvider, + secondaryModel: config.secondaryModel as Record | undefined, providerId, }); return { @@ -148,5 +192,9 @@ export function removeProviderFromConfig(config: KimiConfig, providerId: string) models: plan.models as KimiConfig['models'], defaultModel: plan.clearDefaultModel ? undefined : config.defaultModel, defaultProvider: plan.clearDefaultProvider ? undefined : config.defaultProvider, + secondaryModel: + plan.secondaryModel === null + ? undefined + : ((plan.secondaryModel ?? config.secondaryModel) as KimiConfig['secondaryModel']), }; } diff --git a/packages/node-sdk/src/v2/session-wiring.ts b/packages/node-sdk/src/v2/session-wiring.ts index 2924eb8b34..693a614257 100644 --- a/packages/node-sdk/src/v2/session-wiring.ts +++ b/packages/node-sdk/src/v2/session-wiring.ts @@ -38,12 +38,10 @@ import { IAgentTokenCountingService, IAgentUsageService, IEventBus, - IModelCatalog, ISessionApprovalService, ISessionInteractionService, ISessionQuestionService, MAIN_AGENT_ID, - SECONDARY_DERIVED_MODEL_ID, type DomainEvent, type IAgentScopeHandle, type IDisposable, @@ -289,26 +287,6 @@ function withStatusSnapshot(agent: IAgentScopeHandle, event: DomainEvent): Domai usage: usageService.status(), contextTokens, maxContextTokens, - model: displayModelAlias(agent, profile.getModel()), + model: profile.getModel(), } as unknown as DomainEvent; } - -/** - * The wire `model` is normally the bound alias, which clients resolve against - * the model listing into a display name. The secondary-model derived entry is - * synthesized runtime state hidden from that listing, so resolve it here to - * the pointed entry's display string (the client's own - * `displayName ?? wireName` priority) instead of leaking the reserved id. - * Mirrors kap-server's `displayModelAlias`. - */ -function displayModelAlias(agent: IAgentScopeHandle, alias: string): string { - if (alias !== SECONDARY_DERIVED_MODEL_ID) return alias; - const catalog = agent.accessor.get(IModelCatalog) as IModelCatalog | undefined; - if (catalog === undefined) return alias; - try { - const model = catalog.get(alias); - return model.displayName ?? model.name; - } catch { - return alias; - } -} diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index 95e004e32d..f981cee4f8 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -7,7 +7,7 @@ * Wiring: real v2 engine bootstrapped on a temp KIMI_CODE_HOME; no provider calls. * Run: pnpm exec vitest run test/sdk-rpc-client-v2.test.ts */ -import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -343,6 +343,47 @@ describe('SDKRpcClientV2 (agent-core-v2 wiring MVP)', () => { } }); + it('cascades removeProvider into the secondary_model pool', async () => { + const { harness } = await makeHarness(); + try { + await harness.setConfig({ + providers: { + a: { type: 'openai', baseUrl: 'https://a.example.test/v1', apiKey: 'sk-a' }, + b: { type: 'openai', baseUrl: 'https://b.example.test/v1', apiKey: 'sk-b' }, + }, + models: { + 'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 }, + 'b/m1': { provider: 'b', model: 'm1', maxContextSize: 100 }, + }, + secondaryModel: { + defaultModel: 'a/m1', + models: { 'a/m1': 'fast', 'b/m1': 'smart' }, + }, + }); + + // Pool entries naming a removed model alias are filtered out; the + // surviving default keeps the section valid. + const filtered = await harness.removeProvider('b'); + expect(filtered.secondaryModel).toEqual({ + defaultModel: 'a/m1', + models: { 'a/m1': 'fast' }, + }); + + // When the pool's default dangles the whole section is dropped — a + // leftover models table without its default would fail pool validation + // on every session create. + await harness.setConfig({ + secondaryModel: { defaultModel: 'a/m1', models: { 'a/m1': 'fast' } }, + }); + const cleared = await harness.removeProvider('a'); + expect(cleared.secondaryModel).toBeUndefined(); + const reread = await harness.getConfig({ reload: true }); + expect(reread.secondaryModel).toBeUndefined(); + } finally { + await harness.close(); + } + }); + it('replaces config sections atomically and clears undefined sections', async () => { const { harness } = await makeHarness(); try { @@ -363,6 +404,32 @@ describe('SDKRpcClientV2 (agent-core-v2 wiring MVP)', () => { } }); + it('round-trips the secondaryModel pool field to the [secondary_model] config section', async () => { + const { harness, homeDir } = await makeHarness(); + try { + await harness.setConfig({ + secondaryModel: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast and cheap' }, + }, + }); + + const toml = await readFile(join(homeDir, 'config.toml'), 'utf-8'); + expect(toml).toContain('[secondary_model]'); + expect(toml).toContain('default_model'); + expect(toml).toContain('[secondary_model.models]'); + expect(toml).not.toContain('[subagent.models]'); + + const reread = await harness.getConfig({ reload: true }); + expect(reread.secondaryModel).toEqual({ + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast and cheap' }, + }); + } finally { + await harness.close(); + } + }); + it('fails loudly with not_implemented for methods not yet migrated', async () => { const { harness } = await makeHarness(); try { @@ -623,6 +690,66 @@ describe('removeProviderFromConfig', () => { expect(next.defaultModel).toBe('a/m1'); expect(next.defaultProvider).toBe('a'); }); + + it('filters secondary_model pool entries whose model alias was removed', () => { + const config = { + providers: { a: { type: 'openai' }, b: { type: 'openai' } }, + models: { + 'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 }, + 'b/m1': { provider: 'b', model: 'm1', maxContextSize: 100 }, + }, + secondaryModel: { + defaultModel: 'a/m1', + models: { 'a/m1': 'fast', 'b/m1': 'smart' }, + }, + } as unknown as KimiConfig; + + const next = removeProviderFromConfig(config, 'b'); + + expect(next.secondaryModel).toEqual({ + defaultModel: 'a/m1', + models: { 'a/m1': 'fast' }, + }); + }); + + it('drops the secondary_model section when its default model dangles', () => { + const config = { + providers: { a: { type: 'openai' }, b: { type: 'openai' } }, + models: { + 'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 }, + 'b/m1': { provider: 'b', model: 'm1', maxContextSize: 100 }, + }, + secondaryModel: { + defaultModel: 'b/m1', + models: { 'a/m1': 'fast', 'b/m1': 'smart' }, + }, + } as unknown as KimiConfig; + + expect(removeProviderFromConfig(config, 'b').secondaryModel).toBeUndefined(); + + // The legacy recipe's `model` key acts as the default fallback and + // cascades the same way. + const legacy = { + ...config, + secondaryModel: { model: 'b/m1', default_effort: 'low' }, + } as unknown as KimiConfig; + expect(removeProviderFromConfig(legacy, 'b').secondaryModel).toBeUndefined(); + }); + + it('leaves the secondary_model section untouched when nothing dangles', () => { + const config = { + providers: { a: { type: 'openai' }, b: { type: 'openai' } }, + models: { + 'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 }, + 'b/m1': { provider: 'b', model: 'm1', maxContextSize: 100 }, + }, + secondaryModel: { defaultModel: 'a/m1' }, + } as unknown as KimiConfig; + + const next = removeProviderFromConfig(config, 'b'); + + expect(next.secondaryModel).toEqual({ defaultModel: 'a/m1' }); + }); }); async function writeSkill(dir: string, name: string): Promise { await mkdir(dir, { recursive: true }); diff --git a/packages/node-sdk/test/session-event-wiring.test.ts b/packages/node-sdk/test/session-event-wiring.test.ts index cd4e3d9aee..6f0fe206ae 100644 --- a/packages/node-sdk/test/session-event-wiring.test.ts +++ b/packages/node-sdk/test/session-event-wiring.test.ts @@ -3,8 +3,7 @@ * bus. Covers the status-snapshot fold: v2 emits `agent.status.updated` in * slices and the model slice rides only the bind-time emission, so the * wiring merges a consistent usage + context + model snapshot into every - * status event (mirrors kap-server's broadcaster bridge), including the - * secondary-model derived id resolution. + * status event (mirrors kap-server's broadcaster bridge). * Run: pnpm exec vitest run test/session-event-wiring.test.ts */ import { describe, expect, it } from 'vitest'; @@ -16,9 +15,7 @@ import { IAgentTokenCountingService, IAgentUsageService, IEventBus, - IModelCatalog, ISessionInteractionService, - SECONDARY_DERIVED_MODEL_ID, type IAgentScopeHandle, type ISessionScopeHandle, } from '@moonshot-ai/agent-core-v2'; @@ -147,40 +144,6 @@ describe('SessionEventWiring status snapshot fold', () => { expect(events[1]).not.toHaveProperty('model'); }); - it('resolves the secondary derived model id to a display string', () => { - const sub = new FakeAgentHandle('agent-1'); - bindStatusServices(sub, SECONDARY_DERIVED_MODEL_ID); - const { sink, events } = collectingSink(); - const wiring = new SessionEventWiring(makeSession([sub]), sink); - try { - sub.set(IModelCatalog, { - get: (id: string) => { - expect(id).toBe(SECONDARY_DERIVED_MODEL_ID); - return { id, name: 'kimi-k2-wire', displayName: 'Kimi K2' }; - }, - }); - sub.bus.emit({ type: 'agent.status.updated', usage: USAGE }); - // Without a displayName the pointed entry's wire name is shown. - sub.set(IModelCatalog, { get: (id: string) => ({ id, name: 'kimi-k2-wire' }) }); - sub.bus.emit({ type: 'agent.status.updated', usage: USAGE }); - // A resolution failure falls back to the raw alias. - sub.set(IModelCatalog, { - get: () => { - throw new Error('unknown model'); - }, - }); - sub.bus.emit({ type: 'agent.status.updated', usage: USAGE }); - } finally { - wiring.dispose(); - } - - expect(events.map((event) => (event as { model?: string }).model)).toEqual([ - 'Kimi K2', - 'kimi-k2-wire', - SECONDARY_DERIVED_MODEL_ID, - ]); - }); - it('passes status events through unchanged when the agent services are incomplete', () => { const sub = new FakeAgentHandle('agent-1'); // No profile/usage/context/wire services bound — nothing to fold in. diff --git a/packages/node-sdk/test/v1-v2-parity.test.ts b/packages/node-sdk/test/v1-v2-parity.test.ts index ce5e333c0d..f862eed70f 100644 --- a/packages/node-sdk/test/v1-v2-parity.test.ts +++ b/packages/node-sdk/test/v1-v2-parity.test.ts @@ -515,7 +515,7 @@ async function closeAll(...harnesses: readonly KimiHarness[]): Promise { * and skew the comparison; the original values are restored on cleanup. */ const CONFIG_ENV_PATTERN = - /^(KIMI_MODEL_|KIMI_LOOP_|KIMI_MCP_|KIMI_WEB_|KIMI_SECONDARY_|KIMI_IMAGE_|KIMI_CODE_BACKGROUND_|KIMI_CODE_MODEL_CATALOG_)/; + /^(KIMI_MODEL_|KIMI_LOOP_|KIMI_MCP_|KIMI_WEB_|KIMI_IMAGE_|KIMI_CODE_BACKGROUND_|KIMI_CODE_MODEL_CATALOG_)/; function scrubConfigEnv(): () => void { const saved: Record = {}; @@ -633,35 +633,6 @@ api_key = "fixture-api-key" enabled = "not-a-boolean" `; -/** - * Secondary-model parity fixture: one resolvable model and the experiment - * enabled, no `[secondary_model]` recipe — the apply cases persist the recipe - * through `setConfig` mid-test. - */ -const SECONDARY_MODEL_CONFIG_TOML = ` -default_provider = "fixture-provider" -default_model = "fixture-model" - -[providers.fixture-provider] -type = "kimi" -api_key = "fixture-api-key" -base_url = "https://example.com/v1" - -[models.fixture-model] -provider = "fixture-provider" -model = "kimi-for-coding" -max_context_size = 262144 - -[experimental] -secondary-model = true -`; - -/** Same fixture with a dangling `[secondary_model]` pointer baked in. */ -const SECONDARY_MODEL_BROKEN_CONFIG_TOML = `${SECONDARY_MODEL_CONFIG_TOML} -[secondary_model] -model = "missing-model" -`; - function expectConfigParity(v1Config: KimiConfig, v2Config: KimiConfig): void { const project = KNOWN_DIFFS.getConfig; expect(normalize(project(v2Config), '')).toEqual(normalize(project(v1Config), '')); @@ -2710,96 +2681,6 @@ describe('v1↔v2 agent interaction parity', () => { restoreEnv(); } }); - - it('applyPersistedSecondaryModel validates, applies, and refreshes warnings identically', async () => { - const restoreEnv = scrubConfigEnv(); - const pair = await makeSessionParityPair(SECONDARY_MODEL_CONFIG_TOML); - try { - await createOnBoth(pair, { id: 'session_parity_secondary_apply' }); - const input = { sessionId: 'session_parity_secondary_apply' } as const; - const applyError = (client: SDKRpcClient | SDKRpcClientV2) => - client.applyPersistedSecondaryModel(input).then( - () => undefined, - (error: unknown) => error as Error, - ); - - // No recipe persisted yet: both reject with v1's persist-first error. - const [v1NoRecipe, v2NoRecipe] = await Promise.all([ - applyError(pair.v1), - applyError(pair.v2), - ]); - expect(v1NoRecipe).toMatchObject({ code: ErrorCodes.CONFIG_INVALID }); - expect(v2NoRecipe).toMatchObject({ code: ErrorCodes.CONFIG_INVALID }); - expect(v2NoRecipe?.message).toBe(v1NoRecipe?.message); - - // A dangling recipe: both reject, pointing at [secondary_model]. - await Promise.all([ - pair.v1.setConfig({ secondaryModel: { model: 'missing-model' } }), - pair.v2.setConfig({ secondaryModel: { model: 'missing-model' } }), - ]); - const [v1Broken, v2Broken] = await Promise.all([ - applyError(pair.v1), - applyError(pair.v2), - ]); - expect(v1Broken).toMatchObject({ code: ErrorCodes.CONFIG_INVALID }); - expect(v2Broken).toMatchObject({ code: ErrorCodes.CONFIG_INVALID }); - expect(v1Broken?.message).toContain('[secondary_model].model'); - expect(v2Broken?.message).toContain('[secondary_model].model'); - - // A valid recipe: both apply cleanly. The warnings pull converges on - // empty — v1's snapshot never held the broken recipe (its apply - // validates before mutating), v2's live-config warning cache is - // refreshed by the successful apply. - await Promise.all([ - pair.v1.setConfig({ secondaryModel: { model: 'fixture-model' } }), - pair.v2.setConfig({ secondaryModel: { model: 'fixture-model' } }), - ]); - await Promise.all([ - pair.v1.applyPersistedSecondaryModel(input), - pair.v2.applyPersistedSecondaryModel(input), - ]); - const [v1Warnings, v2Warnings] = await Promise.all([ - pair.v1.getSessionWarnings(input), - pair.v2.getSessionWarnings(input), - ]); - expect(v2Warnings).toEqual(v1Warnings); - expect(v1Warnings).toEqual([]); - - await expect( - pair.v1.applyPersistedSecondaryModel({ sessionId: 'session_missing' }), - ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); - await expect( - pair.v2.applyPersistedSecondaryModel({ sessionId: 'session_missing' }), - ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); - } finally { - await closeSessionPair(pair); - restoreEnv(); - } - }); - - it('getSessionWarnings flags a creation-time broken secondary recipe on both engines', async () => { - const restoreEnv = scrubConfigEnv(); - const pair = await makeSessionParityPair(SECONDARY_MODEL_BROKEN_CONFIG_TOML); - try { - await createOnBoth(pair, { id: 'session_parity_secondary_broken' }); - const input = { sessionId: 'session_parity_secondary_broken' } as const; - const [v1Warnings, v2Warnings] = await Promise.all([ - pair.v1.getSessionWarnings(input), - pair.v2.getSessionWarnings(input), - ]); - // The message wording is engine-specific; the code + severity are the - // shared contract. - const codes = (warnings: readonly { code: string; severity: string }[]) => - warnings.map(({ code, severity }) => ({ code, severity })); - expect(codes(v2Warnings)).toEqual(codes(v1Warnings)); - expect(codes(v1Warnings)).toEqual([ - { code: 'secondary-model-invalid', severity: 'warning' }, - ]); - } finally { - await closeSessionPair(pair); - restoreEnv(); - } - }); }); // ---------------------------------------------------------------------------