diff --git a/.changeset/thinking-effort-fallback-declared-efforts.md b/.changeset/thinking-effort-fallback-declared-efforts.md new file mode 100644 index 0000000000..7f1684aca3 --- /dev/null +++ b/.changeset/thinking-effort-fallback-declared-efforts.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Models with a declared support_efforts list now fall back to their default thinking effort when the configured effort is not in the list. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 18f7edb5d8..b59fc18bad 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -15,7 +15,12 @@ import { ExperimentsSelectorComponent, type ExperimentalFeatureDraftChange, } from '../components/dialogs/experiments-selector'; -import { modelDisplayName, segmentsFor } from '../components/dialogs/model-selector'; +import { + defaultThinkingEffortFor, + modelDisplayName, + segmentsFor, + effortsOf, +} from '../components/dialogs/model-selector'; import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector'; import { PermissionSelectorComponent } from '../components/dialogs/permission-selector'; import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector'; @@ -311,22 +316,35 @@ export async function handleEffortCommand(host: SlashCommandHost, args: string): showEffortPicker(host, effective, segments); return; } - if (!segments.includes(arg)) { + const canonical = segments.find((segment) => segment.toLowerCase() === arg); + if (canonical === undefined) { + const declared = effortsOf(effective); + // 'on' is the generic enable signal: with a declared effort list the + // engine maps it to the declared default effort, so it stays a valid + // command even though it never appears in the segment list. + if (arg === 'on' && declared.length > 0) { + await performModelSwitch(host, alias, arg, true); + return; + } const providerType = host.state.appState.availableProviders[effective.provider]?.type; const protocol = effective.protocol ?? providerType; - if (protocol !== 'anthropic') { + // With a declared effort list the engine falls back to the model default + // for every protocol, so an unlisted value is rejected like any invalid + // input. Only Anthropic-compatible models WITHOUT a declared list keep + // the warn-and-send escape hatch — there the engine passes the value + // through for the backend to judge. + if (protocol !== 'anthropic' || declared.length > 0) { host.showError( `Unsupported thinking effort "${arg}" for ${alias}. Available: ${segments.join(', ')}`, ); return; } - const knownEfforts = effective.supportEfforts?.join(', ') ?? 'none declared'; host.showStatus( - `Thinking effort "${arg}" is not listed for ${alias} (known: ${knownEfforts}). Sending "${arg}" unchanged; the configured provider will validate it.`, + `Thinking effort "${arg}" is not declared for ${alias}. Sending "${arg}" unchanged; the configured provider will validate it.`, 'warning', ); } - await performModelSwitch(host, alias, arg, true); + await performModelSwitch(host, alias, canonical ?? arg, true); } function showEffortPicker( @@ -512,7 +530,17 @@ async function performModelSwitch( try { if (session === undefined && runtimeChanged) { - await host.authFlow.activateModelAfterLogin(alias, effort); + // Session-less (lazy creation): no engine is around to map the generic + // 'on' onto the model's declared default yet, so resolve it here — the + // carried state and the status line should show the effort the first + // session will actually use. The engine re-resolves the concrete value + // at creation, which is in-list and therefore unchanged. + const selectedModel = host.state.appState.availableModels[alias]; + const sessionlessEffort = + effort === 'on' && selectedModel !== undefined + ? defaultThinkingEffortFor(effectiveModelForHost(host, selectedModel)) + : effort; + await host.authFlow.activateModelAfterLogin(alias, sessionlessEffort); } else if (session !== undefined) { if (alias !== prevModel) { await session.setModel(alias); 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 2532f14a2d..3204c08908 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -107,7 +107,12 @@ export function thinkingAvailability(model: ModelAlias): ThinkingAvailability { } export function effortsOf(model: ModelAlias): readonly string[] { - return model.supportEfforts ?? []; + // Blank entries are not real efforts: the engine resolvers discard them + // (effortsFor), so a list like [""] must not count as a declared list here. + // Entries are trimmed so padded declarations match normalized /effort input. + return (model.supportEfforts ?? []) + .map((effort) => effort.trim()) + .filter((effort) => effort.length > 0); } /** @@ -138,14 +143,50 @@ export function effortLabel(effort: string): string { * thinking is unsupported. */ export function defaultThinkingEffortFor(model: ModelAlias): ThinkingEffort { - if (thinkingAvailability(model) === 'unsupported') return 'off'; const efforts = effortsOf(model); if (efforts.length > 0) { - return model.defaultEffort ?? efforts[Math.floor(efforts.length / 2)]!; + const declared = model.defaultEffort?.trim(); + const matched = declared === undefined ? undefined : matchDeclaredEffort(efforts, declared); + return (matched ?? efforts[Math.floor(efforts.length / 2)]!) as ThinkingEffort; } + if (thinkingAvailability(model) === 'unsupported') return 'off'; return 'on'; } +/** + * Case-insensitive membership against the declared list, returning the + * declared (canonical) entry — the backend recognizes the declared casing. + */ +function matchDeclaredEffort( + efforts: readonly string[], + effort: string, +): string | undefined { + return efforts.find((candidate) => candidate.toLowerCase() === effort.toLowerCase()); +} + +/** + * Mirror of the engine's configured-effort resolution, for session-less + * display state: with a declared effort list, `'on'` or an unlisted value + * becomes the model's default effort; listed values (and anything, when no + * list is declared) pass through. `'off'` always stays `'off'`. + */ +export function resolveConfiguredEffortForModel( + effort: ThinkingEffort, + model: ModelAlias, +): ThinkingEffort { + // Normalize like the engine does before any comparison (trim + lowercase); + // a blank value reads as unconfigured and resolves to the model default. + const normalized = effort.trim().toLowerCase(); + if (normalized.length === 0) return defaultThinkingEffortFor(model); + const efforts = effortsOf(model); + if (efforts.length === 0 || normalized === 'off') return normalized; + if (normalized !== 'on') { + const matched = matchDeclaredEffort(efforts, normalized); + if (matched !== undefined) return matched as ThinkingEffort; + } + return defaultThinkingEffortFor(model); +} + /** * Normalize a draft effort before committing a selection. A boolean `'on'` * never leaks past the UI boundary — it becomes the model's default effort @@ -196,15 +237,7 @@ export class ModelSelectorComponent extends Container implements Focusable { const override = this.thinkingOverrides.get(choice.alias); if (override !== undefined) return override; if (choice.alias === this.opts.currentValue) return this.opts.currentThinkingEffort; - const efforts = effortsOf(choice.model); - if (efforts.length > 0) { - // A model with support_efforts but no default_effort defaults to the - // middle entry of its supported efforts. - const def = choice.model.defaultEffort ?? efforts[Math.floor(efforts.length / 2)]; - if (def !== undefined && efforts.includes(def)) return def; - return efforts[0]!; - } - return thinkingAvailability(choice.model) !== 'unsupported' ? 'on' : 'off'; + return defaultThinkingEffortFor(choice.model); } /** Draft coerced onto the model's segment list so rendering/selection never diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index cb0231d4c0..e430912ffe 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -74,7 +74,10 @@ import { } from './components/dialogs/approval-preview'; import { CompactionComponent } from './components/dialogs/compaction'; import { HelpPanelComponent } from './components/dialogs/help-panel'; -import { defaultThinkingEffortFor } from './components/dialogs/model-selector'; +import { + defaultThinkingEffortFor, + resolveConfiguredEffortForModel, +} from './components/dialogs/model-selector'; import { QuestionDialogComponent } from './components/dialogs/question-dialog'; import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker'; import { TrustPromptComponent, type TrustPromptChoice } from './components/dialogs/trust-prompt'; @@ -2154,16 +2157,29 @@ export class KimiTUI { patch.planMode = config.defaultPlanMode === true; } const effort = thinkingEffortFromConfig(config.thinking); + const startupModelConfig = + startupModel === undefined ? undefined : config.models?.[startupModel]; + const startupProviderType = + startupModelConfig === undefined + ? undefined + : (config.providers?.[startupModelConfig.provider]?.type ?? startupModelConfig.protocol); if (effort !== undefined) { - patch.thinkingEffort = effort; + // A configured effort outside the model's declared list falls back to + // the declared default at session creation — hydrate the same way so + // the footer and the picker never coerce it to 'off'. + patch.thinkingEffort = + startupModelConfig === undefined + ? effort + : resolveConfiguredEffortForModel( + effort, + effectiveModelAlias(startupModelConfig, startupProviderType), + ); } else if (startupModel !== undefined) { // No concrete effort configured: mirror the engine, which resolves the // model's default effort at createSession time. - const raw = config.models?.[startupModel]; - if (raw !== undefined) { - const providerType = config.providers?.[raw.provider]?.type; + if (startupModelConfig !== undefined) { patch.thinkingEffort = defaultThinkingEffortFor( - effectiveModelAlias(raw, providerType ?? raw.protocol), + effectiveModelAlias(startupModelConfig, startupProviderType), ); } } diff --git a/apps/kimi-code/src/tui/utils/thinking-config.ts b/apps/kimi-code/src/tui/utils/thinking-config.ts index da3ea13604..d06edc6764 100644 --- a/apps/kimi-code/src/tui/utils/thinking-config.ts +++ b/apps/kimi-code/src/tui/utils/thinking-config.ts @@ -27,7 +27,12 @@ export function thinkingEffortToConfig( } { if (effort === 'off') return { enabled: false }; if (effort === 'on') return { enabled: true }; - const top = supportEfforts?.at(-1); + // Declared entries are padded-tolerant, matching the engine resolvers: + // trim and drop blanks before identifying the top tier. + const declared = (supportEfforts ?? []) + .map((value) => value.trim()) + .filter((value) => value.length > 0); + const top = declared.at(-1); if (top !== undefined && effort === top) return { enabled: true }; return { enabled: true, effort }; } 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 e5159ec0d9..5a0a80181d 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 @@ -2,7 +2,11 @@ import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it, vi } from 'vitest'; -import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; +import { + ModelSelectorComponent, + defaultThinkingEffortFor, + resolveConfiguredEffortForModel, +} from '#/tui/components/dialogs/model-selector'; import { currentTheme } from '#/tui/theme'; import { darkColors } from '#/tui/theme/colors'; @@ -504,6 +508,72 @@ describe('ModelSelectorComponent', () => { expect(text(picker)).toContain('[ Medium ]'); }); + it('falls back to the middle effort when the declared defaultEffort is unlisted', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + other: effortModel('Kimi Other', ['low', 'high'], 'max'), + }, + currentValue: 'current', + currentThinkingEffort: 'off', + onSelect, + onCancel: vi.fn(), + }); + + // An unlisted default_effort is not selectable: the middle entry wins, + // matching the engine resolvers. + expect(text(picker)).toContain('[ High ]'); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ alias: 'other', thinking: 'high' }); + }); + + it('trims a padded defaultEffort before matching the declared efforts', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + other: effortModel('Kimi Other', [' low ', ' medium ', ' xhigh '], ' xhigh '), + }, + currentValue: 'current', + currentThinkingEffort: 'off', + onSelect, + onCancel: vi.fn(), + }); + + // The padded declared default wins over the first/middle entry. + expect(text(picker)).toContain('[ Xhigh ]'); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ alias: 'other', thinking: 'xhigh' }); + }); + + it('prefers the declared default effort when the model omits the thinking capability', () => { + expect(defaultThinkingEffortFor(effortModel('Kimi Other', ['low', 'high', 'max'], 'max', []))).toBe( + 'max', + ); + expect(defaultThinkingEffortFor(effortModel('Kimi Other', ['low', 'high', 'max'], undefined, []))).toBe( + 'high', + ); + }); + + it('normalizes a padded configured effort before matching the declared list', () => { + const declared = effortModel('Kimi Other', ['low', 'high', 'max'], 'high'); + expect(resolveConfiguredEffortForModel(' LOW ', declared)).toBe('low'); + expect(resolveConfiguredEffortForModel(' ', declared)).toBe('high'); + expect(resolveConfiguredEffortForModel(' ULTRA ', declared)).toBe('high'); + }); + + it('matches declared efforts case-insensitively and resolves the declared casing', () => { + const declared = effortModel('Kimi Other', ['Low', 'High', 'Max'], 'max'); + expect(resolveConfiguredEffortForModel('low', declared)).toBe('Low'); + expect(defaultThinkingEffortFor(declared)).toBe('Max'); + }); + + it('falls back to the middle entry when the declared default is not listed', () => { + // Matches the engine resolvers: an unlisted default_effort is rejected. + expect(defaultThinkingEffortFor(effortModel('Kimi Other', ['low', 'high'], 'max', []))).toBe( + 'high', + ); + }); + it('renders the warning line directly below the key-hint line when provided', () => { const picker = new ModelSelectorComponent({ models: { kimi: model('Kimi K2') }, diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index fdf369ddd0..9e3386a9cb 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -8129,7 +8129,9 @@ describe('/model status displayName override', () => { }); describe('/effort support_efforts override', () => { - it('warns and applies efforts hidden by an Anthropic support_efforts override', async () => { + it('rejects efforts hidden by an Anthropic support_efforts override', async () => { + // The engine falls back to the declared default for unlisted efforts on + // every protocol, so the TUI rejects them like any other invalid value. const session = makeSession(); const { driver } = await makeDriver(session, { getConfig: vi.fn(async () => ({ @@ -8155,6 +8157,265 @@ describe('/effort support_efforts override', () => { driver.handleUserInput('/effort max'); + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain( + 'Unsupported thinking effort "max" for k2. Available: off, low, high', + ); + }); + expect(session.setThinking).not.toHaveBeenCalled(); + }); + + it('accepts /effort on for a declared-list model and shows the mapped default', async () => { + // 'on' never appears in the segment list of an effort-declaring model, + // but the engine maps it to the declared default — keep it a valid + // command and display the resolved effort the engine reports. + const session = makeSession(); + let effort = 'low'; + Object.assign(session, { + setThinking: vi.fn(async (value: string) => { + effort = value === 'on' ? 'high' : value; + }), + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: effort, + permission: 'manual', + planMode: false, + contextTokens: 0, + maxContextTokens: 100, + contextUsage: 0, + })), + }); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'kimi', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 100, + displayName: 'Compatible Model', + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + defaultEffort: 'high', + }, + }, + defaultModel: 'k2', + thinking: { enabled: true, effort: 'low' }, + })), + }); + + driver.handleUserInput('/effort on'); + + await vi.waitFor(() => { + expect(session.setThinking).toHaveBeenCalledWith('on'); + }); + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain('Thinking set to high.'); + }); + expect(renderTranscript(driver)).not.toContain('Unsupported thinking effort'); + }); + + it('resolves /effort on to the declared default before a session exists (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver( + session, + { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'kimi', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 100, + displayName: 'Compatible Model', + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + defaultEffort: 'high', + }, + }, + defaultModel: 'k2', + thinking: { enabled: true, effort: 'low' }, + })), + }, + startupInput, + ); + expect(driver.state.appState.sessionId).toBe(''); + + driver.handleUserInput('/effort on'); + + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain('Thinking set to high.'); + }); + expect(driver.state.appState.thinkingEffort).toBe('high'); + expect(driver.state.appState.lazySessionThinking).toBe('high'); + + driver.handleUserInput('hello'); + + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalled(); + }); + expect(harness.createSession).toHaveBeenCalledWith( + expect.objectContaining({ model: 'k2', thinking: 'high' }), + ); + }); + + it('hydrates an unlisted configured effort to the declared default (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const setConfig = vi.fn(async () => ({})); + const { driver } = await makeDriver( + session, + { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'kimi', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 100, + displayName: 'Compatible Model', + capabilities: ['thinking'], + supportEfforts: ['low', 'xhigh'], + defaultEffort: 'xhigh', + }, + }, + defaultModel: 'k2', + thinking: { enabled: true, effort: 'high' }, + })), + setConfig, + }, + startupInput, + ); + + // "high" is outside the declared list: hydration mirrors the engine + // fallback instead of copying the configured value verbatim. + expect(driver.state.appState.thinkingEffort).toBe('xhigh'); + + // Confirming the current model in the picker must not turn thinking off. + driver.handleUserInput('/model'); + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); + }); + (driver.state.editorContainer.children[0] as TabbedModelSelectorComponent).handleInput('\r'); + + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain( + 'Already using Compatible Model with thinking xhigh.', + ); + }); + expect(setConfig).not.toHaveBeenCalled(); + }); + + it('accepts a case-insensitive /effort match and applies the declared casing', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'kimi', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 100, + displayName: 'Compatible Model', + capabilities: ['thinking'], + supportEfforts: ['Low', 'High'], + defaultEffort: 'High', + }, + }, + defaultModel: 'k2', + thinking: { enabled: true, effort: 'low' }, + })), + }); + + driver.handleUserInput('/effort low'); + + await vi.waitFor(() => { + expect(session.setThinking).toHaveBeenCalledWith('Low'); + }); + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain('Thinking set to Low.'); + }); + }); + + it('persists only the enabled flag when a padded list top tier is selected', async () => { + const session = makeSession(); + const setConfig = vi.fn(async () => ({})); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'kimi', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 100, + displayName: 'Compatible Model', + capabilities: ['thinking'], + supportEfforts: [' low ', ' max '], + }, + }, + defaultModel: 'k2', + thinking: { enabled: true, effort: 'low' }, + })), + setConfig, + }); + + driver.handleUserInput('/effort max'); + + await vi.waitFor(() => { + expect(setConfig).toHaveBeenCalledWith({ + defaultModel: 'k2', + thinking: { enabled: true }, + }); + }); + }); + + it('still sends unlisted efforts unchanged for Anthropic models without a declared list', async () => { const session = makeSession(); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'kimi', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 100, + displayName: 'Compatible Model', + capabilities: ['thinking'], + }, + }, + defaultModel: 'k2', + thinking: { enabled: true }, + })), + }); + + driver.handleUserInput('/effort max'); + await vi.waitFor(() => { expect(session.setThinking).toHaveBeenCalledWith('max'); }); @@ -8163,11 +8424,80 @@ describe('/effort support_efforts override', () => { }); const transcript = renderTranscript(driver).replaceAll(/\s+/g, ' '); expect(transcript).toContain( - 'Thinking effort "max" is not listed for k2 (known: low, high). Sending "max" unchanged; the configured provider will validate it.', + 'Thinking effort "max" is not declared for k2. Sending "max" unchanged; the configured provider will validate it.', ); expect(transcript).toContain('Thinking set to max.'); }); + it('treats a blank-only support_efforts list as no declared list', async () => { + // The engine resolvers discard empty entries, so support_efforts: [""] + // means "no declared list" and the Anthropic escape hatch still applies. + const session = makeSession(); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'kimi', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 100, + displayName: 'Compatible Model', + capabilities: ['thinking'], + supportEfforts: [''], + }, + }, + defaultModel: 'k2', + thinking: { enabled: true }, + })), + }); + + driver.handleUserInput('/effort max'); + + await vi.waitFor(() => { + expect(session.setThinking).toHaveBeenCalledWith('max'); + }); + const transcript = renderTranscript(driver).replaceAll(/\s+/g, ' '); + expect(transcript).toContain('Sending "max" unchanged'); + }); + + it('matches /effort against trimmed padded support_efforts entries', async () => { + // Padded declarations like [" low ", " high "] are normalized by the + // engine; the TUI must match /effort input against the trimmed values. + const session = makeSession(); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'kimi', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 100, + displayName: 'Compatible Model', + capabilities: ['thinking'], + supportEfforts: [' low ', ' high '], + }, + }, + defaultModel: 'k2', + thinking: { enabled: true }, + })), + }); + + driver.handleUserInput('/effort high'); + + await vi.waitFor(() => { + expect(session.setThinking).toHaveBeenCalledWith('high'); + }); + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain('Thinking set to high.'); + }); + }); + it('offers the latest Opus efforts for an unknown Claude-marked Anthropic-compatible model', async () => { const { driver } = await makeDriver(makeSession(), { getConfig: vi.fn(async () => ({ diff --git a/apps/kimi-code/test/tui/utils/thinking-config.test.ts b/apps/kimi-code/test/tui/utils/thinking-config.test.ts index e0a953595a..6bb00ceef4 100644 --- a/apps/kimi-code/test/tui/utils/thinking-config.test.ts +++ b/apps/kimi-code/test/tui/utils/thinking-config.test.ts @@ -35,6 +35,20 @@ describe('thinkingEffortToConfig', () => { it('treats a single declared level as the top tier', () => { expect(thinkingEffortToConfig('max', ['max'])).toEqual({ enabled: true }); }); + + it('matches the top tier against a padded declared list', () => { + expect(thinkingEffortToConfig('max', [' low ', ' high ', ' max '])).toEqual({ + enabled: true, + }); + expect(thinkingEffortToConfig('high', [' low ', ' high ', ' max '])).toEqual({ + enabled: true, + effort: 'high', + }); + expect(thinkingEffortToConfig('max', ['', ' '])).toEqual({ + enabled: true, + effort: 'max', + }); + }); }); describe('isThinkingOn', () => { diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 45dc08dbe6..2a87136ad1 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -40,7 +40,12 @@ import { import type { ModelOverrides } from '#/kosong/model/model.types'; import { IModelService } from '#/kosong/model/model'; import { completionBudgetParams, resolveCompletionBudget } from '#/kosong/model/completionBudget'; -import { resolveThinkingKeep, type ThinkingConfig } from '#/kosong/model/thinking'; +import { + declaredThinkingEfforts, + isDeclaredThinkingEffort, + resolveThinkingKeep, + type ThinkingConfig, +} from '#/kosong/model/thinking'; import { THINKING_SECTION } from '#/app/kosongConfig/configSection'; import type { Protocol } from '#/kosong/protocol/protocol'; import type { ApiErrorEvent } from '#/app/telemetry/events'; @@ -336,7 +341,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { signal, ), }; - this.warnAboutAnthropicThinkingEffort(request); + this.warnAboutThinkingEffortNotListed(request); const logInput: LLMRequestLogInput = { protocol: request.model.protocol, providerType: request.model.providerType, @@ -514,21 +519,15 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { return assigned === part.id ? part : { ...part, id: assigned }; } - private warnAboutAnthropicThinkingEffort(request: ResolvedLLMRequest): void { - if (request.model.protocol !== 'anthropic') return; + private warnAboutThinkingEffortNotListed(request: ResolvedLLMRequest): void { const effort = request.thinkingEffort; if (effort === 'on' || effort === 'off') return; - - let code: string; - let message: string; - let knownEfforts: string | undefined; - const supportEfforts = request.model.supportEfforts?.filter((value) => value.length > 0); - if (supportEfforts === undefined || supportEfforts.length === 0) return; - if (supportEfforts.includes(effort)) return; - code = 'anthropic-thinking-effort-not-listed'; - knownEfforts = supportEfforts.join(','); - message = `Thinking effort "${effort}" is not listed for model "${request.model.name}" (known: ${supportEfforts.join(', ')}). The configured value will be sent unchanged to the Anthropic-compatible backend.`; - + const supportEfforts = declaredThinkingEfforts(request.model); + if (supportEfforts.length === 0) return; + if (isDeclaredThinkingEffort(request.model.supportEfforts, effort)) return; + const code = 'thinking-effort-not-listed'; + const knownEfforts = supportEfforts.join(','); + const message = `Thinking effort "${effort}" is not listed for model "${request.model.name}" (known: ${supportEfforts.join(', ')}). The value will be sent unchanged to the backend.`; const key = [code, request.modelAlias, request.model.name, effort, knownEfforts].join('\u0000'); if (this.emittedThinkingEffortWarnings.has(key)) return; this.emittedThinkingEffortWarnings.add(key); diff --git a/packages/agent-core-v2/src/agent/profile/profile.ts b/packages/agent-core-v2/src/agent/profile/profile.ts index 529c23e428..733573620a 100644 --- a/packages/agent-core-v2/src/agent/profile/profile.ts +++ b/packages/agent-core-v2/src/agent/profile/profile.ts @@ -81,6 +81,7 @@ export interface ProfileBindingSnapshot { export interface ProfileServiceOptions { readonly emitStatusUpdated?: () => void; + readonly scheduleThinkingEffortRevalidation?: (run: () => void) => void; } export interface ApplyProfileOptions { diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 2b504afa03..eb7624fb7d 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -1,19 +1,23 @@ -import { Disposable } from '#/_base/di/lifecycle'; +import { Disposable, toDisposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/state/state'; import { UNKNOWN_CAPABILITY, type ModelCapability } from '#/kosong/contract/capability'; import { type SamplingOptions, type ThinkingEffort } from '#/kosong/contract/provider'; import { IModelCatalog, type Model } from '#/kosong/model/catalog'; +import { IModelService } from '#/kosong/model/model'; +import { IProviderService } from '#/kosong/provider/provider'; import { type ModelOverrides } from '#/kosong/model/model.types'; import { type ModelRequestParams } from '#/kosong/model/modelRequester'; import { IProtocolAdapterRegistry } from '#/kosong/protocol/protocol'; import { + declaredThinkingEfforts, drivesThinkingThroughTraits, modelSupportsThinkingEffort, normalizeRequestedThinkingEffort, resolveForcedThinkingEffort, resolveThinkingEffortForModel, + resolveThinkingEffortForModelWithFallback, resolveThinkingKeep, requiresStrictThinkingValidation, type ThinkingConfig, @@ -149,6 +153,8 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ @IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService, @IConfigService private readonly config: IConfigService, @IModelCatalog private readonly modelCatalog: IModelCatalog, + @IModelService private readonly models: IModelService, + @IProviderService private readonly providers: IProviderService, @IProtocolAdapterRegistry private readonly protocolAdapters: IProtocolAdapterRegistry, @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, @IHostClock private readonly clock: IHostClock, @@ -195,6 +201,23 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ } }), ); + this._register( + this.models.onDidChangeModels(() => { + this.scheduleThinkingEffortRevalidation(); + }), + ); + this._register( + this.providers.onDidChangeProviders(() => { + this.scheduleThinkingEffortRevalidation(); + }), + ); + this._register( + toDisposable(() => { + if (this.thinkingEffortRevalidationTimer !== undefined) { + clearTimeout(this.thinkingEffortRevalidationTimer); + } + }), + ); this._register( this.skillCatalog.onDidChange((sourceId) => { if (sourceId === BUILTIN_SKILL_SOURCE_ID) { @@ -202,6 +225,12 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ } }), ); + this._register( + this.dispatcher.hooks.onDidRestore.register('profile', async (_ctx, next) => { + this.warnAboutThinkingEffortFallback(this.profileState.thinkingLevel); + await next(); + }), + ); } private get activeToolNamesOverlay(): readonly string[] | undefined { @@ -235,6 +264,9 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ configure(options: ProfileServiceOptions): void { this.optionsValue = { emitStatusUpdated: options.emitStatusUpdated ?? this.optionsValue.emitStatusUpdated, + scheduleThinkingEffortRevalidation: + options.scheduleThinkingEffortRevalidation ?? + this.optionsValue.scheduleThinkingEffortRevalidation, }; } @@ -247,8 +279,13 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ this.activeProfile = undefined; } if (Object.keys(configChanged).length > 0) { - void this.dispatcher.dispatch(new ConfigUpdate(this.resolveConfigPayload(configChanged))); - this.afterConfigDispatch(configChanged); + const thinkingRequested = + configChanged.thinkingLevel ?? + (this.modelAlias === undefined ? undefined : this.thinkingLevel); + void this.dispatcher.dispatch( + new ConfigUpdate(this.resolveConfigPayload(configChanged, thinkingRequested)), + ); + this.afterConfigDispatch(configChanged, thinkingRequested); } if (activeToolNames !== undefined) { this.setActiveTools(activeToolNames); @@ -283,7 +320,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ environmentDisclosure: snapshot.environmentDisclosure, agentsMdPaths, disallowedTools: snapshot.disallowedTools ?? [], - }); + }, snapshot.thinkingLevel); this.agentsMdReminder.seedInjected(agentsMdPaths, this.sessionContext.cwd); } @@ -324,10 +361,9 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ this.activeProfile = profile; this.cacheAgentsMdWarning(context); - const thinkingLevel = this.resolveThinkingEffort( - input.thinking ?? (currentProfileName !== undefined ? this.thinkingLevel : undefined), - model, - ); + const thinkingRequested = + input.thinking ?? (currentProfileName !== undefined ? this.thinkingLevel : undefined); + const thinkingLevel = this.resolveThinkingEffort(thinkingRequested, model); this.activeToolNamesOverlay = undefined; await this.dispatcher.dispatch(new ProfileBind({ @@ -348,7 +384,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ thinkingLevel, systemPrompt: rendered.text, disallowedTools: profile.disallowedTools ?? [], - }); + }, thinkingRequested); this.seedAgentsMdReminder(context); this.publishAgentsMdWarning(); @@ -568,15 +604,14 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ private resolveConfigPayload( changed: Omit, + thinkingRequested: string | undefined, ): ConfigUpdatePayload { const payload: ConfigUpdatePayload = { agentId: this.scopeContext.agentId }; if (changed.modelAlias !== undefined) payload.modelAlias = changed.modelAlias; if (changed.profileName !== undefined) payload.profileName = changed.profileName; if (changed.thinkingLevel !== undefined || changed.modelAlias !== undefined) { const model = this.resolveModelForThinking(changed.modelAlias ?? this.modelAlias); - const requested = - changed.thinkingLevel ?? (this.modelAlias === undefined ? undefined : this.thinkingLevel); - payload.thinkingEffort = this.resolveThinkingEffort(requested, model); + payload.thinkingEffort = this.resolveThinkingEffort(thinkingRequested, model); } if (changed.systemPrompt !== undefined) { payload.systemPrompt = changed.systemPrompt; @@ -593,7 +628,10 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ return payload; } - private afterConfigDispatch(changed: Omit): void { + private afterConfigDispatch( + changed: Omit, + thinkingRequested: string | undefined, + ): void { if (changed.modelAlias !== undefined) { const model = this.tryResolveRawModel(); this.telemetryContext.set({ @@ -602,30 +640,64 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ }); } if (changed.modelAlias !== undefined || changed.thinkingLevel !== undefined) { - this.warnAboutAnthropicThinkingEffort(); + this.warnAboutThinkingEffortFallback(thinkingRequested); } this.emitStatusUpdated( changed.modelAlias !== undefined || changed.thinkingLevel !== undefined, ); } - private warnAboutAnthropicThinkingEffort(): void { + private thinkingEffortRevalidationScheduled = false; + private thinkingEffortRevalidationTimer: ReturnType | undefined; + + private scheduleThinkingEffortRevalidation(): void { + if (this.thinkingEffortRevalidationScheduled) return; + this.thinkingEffortRevalidationScheduled = true; + const run = () => { + this.thinkingEffortRevalidationScheduled = false; + this.thinkingEffortRevalidationTimer = undefined; + this.revalidateStoredThinkingEffort(); + }; + const custom = this.optionsValue.scheduleThinkingEffortRevalidation; + if (custom !== undefined) { + custom(run); + return; + } + this.thinkingEffortRevalidationTimer = setTimeout(run, 0); + } + + private revalidateStoredThinkingEffort(): void { + const before = this.lastPublishedThinkingEffort; + this.warnAboutThinkingEffortFallback(this.profileState.thinkingLevel); + const after = this.getEffectiveThinkingLevel(); + if (before !== undefined && after !== before) { + this.emitStatusUpdated(true); + } + } + + private warnAboutThinkingEffortFallback(requested: string | undefined): void { try { const model = this.tryResolveRawModel(); - if (model?.protocol !== 'anthropic') return; - const effort = this.getEffectiveThinkingLevel(); - if (effort === 'on' || effort === 'off') return; - - let code: string; - let message: string; - let knownEfforts = ''; - const efforts = model.supportEfforts?.filter((value) => value.length > 0); - if (efforts === undefined || efforts.length === 0 || efforts.includes(effort)) return; - knownEfforts = efforts.join(','); - code = 'anthropic-thinking-effort-not-listed'; - message = `Thinking effort "${effort}" is not listed for model "${model.name}" (known: ${efforts.join(', ')}). The configured value will be sent unchanged to the Anthropic-compatible backend.`; - - const key = [code, model.id, model.name, effort, knownEfforts].join('\u0000'); + if (model === undefined) return; + const thinking = this.config.get(THINKING_SECTION); + const { effort, fallback } = resolveThinkingEffortForModelWithFallback( + requested, + thinking, + model, + this.strictThinkingValidation(model), + ); + if (fallback === undefined) return; + const forced = resolveForcedThinkingEffort( + thinking?.forcedEffort, + effort, + drivesThinkingThroughTraits(model.providerType), + ); + if (forced !== undefined) return; + const efforts = declaredThinkingEfforts(model); + const knownEfforts = efforts.join(','); + const code = 'thinking-effort-not-listed'; + const message = `Thinking effort "${fallback.configured}" is not listed for model "${model.name}" (known: ${efforts.join(', ')}). Falling back to the model's default effort "${fallback.resolved}".`; + const key = [code, model.id, model.name, fallback.configured, fallback.resolved, knownEfforts].join('\u0000'); if (this.emittedThinkingEffortWarnings.has(key)) return; this.emittedThinkingEffortWarnings.add(key); void this.dispatcher.dispatch(new WarningIssued({ agentId: this.scopeContext.agentId, code, message })); @@ -647,6 +719,9 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ private emitStatusUpdated(includeThinkingEffort = false): void { const custom = this.optionsValue.emitStatusUpdated; if (custom !== undefined) { + if (includeThinkingEffort) { + this.lastPublishedThinkingEffort = this.getEffectiveThinkingLevel(); + } custom(); return; } @@ -654,13 +729,15 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ if (modelAlias === undefined) return; const capabilities = this.tryResolveRawModel()?.capabilities; const maxContextTokens = capabilities?.max_input_tokens ?? capabilities?.max_context_tokens; + const thinkingEffort = includeThinkingEffort ? this.getEffectiveThinkingLevel() : undefined; + if (thinkingEffort !== undefined) { + this.lastPublishedThinkingEffort = thinkingEffort; + } void this.dispatcher.dispatch( new AgentStatusUpdated({ agentId: this.scopeContext.agentId, model: modelAlias, - thinkingEffort: includeThinkingEffort - ? this.getEffectiveThinkingLevel() - : undefined, + thinkingEffort, maxContextTokens: maxContextTokens !== undefined && maxContextTokens > 0 ? maxContextTokens : undefined, }), @@ -696,13 +773,11 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ } private get thinkingLevel(): ThinkingEffort { - const stored = this.profileState.thinkingLevel; - if (stored === 'off' && this.alwaysThinkingModel) { - return this.resolveThinkingEffort(stored, this.tryResolveRawModel()); - } - return stored; + return this.resolveThinkingEffort(this.profileState.thinkingLevel, this.tryResolveRawModel()); } + private lastPublishedThinkingEffort: ThinkingEffort | undefined; + private resolveThinkingState(model: Model | undefined): { readonly effective: ThinkingEffort; readonly forced: ThinkingEffort | undefined; @@ -741,10 +816,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ return modelSupportsThinkingEffort(effort, model, this.strictThinkingValidation(model)); } - private get alwaysThinkingModel(): boolean { - return this.tryResolveRawModel()?.alwaysThinking === true; - } - private tryResolveRawModel(): Model | undefined { const alias = this.modelAlias; return this.resolveModelForThinking(alias); diff --git a/packages/agent-core-v2/src/kosong/contract/errors.ts b/packages/agent-core-v2/src/kosong/contract/errors.ts index 3a6bafde1d..ac2df088f3 100644 --- a/packages/agent-core-v2/src/kosong/contract/errors.ts +++ b/packages/agent-core-v2/src/kosong/contract/errors.ts @@ -316,7 +316,7 @@ function appendThinkingEffortConfigHint(statusCode: number, message: string): st if (message.includes(THINKING_EFFORT_CONFIG_DOCS_URL)) return message; return `${message} -The provider rejected the configured thinking effort. Non-Kimi providers receive effort strings without client-side mapping; choose an effort supported by the selected model. For Kimi models, check support_efforts and default_effort. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`; +The provider rejected the configured thinking effort. Efforts outside a model's declared support_efforts fall back to the model default, except a forced effort (forced_effort or KIMI_MODEL_THINKING_EFFORT) or an effort a running session locked in before a config reload, which are always sent unchanged; models without a declared list pass efforts to non-Kimi providers unchanged. Choose an effort supported by the selected model. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`; } export function isContextOverflowErrorCode(code: string | null | undefined): boolean { diff --git a/packages/agent-core-v2/src/kosong/model/modelAuth.ts b/packages/agent-core-v2/src/kosong/model/modelAuth.ts index b53d9a0213..4ea8f0f9a6 100644 --- a/packages/agent-core-v2/src/kosong/model/modelAuth.ts +++ b/packages/agent-core-v2/src/kosong/model/modelAuth.ts @@ -12,7 +12,7 @@ import { explainProviderEndpoint } from '../provider/providerDefinition'; import type { ModelRecord } from './model'; import type { ResolvedModelAuthMaterial } from './model.types'; -import { drivesThinkingThroughTraits } from './thinking'; +import { drivesThinkingThroughTraits, isDeclaredThinkingEffort } from './thinking'; export function resolveModelAuthMaterial( args: { @@ -87,7 +87,7 @@ export function effectiveModelConfig( overrides?.supportEfforts !== undefined && overrides.defaultEffort === undefined && effective.defaultEffort !== undefined && - !overrides.supportEfforts.includes(effective.defaultEffort) + !isDeclaredThinkingEffort(overrides.supportEfforts, effective.defaultEffort) ) { delete effective.defaultEffort; } diff --git a/packages/agent-core-v2/src/kosong/model/thinking.ts b/packages/agent-core-v2/src/kosong/model/thinking.ts index 96238d5e84..2b4d3aa103 100644 --- a/packages/agent-core-v2/src/kosong/model/thinking.ts +++ b/packages/agent-core-v2/src/kosong/model/thinking.ts @@ -101,6 +101,47 @@ function effortsFor(model: ModelThinkingMetadata | undefined): readonly string[] return model?.supportEfforts?.map(nonEmpty).filter((v): v is string => v !== undefined) ?? []; } +/** + * The model's declared `support_efforts`, normalized the same way the + * resolution layer normalizes them (trimmed, blanks dropped) — for + * diagnostics that must agree with what resolution accepted. + */ +export function declaredThinkingEfforts( + model: ModelThinkingMetadata | undefined, +): readonly string[] { + return effortsFor(model); +} + +/** + * Whether a raw declared `support_efforts` list covers `effort`, using the + * resolvers' normalization: trimmed, case-insensitive comparison. + */ +export function isDeclaredThinkingEffort( + supportEfforts: readonly string[] | undefined, + effort: string, +): boolean { + return matchDeclaredEffort(effortsFor({ supportEfforts }), effort) !== undefined; +} + +function declaredDefaultEffortFor( + model: ModelThinkingMetadata | undefined, + efforts: readonly string[], +): ThinkingEffort { + const declaredDefault = nonEmpty(model?.defaultEffort); + if (declaredDefault !== undefined) { + const matched = matchDeclaredEffort(efforts, declaredDefault); + if (matched !== undefined) return matched as ThinkingEffort; + } + return middleOf(efforts) as ThinkingEffort; +} + +function matchDeclaredEffort( + efforts: readonly string[], + effort: string, +): string | undefined { + return efforts.find((candidate) => candidate.toLowerCase() === effort.toLowerCase()); +} + export function modelSupportsThinking(model: ModelThinkingMetadata | undefined): boolean { if (model === undefined) return false; return ( @@ -114,14 +155,9 @@ export function modelSupportsThinking(model: ModelThinkingMetadata | undefined): export function defaultThinkingEffortForModel( model: ModelThinkingMetadata | undefined, ): ThinkingEffort { - if (model === undefined || !modelSupportsThinking(model)) return 'off'; const efforts = effortsFor(model); - if (efforts.length > 0) { - const declaredDefault = nonEmpty(model.defaultEffort); - return (declaredDefault !== undefined && efforts.includes(declaredDefault) - ? declaredDefault - : middleOf(efforts)) as ThinkingEffort; - } + if (efforts.length > 0) return declaredDefaultEffortFor(model, efforts); + if (model === undefined || !modelSupportsThinking(model)) return 'off'; return 'on'; } @@ -131,9 +167,9 @@ export function modelSupportsThinkingEffort( strictValidation: boolean, ): boolean { if (!strictValidation || effort === 'off') return true; - if (!modelSupportsThinking(model)) return false; const efforts = effortsFor(model); - return efforts.length === 0 || effort === 'on' || efforts.includes(effort); + if (efforts.length > 0) return effort === 'on' || matchDeclaredEffort(efforts, effort) !== undefined; + return modelSupportsThinking(model); } function normalizeThinkingEffortForModel( @@ -144,24 +180,31 @@ function normalizeThinkingEffortForModel( if (effort === 'off' && model?.alwaysThinking !== true) return 'off'; const efforts = effortsFor(model); if (!strictValidation) { - return effort === 'on' && efforts.length > 0 - ? defaultThinkingEffortForModel(model) - : effort; + if (efforts.length === 0) return effort; + if (effort === 'on') return declaredDefaultEffortFor(model, efforts); + return (matchDeclaredEffort(efforts, effort) ?? + declaredDefaultEffortFor(model, efforts)) as ThinkingEffort; } - if (!modelSupportsThinking(model)) return 'off'; - if (efforts.length === 0) return 'on'; - if (effort === 'on' || !efforts.includes(effort)) { - return defaultThinkingEffortForModel(model); + if (efforts.length > 0) { + if (effort === 'on') return declaredDefaultEffortFor(model, efforts); + return (matchDeclaredEffort(efforts, effort) ?? + declaredDefaultEffortFor(model, efforts)) as ThinkingEffort; } - return effort; + if (!modelSupportsThinking(model)) return 'off'; + return 'on'; } -export function resolveThinkingEffortForModel( +export interface ThinkingEffortFallback { + readonly configured: ThinkingEffort; + readonly resolved: ThinkingEffort; +} + +export function resolveThinkingEffortForModelWithFallback( requested: string | undefined, defaults: ThinkingDefaults | undefined, model: ModelThinkingMetadata | undefined, strictValidation = false, -): ThinkingEffort { +): { readonly effort: ThinkingEffort; readonly fallback: ThinkingEffortFallback | undefined } { const configured = normalizeRequestedThinkingEffort(defaults?.effort); const normalized = normalizeRequestedThinkingEffort(requested); let effort: ThinkingEffort; @@ -179,7 +222,23 @@ export function resolveThinkingEffortForModel( ? configured : defaultThinkingEffortForModel(model); } - return normalizeThinkingEffortForModel(effort, model, strictValidation); + const resolved = normalizeThinkingEffortForModel(effort, model, strictValidation); + const efforts = effortsFor(model); + const fallback: ThinkingEffortFallback | undefined = + effort !== 'on' && effort !== 'off' && efforts.length > 0 && matchDeclaredEffort(efforts, effort) === undefined + ? { configured: effort, resolved } + : undefined; + return { effort: resolved, fallback }; +} + +export function resolveThinkingEffortForModel( + requested: string | undefined, + defaults: ThinkingDefaults | undefined, + model: ModelThinkingMetadata | undefined, + strictValidation = false, +): ThinkingEffort { + return resolveThinkingEffortForModelWithFallback(requested, defaults, model, strictValidation) + .effort; } const KEEP_OFF_VALUES = new Set(['0', 'false', 'no', 'off', 'none', 'null']); diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts index 5df2000f84..9b2304de9c 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts @@ -298,7 +298,7 @@ describe('AgentLLMRequesterService measured anchors', () => { }); }); -describe('AgentLLMRequesterService Anthropic effort diagnostics', () => { +describe('AgentLLMRequesterService thinking effort diagnostics', () => { it('warns and sends when the effort is not listed by the model', async () => { const calls = { value: 0 }; const requester = createRequester(calls, null); @@ -312,12 +312,53 @@ describe('AgentLLMRequesterService Anthropic effort diagnostics', () => { expect(events.filter((event) => event.type === 'warning')).toEqual([ expect.objectContaining({ type: 'warning', - code: 'anthropic-thinking-effort-not-listed', + code: 'thinking-effort-not-listed', message: - 'Thinking effort "high" is not listed for model "wire-model" (known: max). The configured value will be sent unchanged to the Anthropic-compatible backend.', + 'Thinking effort "high" is not listed for model "wire-model" (known: max). The value will be sent unchanged to the backend.', }), ]); }); + + it('warns for unlisted efforts on any protocol', async () => { + const calls = { value: 0 }; + const requester = createRequester(calls, null); + Object.defineProperty(requester.model, 'protocol', { value: 'openai' }); + Object.defineProperty(requester.model, 'supportEfforts', { value: ['max'] }); + const { service, events } = createService(requester, undefined, { thinkingLevel: 'high' }); + + await service.request(); + + expect(events.filter((event) => event.type === 'warning')).toEqual([ + expect.objectContaining({ + type: 'warning', + code: 'thinking-effort-not-listed', + message: + 'Thinking effort "high" is not listed for model "wire-model" (known: max). The value will be sent unchanged to the backend.', + }), + ]); + }); + + it('does not warn when the effort matches a padded declared list entry', async () => { + const calls = { value: 0 }; + const requester = createRequester(calls, null); + Object.defineProperty(requester.model, 'supportEfforts', { value: [' max '] }); + const { service, events } = createService(requester, undefined, { thinkingLevel: 'max' }); + + await service.request(); + + expect(events.filter((event) => event.type === 'warning')).toEqual([]); + }); + + it('does not warn when the effort matches a declared entry case-insensitively', async () => { + const calls = { value: 0 }; + const requester = createRequester(calls, null); + Object.defineProperty(requester.model, 'supportEfforts', { value: [' High '] }); + const { service, events } = createService(requester, undefined, { thinkingLevel: 'high' }); + + await service.request(); + + expect(events.filter((event) => event.type === 'warning')).toEqual([]); + }); }); describe('AgentLLMRequesterService strict resend', () => { 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 e9e949ceb8..5d15377210 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,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester'; import { IAgentProfileService } from '#/agent/profile/profile'; +import { DEFAULT_AGENT_PROFILE_NAME } from '#/app/agentProfileCatalog/agentProfileCatalog'; import type { ModelRecord } from '#/kosong/model/model'; +import { WIRE_PROTOCOL_VERSION } from '#/wire/migration/migration'; import { configServices, createTestAgent, @@ -395,6 +397,304 @@ describe('ConfigState thinking clamp for always-thinking models', () => { expect(profile.data().thinkingLevel).toBe('max'); }); + it('warns once when a model metadata reload strands the stored effort', async () => { + const scheduled: Array<() => void> = []; + profile.configure({ + scheduleThinkingEffortRevalidation: (run) => { + scheduled.push(run); + }, + }); + profile.update({ modelAlias: 'kimi-code/ultra', thinkingLevel: 'high' }); + expect(profile.data().thinkingLevel).toBe('high'); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([]); + + kimiConfig = { + ...kimiConfig, + models: { + ...kimiConfig.models, + 'kimi-code/ultra': { + provider: 'kimi', + model: 'kimi-ultra', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'ultra'], + defaultEffort: 'ultra', + }, + }, + }; + profile.data(); + await vi.waitFor(() => { + expect(scheduled.length).toBeGreaterThan(0); + }); + for (const run of scheduled.splice(0)) run(); + + expect(profile.data().thinkingLevel).toBe('ultra'); + await vi.waitFor(() => { + expect(ctx.allEvents).toContainEqual({ + type: '[rpc]', + event: 'warning', + args: expect.objectContaining({ + code: 'thinking-effort-not-listed', + message: + 'Thinking effort "high" is not listed for model "kimi-ultra" (known: low, ultra). Falling back to the model\'s default effort "ultra".', + }), + }); + }); + await vi.waitFor(() => { + const statuses = ctx.allEvents.filter((event) => event.event === 'agent.status.updated'); + expect(statuses.at(-1)?.args).toMatchObject({ thinkingEffort: 'ultra' }); + }); + }); + + it('warns again when a default_effort-only reload changes the fallback target', async () => { + const scheduled: Array<() => void> = []; + profile.configure({ + scheduleThinkingEffortRevalidation: (run) => { + scheduled.push(run); + }, + }); + profile.update({ modelAlias: 'kimi-code/ultra', thinkingLevel: 'high' }); + expect(profile.data().thinkingLevel).toBe('high'); + + const withUltraDefault = (defaultEffort: string) => ({ + ...kimiConfig, + models: { + ...kimiConfig.models, + 'kimi-code/ultra': { + provider: 'kimi', + model: 'kimi-ultra', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'ultra'], + defaultEffort, + }, + }, + }); + const revalidate = async (): Promise => { + profile.data(); + await vi.waitFor(() => { + expect(scheduled.length).toBeGreaterThan(0); + }); + for (const run of scheduled.splice(0)) run(); + }; + const fallbackWarnings = () => + ctx.allEvents.filter( + (event) => + event.event === 'warning' && + (event.args as { code?: string }).code === 'thinking-effort-not-listed', + ); + + kimiConfig = withUltraDefault('ultra'); + await revalidate(); + expect(profile.data().thinkingLevel).toBe('ultra'); + await vi.waitFor(() => { + expect(fallbackWarnings()).toHaveLength(1); + }); + + kimiConfig = withUltraDefault('low'); + await revalidate(); + expect(profile.data().thinkingLevel).toBe('low'); + await vi.waitFor(() => { + expect(fallbackWarnings()).toHaveLength(2); + }); + expect(fallbackWarnings().at(-1)?.args).toMatchObject({ + message: + 'Thinking effort "high" is not listed for model "kimi-ultra" (known: low, ultra). Falling back to the model\'s default effort "low".', + }); + }); + + it('republishes the status when a reload makes a stranded effort valid again', async () => { + const scheduled: Array<() => void> = []; + profile.configure({ + scheduleThinkingEffortRevalidation: (run) => { + scheduled.push(run); + }, + }); + profile.update({ modelAlias: 'kimi-code/ultra', thinkingLevel: 'high' }); + expect(profile.data().thinkingLevel).toBe('high'); + + const withUltraEfforts = (supportEfforts: string[]) => ({ + ...kimiConfig, + models: { + ...kimiConfig.models, + 'kimi-code/ultra': { + provider: 'kimi', + model: 'kimi-ultra', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts, + defaultEffort: 'ultra', + }, + }, + }); + const revalidate = async (): Promise => { + profile.data(); + await vi.waitFor(() => { + expect(scheduled.length).toBeGreaterThan(0); + }); + for (const run of scheduled.splice(0)) run(); + }; + + kimiConfig = withUltraEfforts(['low', 'ultra']); + await revalidate(); + expect(profile.data().thinkingLevel).toBe('ultra'); + await vi.waitFor(() => { + expect(ctx.allEvents).toContainEqual( + expect.objectContaining({ + event: 'warning', + args: expect.objectContaining({ code: 'thinking-effort-not-listed' }), + }), + ); + }); + await vi.waitFor(() => { + const statuses = ctx.allEvents.filter((event) => event.event === 'agent.status.updated'); + expect(statuses.at(-1)?.args).toMatchObject({ thinkingEffort: 'ultra' }); + }); + + kimiConfig = withUltraEfforts(['low', 'high', 'ultra']); + await revalidate(); + expect(profile.data().thinkingLevel).toBe('high'); + await vi.waitFor(() => { + const statuses = ctx.allEvents.filter((event) => event.event === 'agent.status.updated'); + expect(statuses.at(-1)?.args).toMatchObject({ thinkingEffort: 'high' }); + }); + expect( + ctx.allEvents.filter( + (event) => + event.event === 'warning' && + (event.args as { code?: string }).code === 'thinking-effort-not-listed', + ), + ).toHaveLength(1); + }); + + it('republishes the status even when a read happens before the revalidation runs', async () => { + const scheduled: Array<() => void> = []; + profile.configure({ + scheduleThinkingEffortRevalidation: (run) => { + scheduled.push(run); + }, + }); + profile.update({ modelAlias: 'kimi-code/ultra', thinkingLevel: 'high' }); + expect(profile.data().thinkingLevel).toBe('high'); + + kimiConfig = { + ...kimiConfig, + models: { + ...kimiConfig.models, + 'kimi-code/ultra': { + provider: 'kimi', + model: 'kimi-ultra', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'ultra'], + defaultEffort: 'ultra', + }, + }, + }; + profile.data(); + await vi.waitFor(() => { + expect(scheduled.length).toBeGreaterThan(0); + }); + + expect(profile.getEffectiveThinkingLevel()).toBe('ultra'); + + for (const run of scheduled.splice(0)) run(); + + await vi.waitFor(() => { + const statuses = ctx.allEvents.filter((event) => event.event === 'agent.status.updated'); + expect(statuses.at(-1)?.args).toMatchObject({ thinkingEffort: 'ultra' }); + }); + }); + + it('warns once when a provider-only reload changes the inferred effort list', async () => { + const scheduled: Array<() => void> = []; + profile.configure({ + scheduleThinkingEffortRevalidation: (run) => { + scheduled.push(run); + }, + }); + kimiConfig = { + providers: { + compat: { type: 'openai', apiKey: 'test-key', baseUrl: 'https://api.example.test/v1' }, + }, + models: { + 'compat/claude': { + provider: 'compat', + model: 'joint-claude-custom', + maxContextSize: 128_000, + capabilities: ['thinking'], + }, + }, + }; + profile.update({ modelAlias: 'compat/claude', thinkingLevel: 'ultra' }); + expect(profile.data().thinkingLevel).toBe('ultra'); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([]); + + kimiConfig = { + ...kimiConfig, + providers: { + compat: { type: 'anthropic', apiKey: 'test-key', baseUrl: 'https://api.example.test/v1' }, + }, + }; + profile.data(); + await vi.waitFor(() => { + expect(scheduled.length).toBeGreaterThan(0); + }); + for (const run of scheduled.splice(0)) run(); + + expect(profile.data().thinkingLevel).toBe('high'); + await vi.waitFor(() => { + expect(ctx.allEvents).toContainEqual({ + type: '[rpc]', + event: 'warning', + args: expect.objectContaining({ + code: 'thinking-effort-not-listed', + message: + 'Thinking effort "ultra" is not listed for model "joint-claude-custom" (known: low, medium, high, xhigh, max). Falling back to the model\'s default effort "high".', + }), + }); + }); + + kimiConfig = { + providers: { + compat: { type: 'anthropic', apiKey: 'test-key', baseUrl: 'https://api.example.test/v2' }, + }, + models: { + 'compat/claude': { + provider: 'compat', + model: 'joint-claude-custom', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + }, + }, + }; + profile.data(); + await vi.waitFor(() => { + expect(scheduled.length).toBeGreaterThan(0); + }); + for (const run of scheduled.splice(0)) run(); + + await vi.waitFor(() => { + expect(ctx.allEvents).toContainEqual({ + type: '[rpc]', + event: 'warning', + args: expect.objectContaining({ + code: 'thinking-effort-not-listed', + message: + 'Thinking effort "ultra" is not listed for model "joint-claude-custom" (known: low, high). Falling back to the model\'s default effort "high".', + }), + }); + }); + expect( + ctx.allEvents.filter( + (event) => + event.event === 'warning' && + (event.args as { code?: string }).code === 'thinking-effort-not-listed', + ), + ).toHaveLength(2); + }); + it('projects an inherited concrete effort to on when switching to a boolean model', () => { profile.update({ modelAlias: 'kimi-code/ultra', thinkingLevel: 'ultra' }); @@ -432,20 +732,126 @@ describe('ConfigState thinking clamp for always-thinking models', () => { expect(profile.data().thinkingLevel).toBe('max'); }); - it('preserves unlisted efforts with a warning for Kimi-managed Anthropic models', () => { + it('falls back to the model default with a warning for an unlisted effort', () => { profile.update({ modelAlias: 'kimi-code/compatible', thinkingLevel: 'max' }); expect(() => { profile.setThinking('high'); }).not.toThrow(); - expect(profile.data().thinkingLevel).toBe('high'); + expect(profile.data().thinkingLevel).toBe('max'); + expect(ctx.allEvents).toContainEqual({ + type: '[rpc]', + event: 'warning', + args: expect.objectContaining({ + code: 'thinking-effort-not-listed', + message: + 'Thinking effort "high" is not listed for model "compatible-model" (known: max). Falling back to the model\'s default effort "max".', + }), + }); + }); + + it('normalizes a persisted unlisted effort to the declared default on resume', async () => { + await ctx.restore([ + { + type: 'metadata', + protocol_version: WIRE_PROTOCOL_VERSION, + created_at: 1, + }, + { + type: 'profile.bind', + modelAlias: 'kimi-code/compatible', + profileName: 'restored-profile', + thinkingEffort: 'high', + systemPrompt: 'restored prompt', + disallowedTools: [], + }, + ]); + + expect(profile.data().thinkingLevel).toBe('max'); + expect(ctx.allEvents).toContainEqual({ + type: '[rpc]', + event: 'warning', + args: expect.objectContaining({ + code: 'thinking-effort-not-listed', + message: + 'Thinking effort "high" is not listed for model "compatible-model" (known: max). Falling back to the model\'s default effort "max".', + }), + }); + + await requester.request({}, undefined, new AbortController().signal); + + expect(capturedThinking).toMatchObject({ effort: 'max' }); + }); + + it('suppresses the fallback warning when a forced effort decides the final effort', async () => { + kimiConfig = { + providers: { kimi: { type: 'kimi', apiKey: 'test-key', baseUrl: 'https://api.example.test/v1' } }, + models: { + 'kimi-code/custom': { + provider: 'kimi', + model: 'kimi-custom-coder', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'max'], + defaultEffort: 'max', + }, + }, + thinking: { effort: 'high', forcedEffort: 'low' }, + }; + + await profile.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: 'kimi-code/custom' }); + + expect(profile.data().thinkingLevel).toBe('max'); + expect( + ctx.allEvents.filter( + (event) => + event.event === 'warning' && + String((event.args as { code?: string }).code).startsWith('thinking-effort'), + ), + ).toEqual([]); + + await requester.request({}, undefined, new AbortController().signal); + + expect(capturedThinking).toMatchObject({ effort: 'low' }); + }); + + it('warns only about an unlisted forced effort sent unchanged', async () => { + kimiConfig = { + providers: { kimi: { type: 'kimi', apiKey: 'test-key', baseUrl: 'https://api.example.test/v1' } }, + models: { + 'kimi-code/custom': { + provider: 'kimi', + model: 'kimi-custom-coder', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'max'], + defaultEffort: 'max', + }, + }, + thinking: { effort: 'high', forcedEffort: 'extreme' }, + }; + + await profile.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: 'kimi-code/custom' }); + + expect(profile.data().thinkingLevel).toBe('max'); + expect( + ctx.allEvents.filter( + (event) => + event.event === 'warning' && + String((event.args as { code?: string }).code).startsWith('thinking-effort'), + ), + ).toEqual([]); + + await requester.request({}, undefined, new AbortController().signal); + + expect(capturedThinking).toMatchObject({ effort: 'extreme' }); expect(ctx.allEvents).toContainEqual({ type: '[rpc]', event: 'warning', args: expect.objectContaining({ - code: 'anthropic-thinking-effort-not-listed', + code: 'thinking-effort-not-listed', message: - 'Thinking effort "high" is not listed for model "compatible-model" (known: max). The configured value will be sent unchanged to the Anthropic-compatible backend.', + 'Thinking effort "extreme" is not listed for model "kimi-custom-coder" (known: low, medium, max). The value will be sent unchanged to the backend.', }), }); }); diff --git a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts index 0f04bee73a..84559ef70b 100644 --- a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts +++ b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts @@ -1,12 +1,12 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; -import { Event } from '#/_base/event'; +import { Emitter, Event } from '#/_base/event'; import { IAgentProfileService } from '#/agent/profile/profile'; import { AgentProfileService } from '#/agent/profile/profileService'; -import { profileActiveToolsKey, profileKey } from '#/agent/profile/profileOps'; +import { profileActiveToolsKey, profileKey, WarningIssued } from '#/agent/profile/profileOps'; import { DEFAULT_AGENT_PROFILE_NAME, type EnvironmentDisclosureSnapshot, @@ -16,6 +16,8 @@ import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalo import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { IModelCatalog, type Model } from '#/kosong/model/catalog'; +import { IModelService, type ModelsChangedEvent } from '#/kosong/model/model'; +import { IProviderService, type ProvidersChangedEvent } from '#/kosong/provider/provider'; import { IProtocolAdapterRegistry, type Protocol } from '#/kosong/protocol/protocol'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; @@ -186,6 +188,8 @@ let agentState: IAgentStateService; let svc: IAgentProfileService; let configValues: Record; let modelCatalog: IModelCatalog; +let modelChangeEvents: Emitter; +let providerChangeEvents: Emitter; function buildHost(key: string): { ix: TestInstantiationService; @@ -205,6 +209,15 @@ function buildHost(key: string): { ); host.stub(IConfigService, createConfigStub()); host.stub(IModelCatalog, modelCatalog); + host.stub(IModelService, { + _serviceBrand: undefined, + onDidChangeModels: modelChangeEvents.event, + onDidChangeDefaultModel: Event.None, + } as unknown as IModelService); + host.stub(IProviderService, { + _serviceBrand: undefined, + onDidChangeProviders: providerChangeEvents.event, + } as unknown as IProviderService); host.stub(IProtocolAdapterRegistry, createProtocolRegistryStub()); host.stub(IHostEnvironment, stubUnused()); host.stub(IHostFileSystem, stubUnused()); @@ -266,6 +279,8 @@ beforeEach(() => { disposables = new DisposableStore(); configValues = {}; modelCatalog = createModelCatalogStub(); + modelChangeEvents = disposables.add(new Emitter()); + providerChangeEvents = disposables.add(new Emitter()); const host = buildHost(KEY); ix = host.ix; dispatcher = host.dispatcher; @@ -737,4 +752,39 @@ describe('AgentProfileService (wire-backed config.update)', () => { expect(host.svc.resolveRequestParams().cacheKey).toBe('session-test'); }); + + it('coalesces a combined provider and model reload into one revalidation', () => { + const catalogModels: Record = { + 'kimi-code': createTestModel({ providerType: 'openai' }), + }; + modelCatalog = createModelCatalogStub(catalogModels); + const host = buildHost('profile-reload-coalesce'); + const scheduled: Array<() => void> = []; + host.svc.configure({ + scheduleThinkingEffortRevalidation: (run) => { + scheduled.push(run); + }, + }); + const dispatched = vi.spyOn(host.dispatcher, 'dispatch'); + + host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'ultra' }); + expect(host.svc.data().thinkingLevel).toBe('ultra'); + + catalogModels['kimi-code'] = { + ...createTestModel({ providerType: 'openai' }), + supportEfforts: ['low', 'high'], + }; + providerChangeEvents.fire({ added: [], removed: [], changed: ['compat'] }); + catalogModels['kimi-code'] = { + ...createTestModel({ providerType: 'openai' }), + supportEfforts: ['ultra'], + }; + modelChangeEvents.fire({ added: [], removed: [], changed: ['kimi-code'] }); + + expect(scheduled).toHaveLength(1); + for (const run of scheduled.splice(0)) run(); + + expect(host.svc.data().thinkingLevel).toBe('ultra'); + expect(dispatched.mock.calls.filter(([event]) => event instanceof WarningIssued)).toEqual([]); + }); }); diff --git a/packages/agent-core-v2/test/agent/profile/thinking.test.ts b/packages/agent-core-v2/test/agent/profile/thinking.test.ts index e5e063c942..5983e9bf72 100644 --- a/packages/agent-core-v2/test/agent/profile/thinking.test.ts +++ b/packages/agent-core-v2/test/agent/profile/thinking.test.ts @@ -187,6 +187,29 @@ describe('resolveThinkingEffortForModel', () => { ); }); + it('falls back to the declared default for an unlisted effort on non-strict protocols', () => { + const declared = { + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'xhigh'], + defaultEffort: 'xhigh', + protocol: 'openai', + providerType: 'openai', + }; + expect(resolveThinkingEffortForModel(undefined, { effort: 'high' }, declared, false)).toBe( + 'xhigh', + ); + expect(resolveThinkingEffortForModel('high', undefined, declared, false)).toBe('xhigh'); + expect(resolveThinkingEffortForModel('medium', undefined, declared, false)).toBe('medium'); + expect(resolveThinkingEffortForModel('xhigh', undefined, declared, false)).toBe('xhigh'); + }); + + it('still passes unlisted efforts through when the model declares no list', () => { + expect(resolveThinkingEffortForModel('ultra', undefined, booleanModel, false)).toBe('ultra'); + expect(resolveThinkingEffortForModel(undefined, { effort: 'ultra' }, booleanModel, false)).toBe( + 'ultra', + ); + }); + it('projects a concrete effort to on for a boolean-only Kimi model', () => { expect(resolveThinkingEffortForModel('ultra', undefined, kimiBooleanModel, true)).toBe('on'); }); diff --git a/packages/agent-core-v2/test/kosong/model/modelAuth.test.ts b/packages/agent-core-v2/test/kosong/model/modelAuth.test.ts index 7536c04821..b39ea1bf84 100644 --- a/packages/agent-core-v2/test/kosong/model/modelAuth.test.ts +++ b/packages/agent-core-v2/test/kosong/model/modelAuth.test.ts @@ -115,6 +115,16 @@ describe('effectiveModelConfig', () => { expect(effective.defaultEffort).toBeUndefined(); }); + it('keeps an inherited defaultEffort covered by the override list after normalization', () => { + const effective = effectiveModelConfig({ + model: 'm', + supportEfforts: ['low', 'high'], + defaultEffort: 'high', + overrides: { supportEfforts: [' High ', 'max'] }, + }); + expect(effective.defaultEffort).toBe('high'); + }); + it('infers the Anthropic profile for non-trait-driven vendors only', () => { const record: ModelRecord = { model: 'claude-sonnet-4-5', protocol: 'anthropic' }; const inferred = effectiveModelConfig(record, 'anthropic'); diff --git a/packages/agent-core-v2/test/kosong/model/thinking.test.ts b/packages/agent-core-v2/test/kosong/model/thinking.test.ts index f5fb7ed8ff..f1176185ce 100644 --- a/packages/agent-core-v2/test/kosong/model/thinking.test.ts +++ b/packages/agent-core-v2/test/kosong/model/thinking.test.ts @@ -10,6 +10,7 @@ import { requiresStrictThinkingValidation, resolveForcedThinkingEffort, resolveThinkingEffortForModel, + resolveThinkingEffortForModelWithFallback, resolveThinkingKeep, usesTraitDrivenThinking, } from '#/kosong/model/thinking'; @@ -65,10 +66,118 @@ describe('resolveThinkingEffortForModel', () => { expect(defaultThinkingEffortForModel(undefined)).toBe('off'); }); - it('normalizes unknown efforts back to the model default under kimi semantics', () => { + it('normalizes unknown efforts back to the model default on any wire', () => { expect(resolveThinkingEffortForModel('extreme', undefined, thinkingModel, true)).toBe('high'); - expect(resolveThinkingEffortForModel('extreme', undefined, thinkingModel, false)).toBe('extreme'); + expect(resolveThinkingEffortForModel('extreme', undefined, thinkingModel, false)).toBe('high'); expect(resolveThinkingEffortForModel('on', undefined, thinkingModel, true)).toBe('high'); + expect(resolveThinkingEffortForModel('on', undefined, thinkingModel, false)).toBe('high'); + }); + + it('falls back to the declared default for an unlisted effort without strict validation', () => { + const declared = { + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'xhigh'], + defaultEffort: 'xhigh', + }; + expect(resolveThinkingEffortForModel(undefined, { effort: 'high' }, declared, false)).toBe( + 'xhigh', + ); + expect(resolveThinkingEffortForModel('high', undefined, declared, false)).toBe('xhigh'); + expect(resolveThinkingEffortForModel('medium', undefined, declared, false)).toBe('medium'); + }); + + it('passes concrete efforts through when the model declares no effort list', () => { + expect( + resolveThinkingEffortForModel('extreme', undefined, { capabilities: ['thinking'] }, false), + ).toBe('extreme'); + expect( + resolveThinkingEffortForModel( + undefined, + { effort: 'extreme' }, + { capabilities: ['thinking'] }, + false, + ), + ).toBe('extreme'); + }); + + it('falls back to the declared default when the model omits the thinking capability', () => { + const declared = { + supportEfforts: ['low', 'medium', 'xhigh'], + defaultEffort: 'xhigh', + }; + expect(resolveThinkingEffortForModel(undefined, { effort: 'high' }, declared, false)).toBe( + 'xhigh', + ); + expect(resolveThinkingEffortForModel('high', undefined, declared, false)).toBe('xhigh'); + const withFallback = resolveThinkingEffortForModelWithFallback( + 'high', + undefined, + declared, + false, + ); + expect(withFallback.effort).toBe('xhigh'); + expect(withFallback.fallback).toEqual({ configured: 'high', resolved: 'xhigh' }); + expect( + resolveThinkingEffortForModel( + 'high', + undefined, + { supportEfforts: ['low', 'medium', 'xhigh'] }, + false, + ), + ).toBe('medium'); + }); + + it('treats a declared effort list as thinking support under strict validation', () => { + const declared = { + supportEfforts: ['low', 'medium', 'xhigh'], + defaultEffort: 'xhigh', + }; + expect(resolveThinkingEffortForModel(undefined, { effort: 'high' }, declared, true)).toBe( + 'xhigh', + ); + expect(resolveThinkingEffortForModel('high', undefined, declared, true)).toBe('xhigh'); + expect(resolveThinkingEffortForModel('on', undefined, declared, true)).toBe('xhigh'); + expect(resolveThinkingEffortForModel('medium', undefined, declared, true)).toBe('medium'); + expect(modelSupportsThinkingEffort('low', declared, true)).toBe(true); + expect(modelSupportsThinkingEffort('bogus', declared, true)).toBe(false); + expect(resolveThinkingEffortForModelWithFallback('high', undefined, declared, true)).toEqual({ + effort: 'xhigh', + fallback: { configured: 'high', resolved: 'xhigh' }, + }); + }); + + it('resolves the declared default when nothing is configured and the capability is omitted', () => { + const declared = { + supportEfforts: ['low', 'medium', 'xhigh'], + defaultEffort: 'xhigh', + }; + expect(defaultThinkingEffortForModel(declared)).toBe('xhigh'); + expect(resolveThinkingEffortForModel(undefined, undefined, declared, false)).toBe('xhigh'); + expect(resolveThinkingEffortForModel(undefined, undefined, declared, true)).toBe('xhigh'); + }); + + it('trims a padded default_effort before matching the declared list', () => { + expect( + defaultThinkingEffortForModel({ + supportEfforts: [' low ', ' medium ', ' xhigh '], + defaultEffort: ' xhigh ', + }), + ).toBe('xhigh'); + }); + + it('matches declared efforts case-insensitively and resolves the declared casing', () => { + const declared = { + capabilities: ['thinking'], + supportEfforts: ['Low', 'High', 'Max'], + defaultEffort: 'max', + }; + expect(resolveThinkingEffortForModel('low', undefined, declared, true)).toBe('Low'); + expect(resolveThinkingEffortForModel(undefined, { effort: 'high' }, declared, false)).toBe( + 'High', + ); + expect(defaultThinkingEffortForModel(declared)).toBe('Max'); + expect(modelSupportsThinkingEffort('low', declared, true)).toBe(true); + expect(resolveThinkingEffortForModel('ultra', undefined, declared, false)).toBe('Max'); }); it('keeps always-thinking models on under kimi semantics', () => { diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index 39cf68cb2b..656bd5e5eb 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -32,6 +32,8 @@ import { agentContextOf } from '#/agent/scopeContext/scopeContext'; import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; import { IModelCatalog } from '#/kosong/model/catalog'; +import { IModelService } from '#/kosong/model/model'; +import { IProviderService } from '#/kosong/provider/provider'; import type { ToolCall } from '#/kosong/contract/message'; import { IProtocolAdapterRegistry } from '#/kosong/protocol/protocol'; import { IHostClock } from '#/os/interface/hostClock'; @@ -367,6 +369,15 @@ describe('AgentLifecycleService', () => { ix.stub(IHostFileSystem, { _serviceBrand: undefined } as IHostFileSystem); ix.stub(IHostClock, { _serviceBrand: undefined } as IHostClock); ix.stub(IModelCatalog, { _serviceBrand: undefined } as IModelCatalog); + ix.stub(IModelService, { + _serviceBrand: undefined, + onDidChangeModels: Event.None, + onDidChangeDefaultModel: Event.None, + } as unknown as IModelService); + ix.stub(IProviderService, { + _serviceBrand: undefined, + onDidChangeProviders: Event.None, + } as unknown as IProviderService); ix.stub(IFlagService, stubFlag()); ix.stub(IProtocolAdapterRegistry, { _serviceBrand: undefined, diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index 5696060429..fce696e1cd 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -17,9 +17,10 @@ import type { Agent } from '..'; import { ErrorCodes, KimiError } from '../../errors'; import type { AgentConfigData, AgentConfigUpdateData } from './types'; import { - resolveThinkingEffort, + resolveThinkingEffortWithFallback, supportsThinkingEffort, type ThinkingEffort, + type ThinkingEffortFallback, } from './thinking'; import type { ModelAlias } from '../../config/schema'; import type { ResolvedRuntimeProvider } from '../../session/provider-manager'; @@ -68,25 +69,30 @@ export class ConfigState { const kimiProvider = targetProvider?.type === 'kimi'; let unforcedThinkingEffort: ThinkingEffort | undefined; let thinkingEffort: ThinkingEffort | undefined; + let thinkingFallback: ThinkingEffortFallback | undefined; if (changed.thinkingEffort !== undefined) { - unforcedThinkingEffort = resolveThinkingEffort( + const resolution = resolveThinkingEffortWithFallback( changed.thinkingEffort, this.agent.kimiConfig?.thinking, targetModel, kimiProtocol, ); + unforcedThinkingEffort = resolution.effort; + thinkingFallback = resolution.fallback; } else if (changed.modelAlias !== undefined) { // A bare model switch carries the previously resolved effort over to the // new model. Before any effort was resolved (fresh session bootstrap) // `undefined` lets resolveThinkingEffort fall through to the model // default — computed from the resolved provider, whose capabilities and // efforts include the provider-level protocol inference. - unforcedThinkingEffort = resolveThinkingEffort( + const resolution = resolveThinkingEffortWithFallback( this._unforcedThinkingEffort, this.agent.kimiConfig?.thinking, targetModel, kimiProtocol, ); + unforcedThinkingEffort = resolution.effort; + thinkingFallback = resolution.fallback; } if (unforcedThinkingEffort !== undefined) { thinkingEffort = @@ -129,8 +135,16 @@ export class ConfigState { if (this.hasProvider && (changed.cwd !== undefined || changed.modelAlias)) { this.agent.tools.initializeBuiltinTools(); } - if (thinkingEffort !== undefined || changed.modelAlias !== undefined) { - this.agent.warnAboutCurrentAnthropicThinkingEffort(); + if (thinkingFallback !== undefined && thinkingEffort === unforcedThinkingEffort) { + // When the env override decides the final effort, the fallback never + // reaches the wire — the override warning below is the accurate one. + this.agent.warnAboutThinkingEffortFallback(targetAlias, targetModel, thinkingFallback); + } + if (thinkingEffort !== undefined && thinkingEffort !== unforcedThinkingEffort) { + // The KIMI_MODEL_THINKING_EFFORT override is applied after resolution + // and bypasses support_efforts by design; warn once when the pinned + // value is outside the model's declared list. + this.agent.warnAboutUnlistedThinkingEffortOverride(targetAlias, targetModel, thinkingEffort); } this.agent.emitStatusUpdated(thinkingEffort !== undefined); } @@ -238,6 +252,18 @@ export class ConfigState { return this._thinkingEffort; } + /** + * Whether the current effort was pinned by the KIMI_MODEL_THINKING_EFFORT + * override rather than model-aware resolution. Diagnostics use this to + * avoid double-reporting the pinned value. + */ + get thinkingEffortOverridden(): boolean { + return ( + this._unforcedThinkingEffort !== undefined && + this._thinkingEffort !== this._unforcedThinkingEffort + ); + } + private get currentModel(): ModelAlias | undefined { const resolved = this.tryResolvedProviderConfig(); return this.modelForThinking(this._modelAlias, resolved); diff --git a/packages/agent-core/src/agent/config/thinking.ts b/packages/agent-core/src/agent/config/thinking.ts index a4166e9ad0..2549757766 100644 --- a/packages/agent-core/src/agent/config/thinking.ts +++ b/packages/agent-core/src/agent/config/thinking.ts @@ -19,16 +19,72 @@ function middleOf(efforts: readonly string[]): string { return efforts[Math.floor(efforts.length / 2)]!; } +/** + * Normalize a declared `support_efforts` list: trim entries and drop blanks. + * Shared by resolution and diagnostics so a padded declaration means the same + * thing everywhere. + */ +export function normalizeDeclaredEfforts( + efforts: readonly string[] | undefined, +): readonly string[] { + return ( + efforts?.map((effort) => effort.trim()).filter((effort) => effort.length > 0) ?? [] + ); +} + function effortsFor(model: ModelAlias | undefined): readonly string[] { const effective = model === undefined ? undefined : effectiveModelAlias(model); - return effective?.supportEfforts?.filter((effort) => effort.length > 0) ?? []; + return normalizeDeclaredEfforts(effective?.supportEfforts); +} + +/** + * Pick the fallback effort straight from the declared list: the declared + * `default_effort` when it is listed, else the middle entry. Unlike + * {@link defaultThinkingEffortFor} this skips the thinking-capability gate — + * a model that declares `support_efforts` without declaring the `thinking` + * capability still has a meaningful declared default to fall back to. + */ +function declaredDefaultEffortFor( + model: ModelAlias | undefined, + efforts: readonly string[], +): ThinkingEffort { + const declaredDefault = model?.defaultEffort?.trim(); + if (declaredDefault !== undefined && declaredDefault.length > 0) { + const matched = matchDeclaredEffort(efforts, declaredDefault); + if (matched !== undefined) return matched; + } + return middleOf(efforts); +} + +/** + * Case-insensitive membership against the declared list, returning the + * declared (canonical) entry — backends recognize the declared casing, so the + * canonical form is what goes on the wire. + */ +function matchDeclaredEffort( + efforts: readonly string[], + effort: string, +): string | undefined { + return efforts.find((candidate) => candidate.toLowerCase() === effort.toLowerCase()); +} + +/** + * Whether a raw declared `support_efforts` list covers `effort`, using the + * resolvers' normalization: trimmed, case-insensitive comparison. + */ +export function isDeclaredThinkingEffort( + supportEfforts: readonly string[] | undefined, + effort: string, +): boolean { + return matchDeclaredEffort(normalizeDeclaredEfforts(supportEfforts), effort) !== undefined; } /** * Resolve the default thinking effort for a model from its declared metadata: + * - models declaring `support_efforts` -> `default_effort`, else the middle + * entry of the list (a declared list is itself a thinking declaration, so + * the capability gate does not apply to it) * - models that do not support thinking (or an unknown model) -> `'off'` - * - effort-capable models -> `default_effort`, else the middle entry of - * `support_efforts` (so we never pick an effort the model does not support) * - boolean models (thinking support without `support_efforts`) -> `'on'` * * `support_efforts` is the single source of truth for efforts; the returned @@ -36,14 +92,9 @@ function effortsFor(model: ModelAlias | undefined): readonly string[] { */ export function defaultThinkingEffortFor(model: ModelAlias | undefined): ThinkingEffort { const effective = model === undefined ? undefined : effectiveModelAlias(model); - if (!supportsThinking(effective)) return 'off'; const efforts = effortsFor(effective); - if (efforts.length > 0) { - const declaredDefault = effective?.defaultEffort; - return declaredDefault !== undefined && efforts.includes(declaredDefault) - ? declaredDefault - : middleOf(efforts); - } + if (efforts.length > 0) return declaredDefaultEffortFor(effective, efforts); + if (!supportsThinking(effective)) return 'off'; return 'on'; } @@ -54,9 +105,11 @@ export function supportsThinkingEffort( ): boolean { if (!kimiProtocol || effort === 'off') return true; const effective = model === undefined ? undefined : effectiveModelAlias(model); - if (!supportsThinking(effective)) return false; const efforts = effortsFor(effective); - return efforts.length === 0 || effort === 'on' || efforts.includes(effort); + // A declared effort list is itself a declaration of thinking support: list + // membership decides, even when the thinking capability was omitted. + if (efforts.length > 0) return effort === 'on' || matchDeclaredEffort(efforts, effort) !== undefined; + return supportsThinking(effective); } function normalizeThinkingEffortForModel( @@ -71,38 +124,44 @@ function normalizeThinkingEffortForModel( const efforts = effortsFor(effective); if (!kimiProtocol) { - return effort === 'on' && efforts.length > 0 - ? defaultThinkingEffortFor(effective) - : effort; + // Compatible protocols pass values through only while the model declares + // no effort list — with a declared list, an unlisted effort is a config + // mistake the backend would reject, so fall back like the Kimi wire does. + if (efforts.length === 0) return effort; + if (effort === 'on') return declaredDefaultEffortFor(effective, efforts); + return matchDeclaredEffort(efforts, effort) ?? declaredDefaultEffortFor(effective, efforts); } - if (!supportsThinking(effective)) return 'off'; - if (efforts.length === 0) return 'on'; - if (effort === 'on' || !efforts.includes(effort)) { - return defaultThinkingEffortFor(effective); + if (efforts.length > 0) { + if (effort === 'on') return declaredDefaultEffortFor(effective, efforts); + return matchDeclaredEffort(efforts, effort) ?? declaredDefaultEffortFor(effective, efforts); } - return effort; + if (!supportsThinking(effective)) return 'off'; + return 'on'; } /** - * Resolve the effective thinking effort for a session. - * - * Precedence: - * 1. an explicit `requested` effort (per-session override) wins; - * 2. `thinking.enabled === false` forces `'off'`; - * 3. otherwise `thinking.effort` when set, else the model's default effort. - * - * A model that declares `always_thinking` can never resolve to `'off'`, on - * any wire — a claimed off state would be a lie, since upstream keeps - * reasoning at its default when no off encoding exists. (Compatible - * protocols still receive every other requested value unchanged so their - * backend can make the final capability decision.) + * A thinking-effort fallback: the configured value is not in the model's + * declared `support_efforts` list, so `resolved` (the model's default effort) + * is applied instead. */ -export function resolveThinkingEffort( +export interface ThinkingEffortFallback { + readonly configured: ThinkingEffort; + readonly resolved: ThinkingEffort; +} + +/** + * Resolve the effective thinking effort for a session, and report whether the + * resolution had to fall back to the model's default effort because the + * configured value is not in the model's declared `support_efforts` list. + * `'on'`/`'off'` are protocol encodings, not list members, so they never + * count as a fallback. + */ +export function resolveThinkingEffortWithFallback( requested: ThinkingEffort | undefined, config: ThinkingConfig | undefined, model: ModelAlias | undefined, kimiProtocol = false, -): ThinkingEffort { +): { readonly effort: ThinkingEffort; readonly fallback: ThinkingEffortFallback | undefined } { const effectiveModel = model === undefined ? undefined : effectiveModelAlias(model); // Normalize the configured value once: 'OFF' / ' off ' must be read as off // on every path, not passed upstream as a concrete effort; whitespace-only @@ -132,5 +191,35 @@ export function resolveThinkingEffort( : defaultThinkingEffortFor(effectiveModel); } - return normalizeThinkingEffortForModel(effort, effectiveModel, kimiProtocol); + const resolved = normalizeThinkingEffortForModel(effort, effectiveModel, kimiProtocol); + const efforts = effortsFor(effectiveModel); + const fallback: ThinkingEffortFallback | undefined = + effort !== 'on' && + effort !== 'off' && + efforts.length > 0 && + matchDeclaredEffort(efforts, effort) === undefined + ? { configured: effort, resolved } + : undefined; + return { effort: resolved, fallback }; +} + +/** + * Resolve the effective thinking effort for a session. + * + * Precedence: + * 1. an explicit `requested` effort (per-session override) wins; + * 2. `thinking.enabled === false` forces `'off'`; + * 3. otherwise `thinking.effort` when set, else the model's default effort. + * + * A model that declares `always_thinking` can never resolve to `'off'`, on + * any wire — a claimed off state would be a lie, since upstream keeps + * reasoning at its default when no off encoding exists. + */ +export function resolveThinkingEffort( + requested: ThinkingEffort | undefined, + config: ThinkingConfig | undefined, + model: ModelAlias | undefined, + kimiProtocol = false, +): ThinkingEffort { + return resolveThinkingEffortWithFallback(requested, config, model, kimiProtocol).effort; } diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index c54bd0896b..3585e61c1b 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -1,7 +1,8 @@ import { join } from 'pathe'; import { randomUUID } from 'node:crypto'; -import { normalizeAdditionalDirs } from '../config'; +import { effectiveModelAlias, normalizeAdditionalDirs } from '../config'; +import type { ModelAlias } from '../config/schema'; import { ErrorCodes, KimiError, makeErrorPayload } from '#/errors'; import { log } from '#/logging/logger'; import type { Logger } from '#/logging/types'; @@ -34,6 +35,12 @@ import { } from './compaction'; import { CronManager } from './cron'; import { ConfigState } from './config'; +import { + isDeclaredThinkingEffort, + normalizeDeclaredEfforts, + type ThinkingEffort, + type ThinkingEffortFallback, +} from './config/thinking'; import { ContextMemory } from './context'; import { GoalMode } from './goal'; import { HookEngine } from '../session/hooks'; @@ -186,6 +193,7 @@ export class Agent { readonly model: string; readonly effort: string; readonly knownEfforts: string | undefined; + readonly fallbackEffort?: string; }> = []; private readonly systemPromptContextProvider?: (() => Promise) | undefined; @@ -295,7 +303,7 @@ export class Agent { // before dispatching), so it must not leave a request trace or a // diagnostic log line claiming a request was sent. if (requestOptions?.signal?.aborted !== true) { - this.warnAboutAnthropicThinkingEffort(provider, modelAlias); + this.warnAboutStaleThinkingEffort(provider, modelAlias); this.llmRequestLogger.logRequest({ provider, modelAlias, @@ -330,60 +338,148 @@ export class Agent { }; } - private warnAboutAnthropicThinkingEffort( - provider: ChatProvider, + /** + * One-shot warning for a configured thinking effort that is not in the + * model's declared support list: the effective effort falls back to the + * model's default instead of being sent upstream as-is. Emitted at config + * resolution time, where the pre-fallback value is still known; during + * record replay the warning is queued and flushed by {@link resume}. + */ + warnAboutThinkingEffortFallback( modelAlias: string | undefined, + model: ModelAlias | undefined, + fallback: ThinkingEffortFallback, ): void { - if (provider.name !== 'anthropic') return; - const effort = provider.thinkingEffort; - if (effort === null || effort === 'on' || effort === 'off') return; + const effective = model === undefined ? undefined : effectiveModelAlias(model); + const supportEfforts = normalizeDeclaredEfforts(effective?.supportEfforts); + const modelName = effective?.model ?? modelAlias ?? 'unknown'; + this.emitThinkingEffortWarning({ + code: 'thinking-effort-not-listed', + message: `Thinking effort "${fallback.configured}" is not listed for model "${modelName}" (known: ${supportEfforts.join(', ')}). Falling back to the model's default effort "${fallback.resolved}".`, + modelAlias, + model: modelName, + effort: fallback.configured, + knownEfforts: supportEfforts.join(','), + fallbackEffort: fallback.resolved, + }); + } - let warning: - | { readonly code: string; readonly message: string; readonly knownEfforts?: string } - | undefined; + /** + * One-shot warning for a `KIMI_MODEL_THINKING_EFFORT` override that is not + * in the model's declared support list. The override is an explicit pin + * applied after resolution: it bypasses the declared list by design and is + * sent upstream unchanged, so the warning says so instead of promising a + * fallback. + */ + warnAboutUnlistedThinkingEffortOverride( + modelAlias: string | undefined, + model: ModelAlias | undefined, + effort: ThinkingEffort, + ): void { + if (effort === 'on' || effort === 'off') return; + const effective = model === undefined ? undefined : effectiveModelAlias(model); + const supportEfforts = normalizeDeclaredEfforts(effective?.supportEfforts); + if (supportEfforts.length === 0 || isDeclaredThinkingEffort(effective?.supportEfforts, effort)) + return; + const modelName = effective?.model ?? modelAlias ?? 'unknown'; + this.emitThinkingEffortWarning({ + code: 'thinking-effort-override-not-listed', + message: `Thinking effort "${effort}" is not listed for model "${modelName}" (known: ${supportEfforts.join(', ')}). The value will be sent unchanged to the backend.`, + modelAlias, + model: modelName, + effort, + knownEfforts: supportEfforts.join(','), + fallbackEffort: undefined, + }); + } + + /** + * Request-time safety net for an effort resolved against older model + * metadata: a config reload swaps the declared effort list without + * re-running config resolution, leaving the cached effort outside the new + * list. The cached value is still sent unchanged (live sessions never + * re-resolve mid-flight); this restores the one-time diagnostic for that + * drift, on every protocol. + */ + private warnAboutStaleThinkingEffort( + provider: ChatProvider, + modelAlias: string | undefined, + ): void { try { + const effort = provider.thinkingEffort; + if (effort === null || effort === 'on' || effort === 'off') return; const resolved = modelAlias === undefined ? undefined : this.modelProvider?.resolveProviderConfig(modelAlias); if (resolved === undefined) return; - - const supportEfforts = resolved.supportEfforts?.filter((value) => value.length > 0); - if (supportEfforts === undefined || supportEfforts.length === 0) return; - if (supportEfforts.includes(effort)) return; - warning = { - code: 'anthropic-thinking-effort-not-listed', - message: `Thinking effort "${effort}" is not listed for model "${provider.modelName}" (known: ${supportEfforts.join(', ')}). The configured value will be sent unchanged to the Anthropic-compatible backend.`, + if (this.config.thinkingEffortOverridden) { + // The pin may have been listed when applied (no warning then) and + // dropped by a later reload: re-check it against the current list + // and emit the override warning. Its dedup key carries the list, so + // an unchanged list dedups against the apply-time warning. + this.warnAboutUnlistedThinkingEffortOverride( + modelAlias, + { + provider: resolved.providerName, + model: resolved.provider.model, + maxContextSize: Math.max(resolved.modelCapabilities.max_context_tokens, 1), + supportEfforts: + resolved.supportEfforts === undefined ? undefined : [...resolved.supportEfforts], + defaultEffort: resolved.defaultEffort, + }, + effort, + ); + return; + } + const supportEfforts = normalizeDeclaredEfforts(resolved.supportEfforts); + if (supportEfforts.length === 0) return; + if (isDeclaredThinkingEffort(resolved.supportEfforts, effort)) return; + this.emitThinkingEffortWarning({ + code: 'thinking-effort-not-listed', + message: `Thinking effort "${effort}" is not listed for model "${provider.modelName}" (known: ${supportEfforts.join(', ')}). The value will be sent unchanged to the backend.`, + modelAlias, + model: provider.modelName, + effort, knownEfforts: supportEfforts.join(','), - }; + fallbackEffort: undefined, + }); } catch { - // Capability diagnostics must never turn an otherwise sendable request - // into a client-side failure. - return; + // Capability diagnostics must never turn a sendable request into a failure. } + } - if (warning === undefined) return; - const key = [warning.code, modelAlias, provider.modelName, effort, warning.knownEfforts].join( - '\u0000', - ); - if (this.emittedThinkingEffortWarnings.has(key)) return; - this.emittedThinkingEffortWarnings.add(key); - const pending = { - code: warning.code, - message: warning.message, - modelAlias, - model: provider.modelName, - effort, - knownEfforts: warning.knownEfforts, - }; - if (this.records.restoring) { - this.pendingThinkingEffortWarnings.push(pending); - return; + private emitThinkingEffortWarning(warning: { + readonly code: string; + readonly message: string; + readonly modelAlias: string | undefined; + readonly model: string; + readonly effort: string; + readonly knownEfforts: string; + readonly fallbackEffort?: string; + }): void { + try { + const key = [ + warning.code, + warning.modelAlias, + warning.model, + warning.effort, + warning.fallbackEffort, + warning.knownEfforts, + ].join('\u0000'); + if (this.emittedThinkingEffortWarnings.has(key)) return; + this.emittedThinkingEffortWarnings.add(key); + if (this.records.restoring) { + this.pendingThinkingEffortWarnings.push(warning); + return; + } + this.publishThinkingEffortWarning(warning); + } catch { + // A capability warning must never make config replay or session resume fail. } - this.publishAnthropicThinkingEffortWarning(pending); } - private publishAnthropicThinkingEffortWarning( + private publishThinkingEffortWarning( warning: (typeof this.pendingThinkingEffortWarnings)[number], ): void { try { @@ -392,6 +488,7 @@ export class Agent { model: warning.model, effort: warning.effort, knownEfforts: warning.knownEfforts, + fallbackEffort: warning.fallbackEffort, }); } catch { // Diagnostics must never block resume or request dispatch. @@ -408,18 +505,9 @@ export class Agent { } } - private flushPendingAnthropicThinkingEffortWarnings(): void { + private flushPendingThinkingEffortWarnings(): void { for (const warning of this.pendingThinkingEffortWarnings.splice(0)) { - this.publishAnthropicThinkingEffortWarning(warning); - } - } - - warnAboutCurrentAnthropicThinkingEffort(): void { - try { - if (!this.config.hasProvider) return; - this.warnAboutAnthropicThinkingEffort(this.config.provider, this.config.modelAlias); - } catch { - // A capability warning must never make config replay or session resume fail. + this.publishThinkingEffortWarning(warning); } } @@ -532,7 +620,7 @@ export class Agent { async resume(options?: AgentRecordsReplayOptions): Promise<{ warning?: string }> { const result = await this.records.replay(options); - this.flushPendingAnthropicThinkingEffortWarnings(); + this.flushPendingThinkingEffortWarnings(); try { this.replayBuilder.postRestoring = true; this.goal.normalizeAfterReplay(); diff --git a/packages/agent-core/src/config/model.ts b/packages/agent-core/src/config/model.ts index f65fbf20e1..d2adc9b1a6 100644 --- a/packages/agent-core/src/config/model.ts +++ b/packages/agent-core/src/config/model.ts @@ -16,10 +16,15 @@ export function effectiveModelAlias( if ( overrides?.supportEfforts !== undefined && overrides.defaultEffort === undefined && - effective.defaultEffort !== undefined && - !overrides.supportEfforts.includes(effective.defaultEffort) + effective.defaultEffort !== undefined ) { - delete effective.defaultEffort; + // The inherited default survives when the override list still covers it; + // compare normalized (trimmed, case-insensitive) like the resolvers do. + const declared = effective.defaultEffort.trim().toLowerCase(); + const covered = overrides.supportEfforts.some( + (candidate) => candidate.trim().toLowerCase() === declared, + ); + if (!covered) delete effective.defaultEffort; } // The input cap can never exceed the effective total window (an override diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 17b261aa30..fac7615b97 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -10,7 +10,6 @@ import { MoonshotWebSearchProvider } from '#/tools/providers/moonshot-web-search import { ImageLimits } from '#/tools/support/image-limits'; import type { PromisableMethods } from '#/utils/types'; import { getCoreVersion } from '#/version'; -import { resolveThinkingEffort } from '../agent/config/thinking'; import { Agent } from '../agent'; import { limitAgentReplayByTurns } from '../agent/replay/turns'; import { @@ -341,16 +340,14 @@ export class KimiCore implements PromisableMethods { const sessionConfig = this.withPrintModeDefaults(config); const id = options.id ?? createSessionId(); const modelAlias = options.model ?? config.defaultModel; - const model = modelAlias !== undefined ? config.models?.[modelAlias] : undefined; - // Forward only an explicitly requested effort. With no explicit value the - // initial effort is left to ConfigState.update(), which resolves it from - // the resolved provider — that carries the provider-level protocol context - // a raw model alias lacks (e.g. provider type "anthropic" with a custom - // model name must default to the inferred profile effort, not "off"). - const thinkingEffort = - options.thinking === undefined - ? undefined - : resolveThinkingEffort(options.thinking, config.thinking, model); + // Forward an explicitly requested effort verbatim: ConfigState.update() + // resolves it against the resolved provider — which carries the + // provider-level protocol context a raw model alias lacks (e.g. provider + // type "anthropic" with a custom model name) — and emits the one-time + // fallback warning when the value lands outside the declared effort + // list. With no explicit value the initial effort falls through to the + // model default on the same path. + const thinkingEffort = options.thinking; const permissionMode = options.permission ?? config.defaultPermissionMode; const baseMcpConfig = await resolveSessionMcpConfig({ cwd: workDir, @@ -481,7 +478,7 @@ export class KimiCore implements PromisableMethods { }; const mainAgent = await session.createMain(); mainAgent.config.update({ - modelAlias: options.model ?? config.defaultModel, + modelAlias, thinkingEffort, }); if (permissionMode !== undefined) { diff --git a/packages/agent-core/test/agent/config-state.test.ts b/packages/agent-core/test/agent/config-state.test.ts index b486e0beb3..2ace081922 100644 --- a/packages/agent-core/test/agent/config-state.test.ts +++ b/packages/agent-core/test/agent/config-state.test.ts @@ -152,7 +152,7 @@ describe('ConfigState model capabilities', () => { expect(requestMaxTokens).toBe(131072); }); - it('warns and sends when an Anthropic effort is not listed by the model', async () => { + it('warns and falls back when an Anthropic effort is not listed by the model', async () => { let requests = 0; const config: KimiConfig = { providers: { @@ -176,6 +176,462 @@ describe('ConfigState model capabilities', () => { const ctx = testAgent({ initialConfig: config, providerManager: new ProviderManager({ config }), + generate: async (provider) => { + requests += 1; + expect(provider.thinkingEffort).toBe('max'); + return { + id: 'response-1', + message: { role: 'assistant', content: [], toolCalls: [] }, + usage: emptyUsage(), + finishReason: 'completed', + rawFinishReason: 'stop', + }; + }, + }); + ctx.agent.config.update({ + modelAlias: 'compatible', + systemPrompt: 'system', + }); + ctx.agent.config.setThinkingEffort('high'); + + await ctx.agent.llm.chat({ + messages: [], + tools: [], + signal: new AbortController().signal, + }); + + expect(requests).toBe(1); + expect(ctx.allEvents).toContainEqual({ + type: '[rpc]', + event: 'warning', + args: { + code: 'thinking-effort-not-listed', + message: + 'Thinking effort "high" is not listed for model "compatible-model" (known: max). Falling back to the model\'s default effort "max".', + }, + }); + }); + + it('warns when a Kimi env effort override is not listed by the model', async () => { + // A Kimi provider routed through the Anthropic protocol still honors + // KIMI_MODEL_THINKING_EFFORT; the override is an explicit pin applied + // after resolution, so an unlisted value is sent unchanged — with a + // one-time warning instead of a fallback. + vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'high'); + try { + let requests = 0; + const config: KimiConfig = { + providers: { + compatible: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test', + }, + }, + models: { + compatible: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['max'], + }, + }, + }; + const ctx = testAgent({ + initialConfig: config, + providerManager: new ProviderManager({ config }), + generate: async (provider) => { + requests += 1; + expect(provider.thinkingEffort).toBe('high'); + return { + id: 'response-1', + message: { role: 'assistant', content: [], toolCalls: [] }, + usage: emptyUsage(), + finishReason: 'completed', + rawFinishReason: 'stop', + }; + }, + }); + ctx.agent.config.update({ + modelAlias: 'compatible', + systemPrompt: 'system', + }); + + await ctx.agent.llm.chat({ + messages: [], + tools: [], + signal: new AbortController().signal, + }); + + expect(requests).toBe(1); + // The apply-time override warning is the single diagnostic for the + // pinned value — the request-time stale check must not repeat it. + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([ + { + type: '[rpc]', + event: 'warning', + args: { + code: 'thinking-effort-override-not-listed', + message: + 'Thinking effort "high" is not listed for model "compatible-model" (known: max). The value will be sent unchanged to the backend.', + }, + }, + ]); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('does not warn when the env effort override is listed by the model', () => { + vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'max'); + try { + const config: KimiConfig = { + providers: { + compatible: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test', + }, + }, + models: { + compatible: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['max'], + }, + }, + }; + const ctx = testAgent({ + initialConfig: config, + providerManager: new ProviderManager({ config }), + }); + + ctx.agent.config.update({ + modelAlias: 'compatible', + systemPrompt: 'system', + }); + + expect(ctx.agent.config.thinkingEffort).toBe('max'); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([]); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('does not warn when the effort matches a padded declared list entry', async () => { + let requests = 0; + const config: KimiConfig = { + providers: { + compatible: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test', + }, + }, + models: { + compatible: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: [' high ', ' max '], + }, + }, + }; + const ctx = testAgent({ + initialConfig: config, + providerManager: new ProviderManager({ config }), + generate: async (provider) => { + requests += 1; + expect(provider.thinkingEffort).toBe('high'); + return { + id: 'response-1', + message: { role: 'assistant', content: [], toolCalls: [] }, + usage: emptyUsage(), + finishReason: 'completed', + rawFinishReason: 'stop', + }; + }, + }); + ctx.agent.config.update({ + modelAlias: 'compatible', + systemPrompt: 'system', + }); + ctx.agent.config.setThinkingEffort('high'); + + await ctx.agent.llm.chat({ + messages: [], + tools: [], + signal: new AbortController().signal, + }); + + expect(requests).toBe(1); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([]); + }); + + it('does not warn when the env override matches a padded declared list entry', () => { + vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'low'); + try { + const config: KimiConfig = { + providers: { + compatible: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test', + }, + }, + models: { + compatible: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: [' low ', ' high '], + }, + }, + }; + const ctx = testAgent({ + initialConfig: config, + providerManager: new ProviderManager({ config }), + }); + + ctx.agent.config.update({ + modelAlias: 'compatible', + systemPrompt: 'system', + }); + + expect(ctx.agent.config.thinkingEffort).toBe('low'); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([]); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('does not warn when the env override matches a mixed-case declared entry', () => { + vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'high'); + try { + const config: KimiConfig = { + providers: { + compatible: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test', + }, + }, + models: { + compatible: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: [' High '], + }, + }, + }; + const ctx = testAgent({ + initialConfig: config, + providerManager: new ProviderManager({ config }), + }); + + ctx.agent.config.update({ + modelAlias: 'compatible', + systemPrompt: 'system', + }); + + expect(ctx.agent.config.thinkingEffort).toBe('high'); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([]); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('does not warn when a reloaded list matches the current effort case-insensitively', async () => { + const compatibleModel = (supportEfforts: string[]) => ({ + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic' as const, + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts, + }); + let current: KimiConfig = { + providers: { + compatible: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test', + }, + }, + models: { compatible: compatibleModel(['high']) }, + }; + let requests = 0; + const ctx = testAgent({ + initialConfig: current, + providerManager: new ProviderManager({ config: () => current }), + generate: async (provider) => { + requests += 1; + expect(provider.thinkingEffort).toBe('high'); + return { + id: 'response-1', + message: { role: 'assistant', content: [], toolCalls: [] }, + usage: emptyUsage(), + finishReason: 'completed', + rawFinishReason: 'stop', + }; + }, + }); + ctx.agent.config.update({ + modelAlias: 'compatible', + systemPrompt: 'system', + }); + ctx.agent.config.setThinkingEffort('high'); + + current = { + ...current, + models: { compatible: compatibleModel([' High ']) }, + }; + + await ctx.agent.llm.chat({ + messages: [], + tools: [], + signal: new AbortController().signal, + }); + + expect(requests).toBe(1); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([]); + }); + + it('suppresses the fallback warning when the env override decides the final effort', () => { + // The configured "high" is unlisted and would fall back to "xhigh", but + // the env pin "low" decides what actually goes on the wire — warning + // about a fallback to "xhigh" would be a lie, and "low" is listed, so no + // warning fires at all. + vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'low'); + try { + const config: KimiConfig = { + providers: { + compatible: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test', + }, + }, + models: { + compatible: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'xhigh'], + defaultEffort: 'xhigh', + }, + }, + thinking: { effort: 'high' }, + }; + const ctx = testAgent({ + initialConfig: config, + providerManager: new ProviderManager({ config }), + }); + + ctx.agent.config.update({ + modelAlias: 'compatible', + systemPrompt: 'system', + }); + + expect(ctx.agent.config.thinkingEffort).toBe('low'); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([]); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('warns only about the override when both the configured effort and the override are unlisted', () => { + vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'extreme'); + try { + const config: KimiConfig = { + providers: { + compatible: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test', + }, + }, + models: { + compatible: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'xhigh'], + defaultEffort: 'xhigh', + }, + }, + thinking: { effort: 'high' }, + }; + const ctx = testAgent({ + initialConfig: config, + providerManager: new ProviderManager({ config }), + }); + + ctx.agent.config.update({ + modelAlias: 'compatible', + systemPrompt: 'system', + }); + + expect(ctx.agent.config.thinkingEffort).toBe('extreme'); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([ + { + type: '[rpc]', + event: 'warning', + args: { + code: 'thinking-effort-override-not-listed', + message: + 'Thinking effort "extreme" is not listed for model "compatible-model" (known: low, xhigh). The value will be sent unchanged to the backend.', + }, + }, + ]); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('warns on the next request when a config reload drops the current effort from the list', async () => { + // The session resolved "high" while the model declared ["high", "max"]; + // a reload then narrows the list to ["max"]. Live sessions never + // re-resolve mid-flight, so the stale value keeps going out — the + // request-time check is the diagnostic for that drift. + const compatibleModel = (supportEfforts: string[]) => ({ + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic' as const, + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts, + }); + let current: KimiConfig = { + providers: { + compatible: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test', + }, + }, + models: { compatible: compatibleModel(['high', 'max']) }, + }; + let requests = 0; + const ctx = testAgent({ + initialConfig: current, + providerManager: new ProviderManager({ config: () => current }), generate: async (provider) => { requests += 1; expect(provider.thinkingEffort).toBe('high'); @@ -201,17 +657,176 @@ describe('ConfigState model capabilities', () => { }); expect(requests).toBe(1); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([]); + + current = { + ...current, + models: { compatible: compatibleModel(['max']) }, + }; + + await ctx.agent.llm.chat({ + messages: [], + tools: [], + signal: new AbortController().signal, + }); + + expect(requests).toBe(2); expect(ctx.allEvents).toContainEqual({ type: '[rpc]', event: 'warning', args: { - code: 'anthropic-thinking-effort-not-listed', + code: 'thinking-effort-not-listed', message: - 'Thinking effort "high" is not listed for model "compatible-model" (known: max). The configured value will be sent unchanged to the Anthropic-compatible backend.', + 'Thinking effort "high" is not listed for model "compatible-model" (known: max). The value will be sent unchanged to the backend.', }, }); }); + it('warns once when a reload drops the env-pinned effort from the declared list', async () => { + // The pin was listed when applied (no apply-time warning); the reload + // narrows the list to ["max"], so the next request must surface the + // override diagnostic against the new list — exactly once. + vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'high'); + try { + const compatibleModel = (supportEfforts: string[]) => ({ + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic' as const, + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts, + }); + let current: KimiConfig = { + providers: { + compatible: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test', + }, + }, + models: { compatible: compatibleModel(['high', 'max']) }, + }; + let requests = 0; + const ctx = testAgent({ + initialConfig: current, + providerManager: new ProviderManager({ config: () => current }), + generate: async (provider) => { + requests += 1; + expect(provider.thinkingEffort).toBe('high'); + return { + id: 'response-1', + message: { role: 'assistant', content: [], toolCalls: [] }, + usage: emptyUsage(), + finishReason: 'completed', + rawFinishReason: 'stop', + }; + }, + }); + ctx.agent.config.update({ + modelAlias: 'compatible', + systemPrompt: 'system', + }); + + await ctx.agent.llm.chat({ + messages: [], + tools: [], + signal: new AbortController().signal, + }); + + expect(requests).toBe(1); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([]); + + current = { + ...current, + models: { compatible: compatibleModel(['max']) }, + }; + + await ctx.agent.llm.chat({ + messages: [], + tools: [], + signal: new AbortController().signal, + }); + + expect(requests).toBe(2); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([ + { + type: '[rpc]', + event: 'warning', + args: { + code: 'thinking-effort-override-not-listed', + message: + 'Thinking effort "high" is not listed for model "compatible-model" (known: max). The value will be sent unchanged to the backend.', + }, + }, + ]); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('stays silent when the env-pinned effort survives a config reload', async () => { + vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'high'); + try { + const compatibleModel = (supportEfforts: string[]) => ({ + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic' as const, + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts, + }); + let current: KimiConfig = { + providers: { + compatible: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test', + }, + }, + models: { compatible: compatibleModel(['high', 'max']) }, + }; + const ctx = testAgent({ + initialConfig: current, + providerManager: new ProviderManager({ config: () => current }), + generate: async (provider) => { + expect(provider.thinkingEffort).toBe('high'); + return { + id: 'response-1', + message: { role: 'assistant', content: [], toolCalls: [] }, + usage: emptyUsage(), + finishReason: 'completed', + rawFinishReason: 'stop', + }; + }, + }); + ctx.agent.config.update({ + modelAlias: 'compatible', + systemPrompt: 'system', + }); + + await ctx.agent.llm.chat({ + messages: [], + tools: [], + signal: new AbortController().signal, + }); + + current = { + ...current, + models: { compatible: compatibleModel(['low', 'high']) }, + }; + + await ctx.agent.llm.chat({ + messages: [], + tools: [], + signal: new AbortController().signal, + }); + + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([]); + } finally { + vi.unstubAllEnvs(); + } + }); + it('uses session id as a provider prompt cache hint without storing it on Agent', () => { const ctx = testAgent({ providerManager: new ProviderManager({ diff --git a/packages/agent-core/test/agent/config/thinking.test.ts b/packages/agent-core/test/agent/config/thinking.test.ts index bb08bf86e6..09a1e08620 100644 --- a/packages/agent-core/test/agent/config/thinking.test.ts +++ b/packages/agent-core/test/agent/config/thinking.test.ts @@ -4,6 +4,7 @@ import type { ModelAlias } from '../../../src/config'; import { defaultThinkingEffortFor, resolveThinkingEffort, + resolveThinkingEffortWithFallback, supportsThinkingEffort, } from '../../../src/agent/config/thinking'; @@ -156,7 +157,7 @@ describe('resolveThinkingEffort', () => { it('normalizes the requested effort (case/whitespace) on every wire', () => { expect(resolveThinkingEffort(' OFF ', undefined, effortModel, false)).toBe('off'); - expect(resolveThinkingEffort(' Max ', undefined, effortModel, false)).toBe('max'); + expect(resolveThinkingEffort(' High ', undefined, effortModel, false)).toBe('high'); expect(resolveThinkingEffort(' ', undefined, effortModel, false)).toBe('medium'); }); @@ -185,6 +186,130 @@ describe('resolveThinkingEffort', () => { expect(resolveThinkingEffort('ultra', undefined, effortModel, true)).toBe('medium'); }); + it('falls back to the model default for an unlisted effort on any protocol', () => { + // A declared supportEfforts list is authoritative on every wire: an + // unlisted configured or requested effort resolves to the model default + // instead of being sent upstream as-is. + const declared = model({ + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'xhigh'], + defaultEffort: 'xhigh', + }); + expect(resolveThinkingEffort(undefined, { effort: 'high' }, declared, false)).toBe('xhigh'); + expect(resolveThinkingEffort('high', undefined, declared, false)).toBe('xhigh'); + expect(resolveThinkingEffort('medium', undefined, declared, false)).toBe('medium'); + expect(resolveThinkingEffort('xhigh', undefined, declared, false)).toBe('xhigh'); + // no declared defaultEffort -> middle entry of the list. + expect(resolveThinkingEffort('ultra', undefined, effortModel, false)).toBe('medium'); + }); + + it('passes concrete efforts through unchanged when no effort list is declared', () => { + expect(resolveThinkingEffort('ultra', undefined, booleanModel, false)).toBe('ultra'); + expect(resolveThinkingEffort(undefined, { effort: 'ultra' }, booleanModel, false)).toBe( + 'ultra', + ); + }); + + it('ignores whitespace-only supportEfforts entries on every protocol', () => { + // A whitespace-only entry is not a real effort: the list must not count as + // declared, and a trimmed declared list must match against trimmed values. + const blankOnly = model({ capabilities: ['thinking'], supportEfforts: [' '] }); + expect(resolveThinkingEffort('ultra', undefined, blankOnly, false)).toBe('ultra'); + expect(resolveThinkingEffort('ultra', undefined, blankOnly, true)).toBe('on'); + const padded = model({ + capabilities: ['thinking'], + supportEfforts: [' low ', ' xhigh '], + defaultEffort: 'xhigh', + }); + expect(resolveThinkingEffort('high', undefined, padded, false)).toBe('xhigh'); + expect(resolveThinkingEffort('low', undefined, padded, false)).toBe('low'); + }); + + it('falls back to the declared default when the model omits the thinking capability', () => { + // A model may declare support_efforts/default_effort without declaring + // the thinking capability; the declared list is still authoritative for + // the fallback — the unlisted value must not silently become 'off'. + const declared = model({ + supportEfforts: ['low', 'medium', 'xhigh'], + defaultEffort: 'xhigh', + }); + expect(resolveThinkingEffort(undefined, { effort: 'high' }, declared, false)).toBe('xhigh'); + expect(resolveThinkingEffort('high', undefined, declared, false)).toBe('xhigh'); + const withFallback = resolveThinkingEffortWithFallback('high', undefined, declared, false); + expect(withFallback.effort).toBe('xhigh'); + expect(withFallback.fallback).toEqual({ configured: 'high', resolved: 'xhigh' }); + // no declared defaultEffort -> middle entry of the list. + expect( + resolveThinkingEffort( + 'high', + undefined, + model({ supportEfforts: ['low', 'medium', 'xhigh'] }), + false, + ), + ).toBe('medium'); + }); + + it('treats a declared effort list as thinking support on the Kimi wire', () => { + // support_efforts without the thinking capability: list membership takes + // precedence over the capability gate on the strict path too. + const declared = model({ + supportEfforts: ['low', 'medium', 'xhigh'], + defaultEffort: 'xhigh', + }); + expect(resolveThinkingEffort(undefined, { effort: 'high' }, declared, true)).toBe('xhigh'); + expect(resolveThinkingEffort('high', undefined, declared, true)).toBe('xhigh'); + expect(resolveThinkingEffort('on', undefined, declared, true)).toBe('xhigh'); + expect(resolveThinkingEffort('medium', undefined, declared, true)).toBe('medium'); + expect(supportsThinkingEffort('low', declared, true)).toBe(true); + expect(supportsThinkingEffort('bogus', declared, true)).toBe(false); + expect(resolveThinkingEffortWithFallback('high', undefined, declared, true)).toEqual({ + effort: 'xhigh', + fallback: { configured: 'high', resolved: 'xhigh' }, + }); + }); + + it('resolves the declared default when nothing is configured and the capability is omitted', () => { + const declared = model({ + supportEfforts: ['low', 'medium', 'xhigh'], + defaultEffort: 'xhigh', + }); + expect(defaultThinkingEffortFor(declared)).toBe('xhigh'); + expect(resolveThinkingEffort(undefined, undefined, declared, false)).toBe('xhigh'); + expect(resolveThinkingEffort(undefined, undefined, declared, true)).toBe('xhigh'); + }); + + it('trims a padded default_effort before matching the declared list', () => { + const declared = model({ + supportEfforts: [' low ', ' medium ', ' xhigh '], + defaultEffort: ' xhigh ', + }); + expect(defaultThinkingEffortFor(declared)).toBe('xhigh'); + expect(resolveThinkingEffort('high', undefined, declared, false)).toBe('xhigh'); + }); + + it('resolves the inherited default when a padded override list covers it', () => { + const declared = model({ + capabilities: ['thinking'], + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'max', + overrides: { supportEfforts: [' max ', 'high'] }, + }); + expect(resolveThinkingEffort(undefined, undefined, declared)).toBe('max'); + }); + + it('matches declared efforts case-insensitively and resolves the declared casing', () => { + const declared = model({ + capabilities: ['thinking'], + supportEfforts: ['Low', 'High', 'Max'], + defaultEffort: 'max', + }); + expect(resolveThinkingEffort('low', undefined, declared, true)).toBe('Low'); + expect(resolveThinkingEffort(undefined, { effort: 'high' }, declared, false)).toBe('High'); + expect(defaultThinkingEffortFor(declared)).toBe('Max'); + expect(supportsThinkingEffort('low', declared, true)).toBe(true); + expect(resolveThinkingEffort('ultra', undefined, declared, false)).toBe('Max'); + }); + it('projects a concrete effort to on for a boolean-only Kimi model', () => { expect(resolveThinkingEffort('ultra', undefined, booleanModel, true)).toBe('on'); }); diff --git a/packages/agent-core/test/config/model-overrides.test.ts b/packages/agent-core/test/config/model-overrides.test.ts index 1af415067a..762daf8aaf 100644 --- a/packages/agent-core/test/config/model-overrides.test.ts +++ b/packages/agent-core/test/config/model-overrides.test.ts @@ -64,6 +64,15 @@ describe('effectiveModelAlias', () => { expect(effectiveModelAlias(model).defaultEffort).toBeUndefined(); }); + it('keeps an inherited defaultEffort covered by the override list after normalization', () => { + expect(effectiveModelAlias(alias({ supportEfforts: [' max ', 'high'] })).defaultEffort).toBe( + 'max', + ); + expect(effectiveModelAlias(alias({ supportEfforts: [' MAX ', 'high'] })).defaultEffort).toBe( + 'max', + ); + }); + it('keeps an explicit defaultEffort override when it is valid', () => { const model = alias({ supportEfforts: ['low', 'high'], defaultEffort: 'high' }); diff --git a/packages/agent-core/test/harness/model-alias-session.test.ts b/packages/agent-core/test/harness/model-alias-session.test.ts index 35d914a38b..f17bae4998 100644 --- a/packages/agent-core/test/harness/model-alias-session.test.ts +++ b/packages/agent-core/test/harness/model-alias-session.test.ts @@ -176,6 +176,29 @@ max_context_size = 200000 expect(config.thinkingEffort).toBe('low'); }); + it('warns once and falls back when createSession requests an unlisted effort', async () => { + await writeFile(configPath, compatibleConfig('"xhigh"', 'xhigh')); + const events: Array[0]> = []; + const rpc = await createTestRpc({ emitEvent: (event) => events.push(event) }); + + const created = await rpc.createSession({ + workDir, + model: 'compatible/model', + thinking: 'high', + }); + + const config = await rpc.getConfig({ sessionId: created.id, agentId: 'main' }); + expect(config.thinkingEffort).toBe('xhigh'); + expect(events).toContainEqual({ + sessionId: created.id, + agentId: 'main', + type: 'warning', + code: 'thinking-effort-not-listed', + message: + 'Thinking effort "high" is not listed for model "compatible-model" (known: xhigh). Falling back to the model\'s default effort "xhigh".', + }); + }); + it('restores the final effort after replaying an earlier unlisted Anthropic effort', async () => { const sessionId = await createEffortReplaySession(); @@ -190,9 +213,9 @@ max_context_size = 200000 sessionId, agentId: 'main', type: 'warning', - code: 'anthropic-thinking-effort-not-listed', + code: 'thinking-effort-not-listed', message: - 'Thinking effort "high" is not listed for model "compatible-model" (known: max). The configured value will be sent unchanged to the Anthropic-compatible backend.', + 'Thinking effort "high" is not listed for model "compatible-model" (known: max). Falling back to the model\'s default effort "max".', }); const restored = await freshRpc.getConfig({ sessionId, agentId: 'main' }); expect(restored.modelAlias).toBe('compatible/model'); diff --git a/packages/kosong/src/errors.ts b/packages/kosong/src/errors.ts index 467a6301f3..68122f2073 100644 --- a/packages/kosong/src/errors.ts +++ b/packages/kosong/src/errors.ts @@ -412,7 +412,7 @@ function appendThinkingEffortConfigHint(statusCode: number, message: string): st if (message.includes(THINKING_EFFORT_CONFIG_DOCS_URL)) return message; return `${message} -The provider rejected the configured thinking effort. Non-Kimi providers receive effort strings without client-side mapping; choose an effort supported by the selected model. For Kimi models, check support_efforts and default_effort. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`; +The provider rejected the configured thinking effort. Efforts outside a model's declared support_efforts fall back to the model default, except a forced effort (forced_effort or KIMI_MODEL_THINKING_EFFORT) or an effort a running session locked in before a config reload, which are always sent unchanged; models without a declared list pass efforts to non-Kimi providers unchanged. Choose an effort supported by the selected model. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`; } export function isContextOverflowErrorCode(code: string | null | undefined): boolean { diff --git a/packages/kosong/test/openai-common-errors.test.ts b/packages/kosong/test/openai-common-errors.test.ts index 0b7b7ca898..5798719b8a 100644 --- a/packages/kosong/test/openai-common-errors.test.ts +++ b/packages/kosong/test/openai-common-errors.test.ts @@ -405,7 +405,10 @@ describe('normalizeAPIStatusError thinking effort guidance', () => { it('adds configuration guidance when a provider rejects reasoning_effort', () => { const error = normalizeAPIStatusError(400, 'Invalid reasoning_effort: xhigh'); - expect(error.message).toContain('Non-Kimi providers receive effort strings'); + expect(error.message).toContain( + "Efforts outside a model's declared support_efforts fall back to the model default", + ); + expect(error.message).toContain('locked in before a config reload'); expect(error.message).toContain( 'https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#thinking', );