diff --git a/.changeset/wise-otters-think.md b/.changeset/wise-otters-think.md new file mode 100644 index 0000000000..2ece900b63 --- /dev/null +++ b/.changeset/wise-otters-think.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix Thinking effort routing so non-Kimi providers preserve configured values for upstream validation, while Kimi models validate runtime selections, fall back safely during model resolution, and synchronize the effective effort back to clients. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 9d95974193..2d86fa180c 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -403,7 +403,8 @@ async function performModelSwitch( const modelChanged = alias !== prevModel; const effortChanged = effort !== prevEffort; const runtimeChanged = modelChanged || effortChanged; - const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]); + let effectiveAlias = alias; + let effectiveEffort = effort; const session = host.session; try { @@ -416,6 +417,9 @@ async function performModelSwitch( if (effort !== prevEffort) { await session.setThinking(effort); } + const status = await session.getStatus(); + effectiveAlias = status.model ?? alias; + effectiveEffort = status.thinkingEffort; } } catch (error) { const msg = formatErrorMessage(error); @@ -423,15 +427,25 @@ async function performModelSwitch( return; } - host.setAppState({ model: alias, thinkingEffort: effort }); + if (session === undefined) { + effectiveAlias = host.state.appState.model; + effectiveEffort = host.state.appState.thinkingEffort; + } + const effectiveModelChanged = effectiveAlias !== prevModel; + const effectiveEffortChanged = effectiveEffort !== prevEffort; + const displayName = modelDisplayName( + effectiveAlias, + host.state.appState.availableModels[effectiveAlias], + ); + host.setAppState({ model: effectiveAlias, thinkingEffort: effectiveEffort }); if (session === undefined && runtimeChanged) { - if (alias !== prevModel) { - host.track('model_switch', { model: alias }); + if (effectiveModelChanged) { + host.track('model_switch', { model: effectiveAlias }); } - if (effort !== prevEffort) { + if (effectiveEffortChanged) { host.track('thinking_toggle', { - enabled: effort !== 'off', - effort, + enabled: effectiveEffort !== 'off', + effort: effectiveEffort, from: prevEffort, }); } @@ -440,7 +454,7 @@ async function performModelSwitch( let persisted = false; if (persist) { try { - persisted = await persistModelSelection(host, alias, effort); + persisted = await persistModelSelection(host, effectiveAlias, effectiveEffort); } catch (error) { const msg = formatErrorMessage(error); host.showError(`Switched to ${displayName}, but failed to save default: ${msg}`); @@ -449,18 +463,18 @@ async function performModelSwitch( } let status: string; - if (modelChanged) { + if (effectiveModelChanged) { status = persist - ? `Switched to ${displayName} with thinking ${effort}.` - : `Switched to ${displayName} with thinking ${effort} for this session only.`; - } else if (effortChanged) { + ? `Switched to ${displayName} with thinking ${effectiveEffort}.` + : `Switched to ${displayName} with thinking ${effectiveEffort} for this session only.`; + } else if (effectiveEffortChanged) { status = persist - ? `Thinking set to ${effort}.` - : `Thinking set to ${effort} for this session only.`; + ? `Thinking set to ${effectiveEffort}.` + : `Thinking set to ${effectiveEffort} for this session only.`; } else if (persist && persisted) { - status = `Saved ${displayName} with thinking ${effort} as default.`; + status = `Saved ${displayName} with thinking ${effectiveEffort} as default.`; } else { - status = `Already using ${displayName} with thinking ${effort}.`; + status = `Already using ${displayName} with thinking ${effectiveEffort}.`; } host.showStatus(status, 'success'); } diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 626116f957..82257c86cf 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -615,6 +615,7 @@ export class SessionEventHandler { patch.permissionMode = event.permission; } if (event.model !== undefined) patch.model = event.model; + if (event.thinkingEffort !== undefined) patch.thinkingEffort = event.thinkingEffort; if (Object.keys(patch).length > 0) this.host.setAppState(patch); if (event.swarmMode === false) { this.host.state.swarmModeEntry = undefined; 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 eb4a385707..f285be77dd 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 @@ -139,6 +139,8 @@ function makeStartupInput(): KimiTUIStartupInput { } function makeSession(overrides: Record = {}) { + let model = 'k2'; + let thinkingEffort = 'off'; return { id: 'ses-1', model: 'k2', @@ -151,8 +153,8 @@ function makeSession(overrides: Record = {}) { cancel: vi.fn(async () => {}), cancelCompaction: vi.fn(async () => {}), getStatus: vi.fn(async () => ({ - model: 'k2', - thinkingEffort: 'off', + model, + thinkingEffort, permission: 'manual', planMode: false, contextTokens: 0, @@ -162,8 +164,12 @@ function makeSession(overrides: Record = {}) { getGoal: vi.fn(async () => ({ goal: null })), setApprovalHandler: vi.fn(), setQuestionHandler: vi.fn(), - setModel: vi.fn(async () => {}), - setThinking: vi.fn(async () => {}), + setModel: vi.fn(async (alias: string) => { + model = alias; + }), + setThinking: vi.fn(async (effort: string) => { + thinkingEffort = effort; + }), setPermission: vi.fn(async () => {}), setPlanMode: vi.fn(async () => {}), setSwarmMode: vi.fn(async () => {}), @@ -2987,6 +2993,24 @@ command = "vim" expect(stripSgr(renderTranscript(driver))).toContain('LLM not set'); }); + it('applies the effective thinking effort from status updates', async () => { + const { driver } = await makeDriver(); + + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + model: 'turbo', + thinkingEffort: 'mid', + } as Event, + vi.fn(), + ); + + expect(driver.state.appState.model).toBe('turbo'); + expect(driver.state.appState.thinkingEffort).toBe('mid'); + }); + it('renders swarm mode markers from /swarm commands, not tool-triggered status updates', async () => { const { driver } = await makeDriver(); @@ -4588,6 +4612,67 @@ command = "vim" expect(driver.state.appState.thinkingEffort).toBe('on'); }); + it('uses the effective effort returned after a model-switch fallback', async () => { + let switched = false; + const session = makeSession({ + getStatus: vi.fn(async () => ({ + model: switched ? 'turbo' : 'k2', + thinkingEffort: switched ? 'mid' : 'ultra', + permission: 'manual', + planMode: false, + contextTokens: 0, + maxContextTokens: 100, + contextUsage: 0, + })), + setModel: vi.fn(async () => { + switched = true; + }), + }); + const setConfig = vi.fn(async () => ({ providers: {} })); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + models: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 100, + capabilities: ['thinking'], + supportEfforts: ['low', 'high', 'ultra'], + defaultEffort: 'ultra', + }, + turbo: { + provider: 'managed:kimi-code', + model: 'kimi-turbo', + maxContextSize: 100, + capabilities: ['thinking'], + supportEfforts: ['low', 'mid', 'high'], + defaultEffort: 'mid', + }, + }, + defaultModel: 'k2', + thinking: { enabled: true, effort: 'ultra' }, + })), + setConfig, + }); + + driver.handleUserInput('/model turbo'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); + }); + (driver.state.editorContainer.children[0] as TabbedModelSelectorComponent).handleInput('\r'); + + await vi.waitFor(() => { + expect(setConfig).toHaveBeenCalledWith({ + defaultModel: 'turbo', + thinking: { enabled: true, effort: 'mid' }, + }); + }); + expect(driver.state.appState.model).toBe('turbo'); + expect(driver.state.appState.thinkingEffort).toBe('mid'); + expect(renderTranscript(driver)).toContain('Switched to kimi-turbo with thinking mid.'); + }); + it('persists /model selection even when runtime state is unchanged', async () => { const session = makeSession(); const setConfig = vi.fn(async () => ({ providers: {} })); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 41997ca219..58199d5d7f 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -140,7 +140,7 @@ Each entry in the `models` table defines a model alias (the name used in `defaul | `max_context_size` | `integer` | Yes | Maximum context length in tokens; must be at least 1 | | `max_output_size` | `integer` | No | Per-request output token cap (maps to `max_tokens`). Currently only the `anthropic` provider honors it. When set for a Claude model, this explicit value overrides the built-in server-side maximum | | `capabilities` | `array` | No | Capability tags to add explicitly: `thinking`, `always_thinking`, `image_in`, `video_in`, `audio_in`, `tool_use`. Unioned with the capabilities auto-detected by the provider — entries can only be added, never removed | -| `support_efforts` | `array` | No | Thinking effort levels declared by the model catalog. Managed and open-platform refreshes may rewrite this field; to pin it manually, set `[models."".overrides] support_efforts` instead | +| `support_efforts` | `array` | No | Thinking effort levels the model accepts. For `kimi`, selecting another value at runtime fails; when model resolution carries an unsupported configured or previous value, the session falls back to the target model's `default_effort` and reports that effective value to the UI. A Thinking-capable Kimi model without this field uses boolean `on` / `off`. Other providers pass concrete values unchanged when their protocol has a native effort field; protocols that expose only levels or token budgets perform the required format conversion. Managed and open-platform refreshes may rewrite this field; to pin it manually, set `[models."".overrides] support_efforts` instead | | `default_effort` | `string` | No | Default thinking effort for the model. Managed and open-platform refreshes may rewrite this field; to pin it manually, set `[models."".overrides] default_effort` instead | | `display_name` | `string` | No | Name shown in the UI; falls back to `model` when unset | | `reasoning_key` | `string` | No | `openai` provider only. Override the field name used for reasoning content when the gateway returns it under a non-standard name; by default `reasoning_content`, `reasoning_details`, and `reasoning` are auto-detected | @@ -181,7 +181,7 @@ You can also switch models temporarily without touching the config file — by s | Field | Type | Default | Description | | --- | --- | --- | --- | | `enabled` | `boolean` | `true` | Whether Thinking is enabled by default for new sessions; set to `false` to force Thinking off | -| `effort` | `string` | — | Thinking effort level (for example `low`, `medium`, `high`, `xhigh`, `max`); the levels actually available depend on the model's declared `support_efforts`, and unrecognized values are ignored by the provider | +| `effort` | `string` | — | Thinking effort level (for example `low`, `medium`, `high`, `xhigh`, `max`). Non-Kimi providers do not remap concrete effort values when the upstream protocol accepts them; if the provider rejects the value, choose one that the model supports. Protocols that expose only levels or token budgets still require format conversion. Kimi models with `support_efforts` fall back to their model default when this configured value is not listed; Kimi models without that list treat every enabled value as boolean `on` | | `keep` | `string` | `"all"` | Preserved Thinking passthrough. On `kimi` it is sent as `thinking.keep`; on `anthropic` (Claude and Kimi's Anthropic-compatible mode) it is sent as a `context_management` `clear_thinking_20251015` edit (enabling keep routes Anthropic requests to the beta Messages API; an off-value disables keep and returns to the standard endpoint). `"all"` preserves prior turns' reasoning (`reasoning_content` / Anthropic thinking blocks); set to an off-value (`false`/`0`/`no`/`off`/`none`/`null`) to disable. Overridden by `KIMI_MODEL_THINKING_KEEP`; only injected while Thinking is on | ### Deprecated fields diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 9f61effd47..574bff2e2c 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -140,7 +140,7 @@ KIMI_BASE_URL = "https://api.moonshot.ai/v1" | `max_context_size` | `integer` | 是 | 最大上下文长度(token 数),必须 ≥ 1 | | `max_output_size` | `integer` | 否 | 单次请求的输出 token 上限(对应 `max_tokens`)。目前仅 `anthropic` 供应商读取。为 Claude 模型设置后,这个显式值会覆盖内置的服务端最大值 | | `capabilities` | `array` | 否 | 显式追加的能力标签:`thinking`、`always_thinking`、`image_in`、`video_in`、`audio_in`、`tool_use`。与供应商自动识别的能力取并集,只能追加不能移除 | -| `support_efforts` | `array` | 否 | 模型目录声明的 Thinking 档位。managed 和 open-platform 刷新可能会改写该字段;如需手动固定,请改用 `[models."".overrides] support_efforts` | +| `support_efforts` | `array` | 否 | 模型接受的 Thinking 档位。对 `kimi` 而言,在运行时选择列表外的值会报错;模型解析时若配置值或之前的值不受目标模型支持,会回落到目标模型的 `default_effort`,并将该有效值同步给 UI。支持 Thinking 但没有此字段的 Kimi 模型使用布尔 `on` / `off`。其他 provider 在协议提供原生 effort 字段时会原样传递具体值;协议仅提供等级或 token budget 时,只做必要的格式转换。managed 和 open-platform 刷新可能会改写该字段;如需手动固定,请改用 `[models."".overrides] support_efforts` | | `default_effort` | `string` | 否 | 模型的默认 Thinking 档位。managed 和 open-platform 刷新可能会改写该字段;如需手动固定,请改用 `[models."".overrides] default_effort` | | `display_name` | `string` | 否 | UI 中显示的名称,未设时回退到 `model` | | `reasoning_key` | `string` | 否 | 仅 `openai` 供应商。当网关用非标准字段名返回推理内容时才需要设置;默认自动识别 `reasoning_content` / `reasoning_details` / `reasoning` | @@ -181,7 +181,7 @@ display_name = "Kimi for Coding (custom)" | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `enabled` | `boolean` | `true` | 新会话是否默认开启 Thinking,设为 `false` 可强制关闭 | -| `effort` | `string` | — | Thinking 强度(例如 `low`、`medium`、`high`、`xhigh`、`max`),实际可用等级取决于模型声明的 `support_efforts`,未识别的值会被供应商忽略 | +| `effort` | `string` | — | Thinking 强度(例如 `low`、`medium`、`high`、`xhigh`、`max`)。非 Kimi provider 在上游协议接受具体 effort 值时不会改写该值;如果上游拒绝,请改成该模型支持的档位。协议仅提供等级或 token budget 时,仍需做格式转换。对于带 `support_efforts` 的 Kimi 模型,若该配置值不在列表中,会回落到模型默认档位;没有该列表的 Kimi 模型会把任意开启值视为布尔 `on` | | `keep` | `string` | `"all"` | 保留思考透传。在 `kimi` 上以 `thinking.keep` 发送;在 `anthropic`(Claude 以及 Kimi 的 Anthropic 兼容模式)上以 `context_management` 的 `clear_thinking_20251015` 编辑发送(开启 keep 会让 Anthropic 请求走 beta Messages API;关值可禁用 keep 并回到标准端点)。`"all"` 会保留历史轮次的思考内容(`reasoning_content` / Anthropic thinking blocks);传入关值(`false`/`0`/`no`/`off`/`none`/`null`)可禁用。可被 `KIMI_MODEL_THINKING_KEEP` 覆盖;仅在 Thinking 开启时注入 | ### 已废弃字段 diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index aa7073aa46..3705f20d64 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -492,6 +492,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull const originalHistory = [...this.context.get()]; const tokensBefore = estimateTokensForMessages(originalHistory); let retryCount = 0; + let thinkingEffort = this.profile.data().thinkingLevel; try { const signal = active.abortController.signal; @@ -500,6 +501,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull await this.hooks.onWillCompact.run(active); const resolvedModel = this.profile.resolveModelContext(); + thinkingEffort = resolvedModel.thinkingLevel; const maxContextTokens = resolvedModel.modelCapabilities.max_context_tokens; const defaultCompactionCap = maxContextTokens > 0 @@ -615,7 +617,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull dropped_count: result.droppedCount, retry_count: retryCount, round: 1, - thinking_effort: this.profile.data().thinkingLevel, + thinking_effort: thinkingEffort, ...usageTelemetry(attempt.usage), }; this.telemetry.track2('compaction_finished', properties); @@ -628,7 +630,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull duration_ms: Date.now() - startedAt, round: 1, retry_count: retryCount, - thinking_effort: this.profile.data().thinkingLevel, + thinking_effort: thinkingEffort, error_type: error instanceof Error ? error.name : 'Unknown', }); if ( diff --git a/packages/agent-core-v2/src/agent/profile/configSection.ts b/packages/agent-core-v2/src/agent/profile/configSection.ts index 0ab37d33d1..b33769cd48 100644 --- a/packages/agent-core-v2/src/agent/profile/configSection.ts +++ b/packages/agent-core-v2/src/agent/profile/configSection.ts @@ -1,13 +1,14 @@ /** * `profile` domain (L4) — `thinking` config-section env bindings. * - * Declares the `KIMI_MODEL_THINKING_EFFORT` environment binding (gated on - * `KIMI_MODEL_NAME`). Applied to the effective `thinking` value by `config`. + * Declares the env-only `KIMI_MODEL_THINKING_EFFORT` force override. Applied + * to the effective `thinking` value by `config` and stripped before + * persistence. */ import { z } from 'zod'; -import { envBindings } from '#/app/config/config'; +import { type ConfigStripEnv, envBindings } from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; export const THINKING_SECTION = 'thinking'; @@ -15,15 +16,23 @@ export const THINKING_SECTION = 'thinking'; export const ThinkingConfigSchema = z.object({ enabled: z.boolean().optional(), effort: z.string().optional(), + forcedEffort: z.string().optional(), keep: z.string().optional(), }); export type ThinkingConfig = z.infer; export const thinkingEnvBindings = envBindings(ThinkingConfigSchema, { - effort: 'KIMI_MODEL_THINKING_EFFORT', + forcedEffort: 'KIMI_MODEL_THINKING_EFFORT', }); +export const stripThinkingEnv: ConfigStripEnv = (value) => { + const result = { ...value }; + delete result.forcedEffort; + return result; +}; + registerConfigSection(THINKING_SECTION, ThinkingConfigSchema, { env: thinkingEnvBindings, + stripEnv: stripThinkingEnv, }); diff --git a/packages/agent-core-v2/src/agent/profile/profile.ts b/packages/agent-core-v2/src/agent/profile/profile.ts index 1a297c1aa5..9c6916b045 100644 --- a/packages/agent-core-v2/src/agent/profile/profile.ts +++ b/packages/agent-core-v2/src/agent/profile/profile.ts @@ -103,6 +103,7 @@ export interface IAgentProfileService { refreshSystemPrompt(): Promise; getAgentsMdWarning(): string | undefined; data(): ProfileData; + getEffectiveThinkingLevel(): ThinkingEffort; resolveModelContext(): ProfileModelContext; getProvider(): Model; resolveModel(): Model | undefined; diff --git a/packages/agent-core-v2/src/agent/profile/profileOps.ts b/packages/agent-core-v2/src/agent/profile/profileOps.ts index 1ae4033b53..a19f7736d0 100644 --- a/packages/agent-core-v2/src/agent/profile/profileOps.ts +++ b/packages/agent-core-v2/src/agent/profile/profileOps.ts @@ -3,15 +3,17 @@ * Op (`configUpdate`) for the agent's persistent configuration slice. * * Declares the persistent profile config — `cwd`, `modelAlias`, `profileName`, - * the resolved thinking effort, and `systemPrompt` — as a wire Model (initial - * `defaultProfileModel()`), plus the single Op whose `apply` is a pure merge of - * an already-resolved payload. Live records carry `thinkingEffort` (matching + * the resolved base thinking effort, and `systemPrompt` — as a wire Model + * (initial `defaultProfileModel()`), plus the single Op whose `apply` is a pure + * merge of an already-resolved payload. Live records carry `thinkingEffort` (matching * the v1 wire field); legacy replay still accepts `thinkingLevel`. The value is * resolved to a `ThinkingEffort` at the call site (via `resolveThinkingEffort` + * the `thinking` config section) and carried in the payload, so `apply` stays - * pure and a resumed agent restores - * the persisted resolved value rather than re-resolving against a possibly- - * drifted config. `modelCapabilities` is intentionally NOT in the Model — it is + * pure and a resumed agent restores the persisted base value rather than + * re-resolving against a possibly-drifted config. Runtime-only Kimi env forcing + * is projected by `AgentProfileService`; keeping it out of this Model prevents + * that Kimi-only value from leaking through model switches or agent forks. + * `modelCapabilities` is intentionally NOT in the Model — it is * derived live from `IModelResolver` so resume never pins stale capabilities. * Each `apply` returns the same reference when nothing changes so the wire's * reference-equality gate stays quiet. The `chdir` side effect and the diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 2573f0b70e..8f5d65b8e9 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -4,9 +4,9 @@ * Owns the active agent's model alias, thinking level, system prompt, and * active-tool set; resolves the runnable god-object Model through the App- * scope `IModelResolver`, persists the persistent config slice (`cwd` / - * `modelAlias` / `profileName` / resolved `thinkingLevel` / `systemPrompt`) in - * the `wire` `ProfileModel` through the `config.update` Op and the persisted - * active-tool set in the `wire` `ActiveToolsModel` through the + * `modelAlias` / `profileName` / resolved base `thinkingLevel` / + * `systemPrompt`) in the `wire` `ProfileModel` through the `config.update` Op + * and the persisted active-tool set in the `wire` `ActiveToolsModel` through the * `tools.set_active_tools` Op (`wire.dispatch`), and reads both through * `wire.getModel`. The effective active-tool set read by consumers is the * persisted base (`ActiveToolsModel`, rebuilt by `wire.replay`) overlaid with @@ -32,12 +32,16 @@ import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from '#/app/a import { type Model } from '#/app/model/modelInstance'; import { type KimiModelOverrides } from '#/app/model/modelOverrides'; import { IModelResolver } from '#/app/model/modelResolver'; +import { + normalizeRequestedThinkingEffort, + resolveKimiThinkingEffortOverride, +} from '#/app/model/thinking'; import picomatch from 'picomatch'; import { ErrorCodes, Error2 } from "#/errors"; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; -import { resolveThinkingEffort, resolveThinkingKeep } from './thinking'; +import { resolveThinkingEffort, resolveThinkingKeep, supportsThinkingEffort } from './thinking'; import type { LoopControl } from '#/agent/loop/configSection'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; @@ -64,7 +68,7 @@ import type { ProfileSetModelResult, ProfileUpdateData, } from './profile'; -import { IAgentProfileService } from './profile'; +import { IAgentProfileService, ProfileError, ProfileErrors } from './profile'; import { THINKING_SECTION, type ThinkingConfig, @@ -190,7 +194,17 @@ export class AgentProfileService implements IAgentProfileService { setThinking(level: string): void { const previousEffort = this.thinkingLevel; - this.update({ thinkingLevel: level }); + const model = this.tryResolveRawModel(); + const normalized = normalizeRequestedThinkingEffort(level); + if (normalized !== undefined && !supportsThinkingEffort(normalized, model)) { + const efforts = model?.supportEfforts ?? []; + const supported = efforts.length === 0 ? 'off' : ['off', ...efforts].join(', '); + throw new ProfileError( + ProfileErrors.codes.MODEL_CONFIG_INVALID, + `Thinking effort "${level}" is not supported by model "${this.modelAlias}". Supported efforts: ${supported}.`, + ); + } + this.update({ thinkingLevel: normalized ?? level }); const effort = this.thinkingLevel; if (effort !== previousEffort) { this.telemetry.track2('thinking_toggle', { @@ -252,6 +266,10 @@ export class AgentProfileService implements IAgentProfileService { }; } + getEffectiveThinkingLevel(): ThinkingEffort { + return this.resolveThinkingState(this.tryResolveRawModel()).effective; + } + resolveModelContext(): ProfileModelContext { const modelAlias = this.model; const model = this.modelFactory.resolve(modelAlias); @@ -261,7 +279,7 @@ export class AgentProfileService implements IAgentProfileService { modelCapabilities: model.capabilities, maxOutputSize: model.maxOutputSize, alwaysThinking: model.alwaysThinking || undefined, - thinkingLevel: this.thinkingLevel, + thinkingLevel: this.resolveThinkingState(model).effective, reservedContextSize: loopControl?.reservedContextSize, compactionTriggerRatio: loopControl?.compactionTriggerRatio, }; @@ -282,12 +300,8 @@ export class AgentProfileService implements IAgentProfileService { resolveModel(): Model | undefined { if (this.modelAlias === undefined) return undefined; let model: Model = this.modelFactory.resolve(this.modelAlias); - const thinkingLevel = this.thinkingLevel; + const thinking = this.resolveThinkingState(model); const thinkingConfig = this.config.get(THINKING_SECTION); - const forcedKimiThinkingEffort = - model.protocol === 'kimi' && thinkingLevel !== 'off' - ? normalizeKimiThinkingEffort(thinkingConfig?.effort) - : undefined; const kwargs: GenerationKwargs = {}; if (model.protocol === 'kimi') { kwargs.prompt_cache_key = this.sessionContext.sessionId; @@ -304,24 +318,24 @@ export class AgentProfileService implements IAgentProfileService { const keep = resolveThinkingKeep( overrides?.thinkingKeep, thinkingConfig?.keep, - thinkingLevel, + thinking.effective, ); if (keep !== undefined) { - if (model.protocol === 'kimi' && forcedKimiThinkingEffort === undefined) { + if (model.protocol === 'kimi' && thinking.forced === undefined) { kwargs.extra_body = { thinking: { keep } }; } else if (model.protocol === 'anthropic') { model = model.withThinkingKeep(keep); } } if (Object.keys(kwargs).length > 0) model = model.withGenerationKwargs(kwargs); - model = model.withThinking(forcedKimiThinkingEffort ?? thinkingLevel); - if (forcedKimiThinkingEffort !== undefined) { - const thinking: { type: 'enabled'; effort: string; keep?: string } = { + model = model.withThinking(thinking.effective); + if (model.protocol === 'kimi' && thinking.forced !== undefined) { + const requestThinking: { type: 'enabled'; effort: string; keep?: string } = { type: 'enabled', - effort: forcedKimiThinkingEffort, + effort: thinking.forced, }; - if (keep !== undefined) thinking.keep = keep; - model = model.withGenerationKwargs({ extra_body: { thinking } }); + if (keep !== undefined) requestThinking.keep = keep; + model = model.withGenerationKwargs({ extra_body: { thinking: requestThinking } }); } return model; } @@ -384,10 +398,12 @@ export class AgentProfileService implements IAgentProfileService { if (changed.cwd !== undefined) payload.cwd = changed.cwd; if (changed.modelAlias !== undefined) payload.modelAlias = changed.modelAlias; if (changed.profileName !== undefined) payload.profileName = changed.profileName; - if (changed.thinkingLevel !== undefined) { - const model = this.resolveModelForThinking(changed.modelAlias); + 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 = resolveThinkingEffort( - changed.thinkingLevel, + requested, this.config.get(THINKING_SECTION), model, ); @@ -404,7 +420,9 @@ export class AgentProfileService implements IAgentProfileService { const protocol = this.tryResolveRawModel()?.protocol; this.telemetryContext.set({ provider_type: protocol, protocol }); } - this.emitStatusUpdated(); + this.emitStatusUpdated( + changed.modelAlias !== undefined || changed.thinkingLevel !== undefined, + ); } private setActiveTools(names: readonly string[]): void { @@ -412,7 +430,7 @@ export class AgentProfileService implements IAgentProfileService { this.wire.dispatch(setActiveTools({ names: [...names] })); } - private emitStatusUpdated(): void { + private emitStatusUpdated(includeThinkingEffort = false): void { const custom = this.optionsValue.emitStatusUpdated; if (custom !== undefined) { custom(); @@ -422,6 +440,9 @@ export class AgentProfileService implements IAgentProfileService { this.eventBus.publish({ type: 'agent.status.updated', model: this.modelAlias, + thinkingEffort: includeThinkingEffort + ? this.getEffectiveThinkingLevel() + : undefined, maxContextTokens: this.getModelCapabilities().max_context_tokens, }); } @@ -466,6 +487,19 @@ export class AgentProfileService implements IAgentProfileService { return stored; } + private resolveThinkingState(model: Model | undefined): { + readonly effective: ThinkingEffort; + readonly forced: ThinkingEffort | undefined; + } { + const base = this.thinkingLevel; + const forced = resolveKimiThinkingEffortOverride( + this.config.get(THINKING_SECTION)?.forcedEffort, + base, + model?.providerType === 'kimi', + ); + return { effective: forced ?? base, forced }; + } + private get alwaysThinkingModel(): boolean { return this.tryResolveRawModel()?.alwaysThinking === true; } @@ -543,11 +577,6 @@ export class AgentProfileService implements IAgentProfileService { } } -function normalizeKimiThinkingEffort(raw: string | undefined): ThinkingEffort | undefined { - const trimmed = raw?.trim(); - return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; -} - registerScopedService( LifecycleScope.Agent, IAgentProfileService, diff --git a/packages/agent-core-v2/src/agent/profile/thinking.ts b/packages/agent-core-v2/src/agent/profile/thinking.ts index 93baab3a91..0cf9066b49 100644 --- a/packages/agent-core-v2/src/agent/profile/thinking.ts +++ b/packages/agent-core-v2/src/agent/profile/thinking.ts @@ -7,16 +7,29 @@ */ import type { ThinkingEffort } from '#/app/llmProtocol/thinkingEffort'; -import { type ModelThinkingMetadata, resolveThinkingEffortForModel } from '#/app/model/thinking'; +import { + modelSupportsThinkingEffort, + type ModelThinkingMetadata, + resolveThinkingEffortForModel, +} from '#/app/model/thinking'; import type { ThinkingConfig } from './configSection'; +type ThinkingModel = ModelThinkingMetadata & { readonly providerType?: string }; + export function resolveThinkingEffort( requested: string | undefined, defaults: ThinkingConfig | undefined, - model?: ModelThinkingMetadata, + model?: ThinkingModel, ): ThinkingEffort { - return resolveThinkingEffortForModel(requested, defaults, model); + return resolveThinkingEffortForModel(requested, defaults, model, model?.providerType === 'kimi'); +} + +export function supportsThinkingEffort( + effort: ThinkingEffort, + model: ThinkingModel | undefined, +): boolean { + return modelSupportsThinkingEffort(effort, model, model?.providerType === 'kimi'); } const KEEP_OFF_VALUES = new Set(['0', 'false', 'no', 'off', 'none', 'null']); diff --git a/packages/agent-core-v2/src/agent/usage/usageOps.ts b/packages/agent-core-v2/src/agent/usage/usageOps.ts index ba47508525..98fa3d013c 100644 --- a/packages/agent-core-v2/src/agent/usage/usageOps.ts +++ b/packages/agent-core-v2/src/agent/usage/usageOps.ts @@ -31,6 +31,7 @@ declare module '#/app/event/eventBus' { swarmMode?: boolean; planMode?: boolean; model?: string; + thinkingEffort?: string; maxContextTokens?: number; contextTokens?: number; phase?: AgentPhase; diff --git a/packages/agent-core-v2/src/app/llmProtocol/errors.ts b/packages/agent-core-v2/src/app/llmProtocol/errors.ts index 0fc0b33019..d1f8152287 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/errors.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/errors.ts @@ -197,6 +197,29 @@ const REQUEST_TOO_LARGE_MESSAGE_PATTERNS = [ /request (?:body )?too large/, ] as const; +const THINKING_EFFORT_CONFIG_DOCS_URL = + 'https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#thinking'; + +const THINKING_EFFORT_STATUS_MESSAGE_PATTERNS = [ + /reasoning[_ .-]?effort/, + /thinking[_ .-]?effort/, + /output_config[\s\S]*effort/, + /unsupported[\s\S]*effort/, + /invalid[\s\S]*effort/, +] as const; + +function appendThinkingEffortConfigHint(statusCode: number, message: string): string { + if (statusCode !== 400 && statusCode !== 422) return message; + const lowerMessage = message.toLowerCase(); + if (!THINKING_EFFORT_STATUS_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage))) { + return message; + } + 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}`; +} + export function isContextOverflowErrorCode(code: string | null | undefined): boolean { return code === 'context_length_exceeded'; } @@ -219,7 +242,12 @@ export function normalizeAPIStatusError( if (isProviderOverloadStatusError(statusCode, message)) { return new APIProviderOverloadedError(statusCode, message, requestId, retryAfterMs); } - return new APIStatusError(statusCode, message, requestId, retryAfterMs); + return new APIStatusError( + statusCode, + appendThinkingEffortConfigHint(statusCode, message), + requestId, + retryAfterMs, + ); } export function parseRetryAfterMs(headers: unknown): number | null { diff --git a/packages/agent-core-v2/src/app/llmProtocol/provider.ts b/packages/agent-core-v2/src/app/llmProtocol/provider.ts index 62c73b6c53..04547944fb 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/provider.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/provider.ts @@ -2,6 +2,13 @@ import type { Message, StreamedMessagePart, VideoURLPart } from './message'; import type { Tool } from './tool'; import type { TokenUsage } from './usage'; +/** + * Thinking effort passed to `ChatProvider.withThinking`. + * + * `'off'` and `'on'` are local control signals. Other strings are concrete + * model effort values. Protocol adapters receive an already-resolved value and + * preserve concrete efforts when their upstream protocol has a native field. + */ export type ThinkingEffort = 'off' | 'on' | (string & {}); export type JsonSchemaObject = Record; diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts index ac6ac18c6a..bad0a662e4 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts @@ -82,6 +82,7 @@ export interface AnthropicOptions { metadata?: Record | undefined; stream?: boolean | undefined; adaptiveThinking?: boolean | undefined; + kimiThinking?: boolean | undefined; betaApi?: boolean | undefined; clientFactory?: (auth: ProviderRequestAuth) => Anthropic; } @@ -320,6 +321,7 @@ function budgetTokensForEffort(effort: ThinkingEffort): number { } throw new Error(`Unknown thinking effort: ${String(effort)}`); } + const CACHE_CONTROL = { type: 'ephemeral' as const }; type CacheableBlock = ContentBlockParam & { cache_control?: { type: 'ephemeral' } }; @@ -828,6 +830,7 @@ export class AnthropicChatProvider implements ChatProvider { private _defaultHeaders: Record | undefined; private _clientFactory: ((auth: ProviderRequestAuth) => Anthropic) | undefined; private _adaptiveThinking: boolean | undefined; + private readonly _kimiThinking: boolean; private _betaApi: boolean; private _explicitMaxTokens: boolean; @@ -836,6 +839,7 @@ export class AnthropicChatProvider implements ChatProvider { this._stream = options.stream ?? true; this._metadata = options.metadata; this._adaptiveThinking = options.adaptiveThinking; + this._kimiThinking = options.kimiThinking ?? false; this._betaApi = options.betaApi ?? false; this._apiKey = options.apiKey === undefined || options.apiKey.length === 0 ? undefined : options.apiKey; @@ -862,21 +866,17 @@ export class AnthropicChatProvider implements ChatProvider { if (thinkingConfig.type === 'disabled') { return 'off'; } + const effort = this._generationKwargs.output_config?.effort; + if (typeof effort === 'string' && effort.length > 0) { + return effort; + } if (thinkingConfig.type === 'adaptive') { - const effort = this._generationKwargs.output_config?.effort; - if (effort === undefined || effort === null) { - return 'high'; - } - switch (effort) { - case 'low': - case 'medium': - case 'high': - case 'xhigh': - case 'max': - return effort; - } + return 'high'; + } + const budget = (thinkingConfig as { budget_tokens?: number }).budget_tokens; + if (budget === undefined) { + return 'on'; } - const budget = (thinkingConfig as { budget_tokens?: number }).budget_tokens ?? 0; if (budget <= 1024) { return 'low'; } @@ -1111,16 +1111,32 @@ export class AnthropicChatProvider implements ChatProvider { return clone; } + let newBetas = [...(this._generationKwargs.betaFeatures ?? [])]; + if (adaptive) { + newBetas = newBetas.filter((b) => b !== INTERLEAVED_THINKING_BETA); + } + if (this._kimiThinking) { + const clone = this._withGenerationKwargs({ + thinking: { type: 'enabled' } as MessageCreateParams['thinking'], + betaFeatures: newBetas, + }); + if (effort === 'on') { + delete clone._generationKwargs.output_config; + } else { + clone._generationKwargs.output_config = { + effort, + } as MessageCreateParams['output_config']; + } + return clone; + } + const clamped = clampEffort(effort, this._model, adaptive); if (clamped === 'off') { throw new Error('Non-off thinking effort unexpectedly clamped to off.'); } const effectiveEffort = clamped as AnthropicEffort; - let newBetas = [...(this._generationKwargs.betaFeatures ?? [])]; - if (adaptive) { - newBetas = newBetas.filter((b) => b !== INTERLEAVED_THINKING_BETA); return this._withGenerationKwargs({ thinking: { type: 'adaptive', display: 'summarized' }, output_config: { effort: effectiveEffort }, diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts index ad6097b702..daa963a4ca 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts @@ -47,7 +47,6 @@ export interface KimiOptions { stream?: boolean | undefined; defaultHeaders?: Record | undefined; generationKwargs?: GenerationKwargs | undefined; - supportEfforts?: readonly string[]; clientFactory?: (auth: ProviderRequestAuth) => OpenAI; } @@ -360,7 +359,6 @@ export class KimiChatProvider implements ChatProvider { private _baseUrl: string; private _defaultHeaders: Record | undefined; private _generationKwargs: GenerationKwargs; - private readonly _supportEfforts: readonly string[]; private _client: OpenAI | undefined; private _clientFactory: ((auth: ProviderRequestAuth) => OpenAI) | undefined; private _files: KimiFiles | undefined; @@ -374,7 +372,6 @@ export class KimiChatProvider implements ChatProvider { this._model = options.model; this._stream = options.stream ?? true; this._generationKwargs = { ...options.generationKwargs }; - this._supportEfforts = options.supportEfforts ?? []; this._client = this._apiKey === undefined ? undefined @@ -496,9 +493,7 @@ export class KimiChatProvider implements ChatProvider { if (effort === 'off') { thinking = { type: 'disabled' }; } else { - thinking = this._supportEfforts.includes(effort) - ? { type: 'enabled', effort } - : { type: 'enabled' }; + thinking = effort === 'on' ? { type: 'enabled' } : { type: 'enabled', effort }; } const oldExtra = this._generationKwargs.extra_body ?? {}; const keep = oldExtra.thinking?.keep; diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts index f11dae0f14..b41a395c18 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts @@ -25,8 +25,6 @@ import { TOOL_RESULT_MEDIA_PLACEHOLDER, TOOL_RESULT_MEDIA_PROMPT, type ToolMessageConversion, - reasoningEffortToThinkingEffort, - thinkingEffortToReasoningEffort, toolToOpenAI, } from './openai-common'; import { @@ -467,7 +465,8 @@ export class OpenAILegacyChatProvider implements ChatProvider { } get thinkingEffort(): ThinkingEffort | null { - return reasoningEffortToThinkingEffort(this._reasoningEffort); + if (this._reasoningEffort === undefined) return null; + return this._reasoningEffort === 'none' ? 'off' : this._reasoningEffort; } get maxCompletionTokens(): number | undefined { @@ -559,7 +558,7 @@ export class OpenAILegacyChatProvider implements ChatProvider { } withThinking(effort: ThinkingEffort): OpenAILegacyChatProvider { - const reasoningEffort = thinkingEffortToReasoningEffort(effort); + const reasoningEffort = effort === 'off' || effort === 'on' ? undefined : effort; const clone = this._clone(); clone._reasoningEffort = reasoningEffort; return clone; diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts index 4e96e9bd48..6fc56d76c4 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts @@ -26,8 +26,6 @@ import { TOOL_RESULT_MEDIA_PLACEHOLDER, TOOL_RESULT_MEDIA_PROMPT, type ToolMessageConversion, - reasoningEffortToThinkingEffort, - thinkingEffortToReasoningEffort, } from './openai-common'; import { mergeRequestHeaders, @@ -985,7 +983,9 @@ export class OpenAIResponsesChatProvider implements ChatProvider { } get thinkingEffort(): ThinkingEffort | null { - return reasoningEffortToThinkingEffort(this._generationKwargs.reasoning_effort); + const effort = this._generationKwargs.reasoning_effort; + if (effort === undefined) return null; + return effort === 'none' ? 'off' : effort; } get maxCompletionTokens(): number | undefined { @@ -1077,7 +1077,7 @@ export class OpenAIResponsesChatProvider implements ChatProvider { } withThinking(effort: ThinkingEffort): OpenAIResponsesChatProvider { - const reasoningEffort = thinkingEffortToReasoningEffort(effort); + const reasoningEffort = effort === 'off' || effort === 'on' ? undefined : effort; const clone = this._clone(); clone._generationKwargs = { ...clone._generationKwargs, diff --git a/packages/agent-core-v2/src/app/model/modelImpl.ts b/packages/agent-core-v2/src/app/model/modelImpl.ts index 23c06985ee..b0b1608d1d 100644 --- a/packages/agent-core-v2/src/app/model/modelImpl.ts +++ b/packages/agent-core-v2/src/app/model/modelImpl.ts @@ -54,6 +54,7 @@ export interface ModelImplInit { readonly supportEfforts?: readonly string[]; readonly defaultEffort?: string; readonly alwaysThinking: boolean; + readonly providerType?: string; readonly providerName: string; readonly authProvider: AuthProvider; readonly protocolRegistry: ProtocolAdapterRegistry; @@ -77,6 +78,7 @@ export class ModelImpl implements Model { readonly authProvider: AuthProvider; readonly thinkingEffort: ThinkingEffort | null; readonly alwaysThinking: boolean; + readonly providerType?: string; readonly providerName: string; private readonly protocolRegistry: ProtocolAdapterRegistry; @@ -104,6 +106,7 @@ export class ModelImpl implements Model { this.providerOptions = init.providerOptions ?? {}; this.transforms = transforms; this.alwaysThinking = init.alwaysThinking; + this.providerType = init.providerType; this.providerName = init.providerName; this.thinkingEffort = null; } @@ -129,6 +132,7 @@ export class ModelImpl implements Model { supportEfforts: this.supportEfforts, defaultEffort: this.defaultEffort, alwaysThinking: this.alwaysThinking, + providerType: this.providerType, providerName: this.providerName, authProvider: this.authProvider, protocolRegistry: this.protocolRegistry, diff --git a/packages/agent-core-v2/src/app/model/modelInstance.ts b/packages/agent-core-v2/src/app/model/modelInstance.ts index 411a95824c..7f4fb6d477 100644 --- a/packages/agent-core-v2/src/app/model/modelInstance.ts +++ b/packages/agent-core-v2/src/app/model/modelInstance.ts @@ -79,6 +79,7 @@ export interface Model { readonly thinkingEffort: ThinkingEffort | null; readonly maxCompletionTokens?: number; readonly alwaysThinking: boolean; + readonly providerType?: string; readonly providerName: string; readonly authProvider: AuthProvider; diff --git a/packages/agent-core-v2/src/app/model/modelResolverService.ts b/packages/agent-core-v2/src/app/model/modelResolverService.ts index 436199f752..cce7bfaac3 100644 --- a/packages/agent-core-v2/src/app/model/modelResolverService.ts +++ b/packages/agent-core-v2/src/app/model/modelResolverService.ts @@ -45,11 +45,15 @@ import { import type { AuthProvider, Model } from './modelInstance'; import { IModelResolver } from './modelResolver'; import { ModelImpl, StaticAuthProvider } from './modelImpl'; -import { resolveThinkingEffortForModel } from './thinking'; +import { + resolveKimiThinkingEffortOverride, + resolveThinkingEffortForModel, +} from './thinking'; interface ThinkingSection { readonly enabled?: boolean; readonly effort?: string; + readonly forcedEffort?: string; } type MutableProtocolProviderOptions = { @@ -93,6 +97,7 @@ export class ModelResolverService extends Disposable implements IModelResolver { const authProvider = this.buildAuthProvider(providerName, auth); const protocol = this.resolveProtocol(id, model, providerConfig); + const providerType = providerConfig?.type ?? protocol; const resolvedBaseUrl = model.protocol === 'anthropic' && rawBaseUrl !== undefined ? stripTrailingV1(rawBaseUrl) @@ -145,28 +150,38 @@ export class ModelResolverService extends Disposable implements IModelResolver { supportEfforts: model.supportEfforts, defaultEffort: model.defaultEffort, alwaysThinking, + providerType, providerName, authProvider, protocolRegistry: this.protocolRegistry as ProtocolAdapterRegistry, providerOptions, }); - const effort = this.resolveDefaultThinking(model, alwaysThinking); + const effort = this.resolveDefaultThinking( + model, + alwaysThinking, + providerType === 'kimi', + ); return effort === 'off' ? impl : impl.withThinking(effort); } private resolveDefaultThinking( model: ModelConfig, alwaysThinking: boolean, + kimiProvider: boolean, ): ThinkingEffort { const thinking = this.config.get('thinking'); - return resolveThinkingEffortForModel( + const effort = resolveThinkingEffortForModel( undefined, { enabled: thinking?.enabled, effort: thinking?.effort, }, { ...model, alwaysThinking }, + kimiProvider, + ); + return ( + resolveKimiThinkingEffortOverride(thinking?.forcedEffort, effort, kimiProvider) ?? effort ); } @@ -320,6 +335,7 @@ function buildProtocolProviderOptions( case 'anthropic': if (model.maxOutputSize !== undefined) options.defaultMaxTokens = model.maxOutputSize; if (model.adaptiveThinking !== undefined) options.adaptiveThinking = model.adaptiveThinking; + if (provider?.type === 'kimi') options.kimiThinking = true; if (model.betaApi !== undefined) options.betaApi = model.betaApi; break; case 'openai': { @@ -328,7 +344,6 @@ function buildProtocolProviderOptions( break; } case 'kimi': - if (model.supportEfforts !== undefined) options.supportEfforts = model.supportEfforts; break; case 'vertexai': { const project = vertexAIProject(provider); diff --git a/packages/agent-core-v2/src/app/model/thinking.ts b/packages/agent-core-v2/src/app/model/thinking.ts index fc8b3afce4..b9fac6f3a4 100644 --- a/packages/agent-core-v2/src/app/model/thinking.ts +++ b/packages/agent-core-v2/src/app/model/thinking.ts @@ -1,9 +1,10 @@ /** * `model` domain (L2) — model-aware thinking effort resolution. * - * Resolves the effective thinking effort from request/config defaults plus the - * model's declared thinking metadata. Shared by `modelResolver` and the - * Agent-scope `profile` domain so both paths keep v1-compatible defaults. + * Resolves the effective thinking effort from request/config defaults, Kimi's + * operational wire override, and the model's declared thinking metadata. + * Shared by `modelResolver` and the Agent-scope `profile` domain so both paths + * keep v1-compatible defaults. */ import type { ModelCapability } from '#/app/llmProtocol/capability'; @@ -27,6 +28,21 @@ function nonEmpty(value: string | undefined): string | undefined { return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; } +export function normalizeRequestedThinkingEffort( + requested: string | undefined, +): ThinkingEffort | undefined { + return nonEmpty(requested)?.toLowerCase() as ThinkingEffort | undefined; +} + +export function resolveKimiThinkingEffortOverride( + forced: string | undefined, + effective: ThinkingEffort, + kimiProvider: boolean, +): ThinkingEffort | undefined { + if (!kimiProvider || effective === 'off') return undefined; + return nonEmpty(forced) as ThinkingEffort | undefined; +} + function hasCapability( capabilities: ModelThinkingMetadata['capabilities'], capability: string, @@ -55,6 +71,10 @@ function middleOf(values: readonly string[]): string { return values[Math.floor(values.length / 2)]!; } +function effortsFor(model: ModelThinkingMetadata | undefined): readonly string[] { + return model?.supportEfforts?.map(nonEmpty).filter((v): v is string => v !== undefined) ?? []; +} + export function modelSupportsThinking(model: ModelThinkingMetadata | undefined): boolean { if (model === undefined) return false; return ( @@ -69,23 +89,58 @@ export function defaultThinkingEffortForModel( model: ModelThinkingMetadata | undefined, ): ThinkingEffort { if (model === undefined || !modelSupportsThinking(model)) return 'off'; - const efforts = model.supportEfforts?.map(nonEmpty).filter((v): v is string => v !== undefined); - if (efforts !== undefined && efforts.length > 0) { - return (nonEmpty(model.defaultEffort) ?? middleOf(efforts)) as ThinkingEffort; + const efforts = effortsFor(model); + if (efforts.length > 0) { + const declaredDefault = nonEmpty(model.defaultEffort); + return (declaredDefault !== undefined && efforts.includes(declaredDefault) + ? declaredDefault + : middleOf(efforts)) as ThinkingEffort; } return 'on'; } +export function modelSupportsThinkingEffort( + effort: ThinkingEffort, + model: ModelThinkingMetadata | undefined, + kimiProvider: boolean, +): boolean { + if (!kimiProvider || effort === 'off') return true; + if (!modelSupportsThinking(model)) return false; + const efforts = effortsFor(model); + return efforts.length === 0 || effort === 'on' || efforts.includes(effort); +} + +function normalizeThinkingEffortForModel( + effort: ThinkingEffort, + model: ModelThinkingMetadata | undefined, + kimiProvider: boolean, +): ThinkingEffort { + if (effort === 'off' && model?.alwaysThinking !== true) return 'off'; + const efforts = effortsFor(model); + if (!kimiProvider) { + return effort === 'on' && efforts.length > 0 + ? defaultThinkingEffortForModel(model) + : effort; + } + if (!modelSupportsThinking(model)) return 'off'; + if (efforts.length === 0) return 'on'; + if (effort === 'on' || !efforts.includes(effort)) { + return defaultThinkingEffortForModel(model); + } + return effort; +} + export function resolveThinkingEffortForModel( requested: string | undefined, defaults: ThinkingDefaults | undefined, model: ModelThinkingMetadata | undefined, + kimiProvider = false, ): ThinkingEffort { const configured = nonEmpty(defaults?.effort) as ThinkingEffort | undefined; - const normalized = nonEmpty(requested)?.toLowerCase(); + const normalized = normalizeRequestedThinkingEffort(requested); let effort: ThinkingEffort; if (normalized !== undefined) { - effort = normalized as ThinkingEffort; + effort = normalized; } else if (defaults?.enabled === false) { effort = 'off'; } else { @@ -93,7 +148,7 @@ export function resolveThinkingEffortForModel( } if (effort === 'off' && model?.alwaysThinking === true) { - return configured ?? defaultThinkingEffortForModel(model); + effort = configured ?? defaultThinkingEffortForModel(model); } - return effort; + return normalizeThinkingEffortForModel(effort, model, kimiProvider); } diff --git a/packages/agent-core-v2/src/app/protocol/protocol.ts b/packages/agent-core-v2/src/app/protocol/protocol.ts index c808afea5f..67575c69ec 100644 --- a/packages/agent-core-v2/src/app/protocol/protocol.ts +++ b/packages/agent-core-v2/src/app/protocol/protocol.ts @@ -34,9 +34,9 @@ export interface ProtocolProviderOptions { readonly reasoningKey?: string; readonly defaultMaxTokens?: number; readonly adaptiveThinking?: boolean; + readonly kimiThinking?: boolean; readonly betaApi?: boolean; readonly metadata?: Readonly>; - readonly supportEfforts?: readonly string[]; readonly vertexai?: boolean; readonly project?: string; readonly location?: string; diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index c800c91cd8..7f54b8f37e 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -161,7 +161,6 @@ export class SessionLegacyService implements ISessionLegacyService { const plan = agent.accessor.get(IAgentPlanService); const swarm = agent.accessor.get(IAgentSwarmService); - const profileData = profile.data(); const model = profile.getModel(); const caps = profile.getModelCapabilities() as { max_context_tokens?: number }; const maxTokens = @@ -172,7 +171,7 @@ export class SessionLegacyService implements ISessionLegacyService { return { status: session?.accessor.get(ISessionActivity).status() ?? 'idle', model: model === '' ? undefined : model, - thinking_level: profileData.thinkingLevel, + thinking_level: profile.getEffectiveThinkingLevel(), permission: permission.mode, plan_mode: planData !== null, swarm_mode: swarm.isActive, diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index d8806041e4..092bac1d09 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -2224,7 +2224,7 @@ describe('FullCompaction', () => { event: 'compaction_finished', properties: expect.objectContaining({ source: 'auto', - thinking_effort: 'high', + thinking_effort: 'on', }), }); }); diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts index 348e57ca98..d547667600 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts @@ -161,6 +161,16 @@ describe('LLMRequester service migration coverage', () => { }); it('records the resolved Kimi thinking keep default when thinking is enabled', async () => { + ctx.configure({ + modelCapabilities: { + image_in: false, + video_in: false, + audio_in: false, + thinking: true, + tool_use: true, + max_context_tokens: 1_000_000, + }, + }); ctx.get(IAgentProfileService).update({ thinkingLevel: 'high' }); ctx.mockNextResponse({ type: 'text', text: 'thinking response' }); @@ -168,11 +178,40 @@ describe('LLMRequester service migration coverage', () => { expect(wireEvents(ctx, 'llm.request')).toHaveLength(1); expect(wireEvents(ctx, 'llm.request')[0]?.args).toMatchObject({ - thinkingEffort: 'high', + thinkingEffort: 'on', thinkingKeep: 'all', }); }); + it('records the env-forced Kimi effort used by the provider', async () => { + await ctx.dispose(); + vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'max'); + ctx = createTestAgent(); + llmRequester = ctx.get(IAgentLLMRequesterService); + ctx.configure({ + modelCapabilities: { + image_in: false, + video_in: false, + audio_in: false, + thinking: true, + tool_use: true, + max_context_tokens: 1_000_000, + }, + }); + const profile = ctx.get(IAgentProfileService); + profile.update({ thinkingLevel: 'high' }); + expect(profile.data().thinkingLevel).toBe('on'); + expect(profile.resolveModelContext().thinkingLevel).toBe('max'); + ctx.mockNextResponse({ type: 'text', text: 'forced thinking response' }); + + await llmRequester.request(); + + expect(wireEvents(ctx, 'llm.request')).toHaveLength(1); + expect(wireEvents(ctx, 'llm.request')[0]?.args).toMatchObject({ + thinkingEffort: 'max', + }); + }); + it('records strict projection resends as separate outbound requests', async () => { await ctx.dispose(); let calls = 0; diff --git a/packages/agent-core-v2/test/agent/profile/binding.test.ts b/packages/agent-core-v2/test/agent/profile/binding.test.ts index 3661e28a1b..cd99924516 100644 --- a/packages/agent-core-v2/test/agent/profile/binding.test.ts +++ b/packages/agent-core-v2/test/agent/profile/binding.test.ts @@ -63,6 +63,16 @@ describe('AgentProfileService.bind', () => { }, hostEnvironmentServices(homeDir), ); + ctx.configure({ + modelCapabilities: { + image_in: false, + video_in: false, + audio_in: false, + thinking: true, + tool_use: true, + max_context_tokens: 1_000_000, + }, + }); const svc = ctx.get(IAgentProfileService); await ctx.get(IAgentWireService).flush(); const start = persistence.records.length; @@ -97,7 +107,7 @@ describe('AgentProfileService.bind', () => { expect(records[2]).toMatchObject({ type: 'config.update', modelAlias: MOCK_MODEL, - thinkingEffort: 'low', + thinkingEffort: 'on', }); expect(records[2]).not.toHaveProperty('thinkingLevel'); }); 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 08b79944fe..9dae5e8760 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,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester'; import { IAgentProfileService } from '#/agent/profile/profile'; +import type { ModelConfig } from '#/app/model/model'; import { configServices, createTestAgent, @@ -14,6 +15,8 @@ import { import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; type TestKimiConfig = ReturnType[0]>; +type TestProtocolModelConfig = NonNullable[string] & + Pick; type GenerateFn = Parameters[0]; function defaultGenerate(): ReturnType { @@ -65,6 +68,7 @@ describe('ConfigState model capabilities', () => { provider: 'kimi', model: 'kimi-for-coding', maxContextSize: 1_000_000, + supportEfforts: ['low', 'high'], capabilities: ['image_in', 'video_in', 'thinking', 'tool_use'], }, }, @@ -98,6 +102,8 @@ describe('ConfigState model capabilities', () => { provider: 'kimi', model: 'kimi-for-coding', maxContextSize: 1_000_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], }, }, }; @@ -261,6 +267,14 @@ describe('ConfigState thinking clamp for always-thinking models', () => { supportEfforts: ['low', 'medium', 'max'], defaultEffort: 'max', }, + 'kimi-code/ultra': { + provider: 'kimi', + model: 'kimi-ultra', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'high', 'ultra'], + defaultEffort: 'ultra', + }, }, }; capturedProvider = undefined; @@ -312,10 +326,10 @@ describe('ConfigState thinking clamp for always-thinking models', () => { expect(profile.data().thinkingLevel).toBe('off'); }); - it('keeps an explicit on request verbatim (normalization is the UI boundary)', () => { + it('resolves an explicit on request to the model default effort', () => { profile.update({ modelAlias: 'kimi-code/custom', thinkingLevel: 'on' }); - expect(profile.data().thinkingLevel).toBe('on'); + expect(profile.data().thinkingLevel).toBe('max'); }); it('re-clamps when switching to an always-on model after thinking was off', () => { @@ -325,6 +339,51 @@ describe('ConfigState thinking clamp for always-thinking models', () => { profile.update({ modelAlias: 'kimi-code/deep' }); expect(profile.data().thinkingLevel).toBe('high'); }); + + it('falls back to the target default when a model switch carries an unsupported effort', () => { + profile.update({ modelAlias: 'kimi-code/ultra', thinkingLevel: 'ultra' }); + + profile.update({ modelAlias: 'kimi-code/custom' }); + + expect(profile.data().thinkingLevel).toBe('max'); + }); + + it('projects an inherited concrete effort to on when switching to a boolean model', () => { + profile.update({ modelAlias: 'kimi-code/ultra', thinkingLevel: 'ultra' }); + + profile.update({ modelAlias: 'kimi-code/toggle' }); + + expect(profile.data().thinkingLevel).toBe('on'); + }); + + it('rejects an unsupported effort explicitly set on the current Kimi model', () => { + profile.update({ modelAlias: 'kimi-code/custom' }); + + expect(() => { + profile.setThinking('ultra'); + }).toThrow( + 'Thinking effort "ultra" is not supported by model "kimi-code/custom"', + ); + }); + + it.each([ + [' HIGH ', 'high'], + ['OFF', 'off'], + ])('normalizes runtime effort %j to %s before validation', (input, expected) => { + profile.update({ modelAlias: 'kimi-code/ultra' }); + + profile.setThinking(input); + + expect(profile.data().thinkingLevel).toBe(expected); + }); + + it('uses the model default when the runtime effort is blank', () => { + profile.update({ modelAlias: 'kimi-code/custom', thinkingLevel: 'low' }); + + profile.setThinking(' '); + + expect(profile.data().thinkingLevel).toBe('max'); + }); }); describe('ConfigState.provider applies global KIMI_MODEL_* request config', () => { @@ -338,7 +397,20 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () = kimiConfig = { providers: { kimi: { type: 'kimi', apiKey: 'test-key', baseUrl: 'https://api.example.test/v1' } }, models: { - 'kimi-code': { provider: 'kimi', model: 'kimi-code', maxContextSize: 128_000 }, + 'kimi-code': { + provider: 'kimi', + model: 'kimi-code', + maxContextSize: 128_000, + capabilities: ['thinking'], + }, + 'kimi-code-anthropic': { + provider: 'kimi', + protocol: 'anthropic', + model: 'kimi-code-anthropic', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + } as TestProtocolModelConfig, }, }; capturedProvider = undefined; @@ -413,4 +485,29 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () = }; expect(gen.extra_body?.thinking?.keep).toBeUndefined(); }); + + it('injects forced effort through the Anthropic protocol for a Kimi provider', async () => { + vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'max'); + createAgentWithEnv(); + + profile.update({ modelAlias: 'kimi-code-anthropic', thinkingLevel: 'high' }); + expect(profile.data().thinkingLevel).toBe('high'); + expect(profile.resolveModelContext().thinkingLevel).toBe('max'); + const statusEvent = ctx?.allEvents.findLast( + (event) => + event.event === 'agent.status.updated' && + (event.args as { thinkingEffort?: unknown } | undefined)?.thinkingEffort !== undefined, + ); + expect(statusEvent?.args).toMatchObject({ + model: 'kimi-code-anthropic', + thinkingEffort: 'max', + }); + + await requester.request({}, undefined, new AbortController().signal); + + expect(capturedProvider).toMatchObject({ + name: 'anthropic', + thinkingEffort: 'max', + }); + }); }); 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 d35f14eb01..cf4ff05fd8 100644 --- a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts +++ b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts @@ -144,6 +144,7 @@ function createRecordingModel( providerOptions: unknown[] = [], protocol: Model['protocol'] = 'kimi', thinkingKeeps: string[] = [], + providerType: string | undefined = protocol === 'kimi' ? 'kimi' : undefined, ): Model { const build = (thinkingEffort: ThinkingEffort | null): Model => ({ id: 'kimi-code', @@ -161,8 +162,12 @@ function createRecordingModel( max_context_tokens: 1000, }, maxContextSize: 1000, + supportEfforts: + providerType === 'kimi' ? ['low', 'medium', 'high', 'max'] : undefined, + defaultEffort: providerType === 'kimi' ? 'high' : undefined, thinkingEffort, alwaysThinking: false, + providerType, providerName: 'kimi', authProvider: { getAuth: async () => undefined }, withThinking: (effort) => { @@ -281,6 +286,18 @@ describe('AgentProfileService (wire-backed config.update)', () => { expect(modelOf(host.wire).thinkingLevel).toBe('high'); }); + it('returns the persisted effort when a replayed model alias no longer resolves', async () => { + const host = buildHost('profile-replay-removed-model'); + + await host.wire.replay({ + type: 'config.update', + modelAlias: 'removed-model', + thinkingEffort: 'high', + }); + + expect(host.svc.getEffectiveThinkingLevel()).toBe('high'); + }); + it('rejects conflicting config.update thinking aliases during replay', async () => { const host = buildHost('profile-replay-conflicting-thinking-aliases'); @@ -318,7 +335,7 @@ describe('AgentProfileService (wire-backed config.update)', () => { ]); }); - it('forces configured Kimi thinking effort outside declared support_efforts', () => { + it('uses the resolved Kimi effort instead of the configured default', () => { const generationKwargs: GenerationKwargs[] = []; const thinkingEfforts: ThinkingEffort[] = []; modelResolver = { @@ -326,11 +343,40 @@ describe('AgentProfileService (wire-backed config.update)', () => { resolve: () => createRecordingModel(generationKwargs, thinkingEfforts), findByName: () => [], }; - const host = buildHost('profile-thinking-effort-force'); + const host = buildHost('profile-thinking-effort-resolved'); host.svc.configure({ emitStatusUpdated: () => undefined }); configValues['thinking'] = { effort: ' max ' }; - host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'on' }); + host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'high' }); + const model = host.svc.resolveModel(); + + expect(model?.thinkingEffort).toBe('high'); + expect(thinkingEfforts).toEqual(['high']); + expect(generationKwargs).toEqual([ + { + prompt_cache_key: 'session-test', + extra_body: { thinking: { keep: 'all' } }, + }, + ]); + }); + + it('forces the environment Kimi effort instead of the resolved effort', () => { + const generationKwargs: GenerationKwargs[] = []; + const thinkingEfforts: ThinkingEffort[] = []; + modelResolver = { + _serviceBrand: undefined, + resolve: () => createRecordingModel(generationKwargs, thinkingEfforts), + findByName: () => [], + }; + const host = buildHost('profile-thinking-effort-force'); + host.svc.configure({ emitStatusUpdated: () => undefined }); + configValues['thinking'] = { effort: 'low', forcedEffort: ' max ' }; + + host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'high' }); + expect(host.svc.data().thinkingLevel).toBe('high'); + expect(modelOf(host.wire).thinkingLevel).toBe('high'); + expect(host.svc.resolveModelContext().thinkingLevel).toBe('max'); + const model = host.svc.resolveModel(); expect(model?.thinkingEffort).toBe('max'); @@ -341,6 +387,34 @@ describe('AgentProfileService (wire-backed config.update)', () => { ]); }); + it('does not leak a forced Kimi effort when switching to a non-Kimi model', () => { + const kimiThinkingEfforts: ThinkingEffort[] = []; + const otherThinkingEfforts: ThinkingEffort[] = []; + modelResolver = { + _serviceBrand: undefined, + resolve: (alias) => + alias === 'kimi-code' + ? createRecordingModel([], kimiThinkingEfforts) + : createRecordingModel([], otherThinkingEfforts, [], 'anthropic'), + findByName: () => [], + }; + const host = buildHost('profile-thinking-effort-force-switch'); + host.svc.configure({ emitStatusUpdated: () => undefined }); + configValues['thinking'] = { forcedEffort: 'max' }; + + host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'high' }); + expect(host.svc.data().thinkingLevel).toBe('high'); + expect(host.svc.resolveModelContext().thinkingLevel).toBe('max'); + expect(host.svc.resolveModel()?.thinkingEffort).toBe('max'); + + host.svc.update({ modelAlias: 'other-code' }); + expect(host.svc.data().thinkingLevel).toBe('high'); + expect(host.svc.resolveModelContext().thinkingLevel).toBe('high'); + expect(host.svc.resolveModel()?.thinkingEffort).toBe('high'); + expect(kimiThinkingEfforts).toEqual(['max']); + expect(otherThinkingEfforts).toEqual(['high']); + }); + it('applies thinking.keep model override on the Anthropic path', () => { const generationKwargs: GenerationKwargs[] = []; const thinkingEfforts: ThinkingEffort[] = []; @@ -372,6 +446,38 @@ describe('AgentProfileService (wire-backed config.update)', () => { expect(generationKwargs).toEqual([{ temperature: 0.3 }]); }); + it('forces Kimi effort through Anthropic without Kimi generation kwargs', () => { + const generationKwargs: GenerationKwargs[] = []; + const thinkingEfforts: ThinkingEffort[] = []; + const providerOptions: unknown[] = []; + const thinkingKeeps: string[] = []; + modelResolver = { + _serviceBrand: undefined, + resolve: () => + createRecordingModel( + generationKwargs, + thinkingEfforts, + providerOptions, + 'anthropic', + thinkingKeeps, + 'kimi', + ), + findByName: () => [], + }; + const host = buildHost('profile-thinking-effort-force-anthropic'); + host.svc.configure({ emitStatusUpdated: () => undefined }); + configValues['thinking'] = { forcedEffort: 'max' }; + + host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'high' }); + const model = host.svc.resolveModel(); + + expect(model?.thinkingEffort).toBe('max'); + expect(thinkingEfforts).toEqual(['max']); + expect(thinkingKeeps).toEqual(['all']); + expect(providerOptions).toEqual([{ metadata: { user_id: 'session-test' } }]); + expect(generationKwargs).toEqual([]); + }); + it('defaults thinking.keep to "all" when thinking is enabled on Kimi', () => { const generationKwargs: GenerationKwargs[] = []; const thinkingEfforts: ThinkingEffort[] = []; @@ -448,9 +554,11 @@ describe('AgentProfileService (wire-backed config.update)', () => { }; const host = buildHost('profile-thinking-keep-off'); host.svc.configure({ emitStatusUpdated: () => undefined }); + configValues['thinking'] = { forcedEffort: 'max' }; configValues['modelOverrides'] = { temperature: 0.3, thinkingKeep: 'all' }; host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'off' }); + expect(host.svc.resolveModelContext().thinkingLevel).toBe('off'); host.svc.resolveModel(); expect(thinkingEfforts).toEqual(['off']); 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 aa7b8a2745..750edbcaf9 100644 --- a/packages/agent-core-v2/test/agent/profile/thinking.test.ts +++ b/packages/agent-core-v2/test/agent/profile/thinking.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { resolveThinkingEffort } from '#/agent/profile/thinking'; +import { resolveThinkingEffort, supportsThinkingEffort } from '#/agent/profile/thinking'; import { defaultThinkingEffortForModel } from '#/app/model/thinking'; const booleanModel = { capabilities: ['thinking'] }; @@ -10,7 +10,7 @@ const effortModel = { }; const effortModelWithDefault = { capabilities: ['thinking'], - supportEfforts: ['low', 'high'], + supportEfforts: ['low', 'high', 'max'], defaultEffort: 'max', }; const alwaysThinkingModel = { @@ -36,6 +36,16 @@ describe('defaultThinkingEffortForModel', () => { expect(defaultThinkingEffortForModel(effortModelWithDefault)).toBe('max'); }); + it('ignores a defaultEffort that is not declared in supportEfforts', () => { + expect( + defaultThinkingEffortForModel({ + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + defaultEffort: 'max', + }), + ).toBe('high'); + }); + it('falls back to the middle supportEfforts entry when defaultEffort is absent', () => { expect(defaultThinkingEffortForModel(effortModel)).toBe('medium'); expect( @@ -61,7 +71,7 @@ describe('resolveThinkingEffort', () => { expect(resolveThinkingEffort('low', undefined, effortModel)).toBe('low'); expect(resolveThinkingEffort('on', { enabled: false }, booleanModel)).toBe('on'); expect(resolveThinkingEffort('off', undefined, booleanModel)).toBe('off'); - expect(resolveThinkingEffort('on', { effort: 'medium' }, effortModel)).toBe('on'); + expect(resolveThinkingEffort('on', { effort: 'medium' }, effortModel)).toBe('medium'); }); it('returns off when config.enabled is false and no effort is requested', () => { @@ -116,4 +126,31 @@ describe('resolveThinkingEffort', () => { expect(resolveThinkingEffort(' Medium ', undefined)).toBe('medium'); expect(resolveThinkingEffort('OFF', { effort: 'high' })).toBe('off'); }); + + it('falls back to the model default for an unsupported Kimi effort', () => { + expect( + resolveThinkingEffort('ultra', undefined, { + ...effortModel, + providerType: 'kimi', + }), + ).toBe('medium'); + }); + + it('projects a concrete effort to on for a boolean-only Kimi model', () => { + expect( + resolveThinkingEffort('ultra', undefined, { + ...booleanModel, + providerType: 'kimi', + }), + ).toBe('on'); + }); + + it('reports unsupported concrete efforts only for Kimi effort models', () => { + expect( + supportsThinkingEffort('ultra', { ...effortModel, providerType: 'kimi' }), + ).toBe(false); + expect( + supportsThinkingEffort('ultra', { ...effortModel, providerType: 'openai' }), + ).toBe(true); + }); }); diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index c83c2b67f2..b54bdcc6e8 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -33,6 +33,10 @@ import '#/agent/permissionMode/configSection'; import { DEFAULT_PERMISSION_MODE_SECTION } from '#/agent/permissionMode/configSection'; import '#/agent/media/configSection'; import { IMAGE_SECTION, type ImageConfig } from '#/agent/media/configSection'; +import { + THINKING_SECTION, + type ThinkingConfig, +} from '#/agent/profile/configSection'; import { KEEP_ALIVE_ON_EXIT_ENV, resolveAgentTaskConfig, @@ -120,7 +124,7 @@ describe('Agent config', () => { await expect(ctx.rpc.getConfig({})).resolves.toMatchObject({ systemPrompt: 'Changed profile prompt.', - thinkingLevel: 'high', + thinkingLevel: 'on', modelCapabilities: nextCapability, }); }); @@ -354,6 +358,49 @@ describe('ConfigService env overlay (live)', () => { disposables.dispose(); }); + + it('keeps the Kimi effort force separate from the configured effort', async () => { + const env: Record = { KIMI_MODEL_THINKING_EFFORT: 'max' }; + const disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + ix.stub(ILogService, stubLog()); + ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg', env)); + ix.stub(IFileSystemStorageService, new InMemoryStorageService()); + ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); + ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.set(IConfigService, new SyncDescriptor(ConfigService)); + const config = ix.get(IConfigService); + await config.ready; + await config.set(THINKING_SECTION, { effort: 'low' }); + + expect(config.get(THINKING_SECTION)).toEqual({ + effort: 'low', + forcedEffort: 'max', + }); + + disposables.dispose(); + }); + + it('strips the Kimi effort force before persisting thinking config', async () => { + const disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + ix.stub(ILogService, stubLog()); + ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg')); + ix.stub(IFileSystemStorageService, new InMemoryStorageService()); + ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); + ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.set(IConfigService, new SyncDescriptor(ConfigService)); + const config = ix.get(IConfigService); + await config.ready; + + await config.set(THINKING_SECTION, { effort: 'low', forcedEffort: 'max' }); + + expect(config.inspect(THINKING_SECTION).userValue).toEqual({ + effort: 'low', + }); + + disposables.dispose(); + }); }); describe('skill config sections', () => { diff --git a/packages/agent-core-v2/test/app/model/modelResolver.test.ts b/packages/agent-core-v2/test/app/model/modelResolver.test.ts index 3e64fba173..f1101da0ba 100644 --- a/packages/agent-core-v2/test/app/model/modelResolver.test.ts +++ b/packages/agent-core-v2/test/app/model/modelResolver.test.ts @@ -31,6 +31,7 @@ import { IProtocolAdapterRegistry, type ProtocolAdapterConfig } from '#/app/prot let generateImpl: ChatProvider['generate']; let uploadVideoImpl: NonNullable | undefined; +let appliedThinkingEfforts: string[]; describe('ModelResolverService', () => { let disposables: DisposableStore; @@ -50,6 +51,7 @@ describe('ModelResolverService', () => { configValues = {}; resolveTokenProvider = vi.fn(); createdProtocolConfigs = []; + appliedThinkingEfforts = []; generateImpl = async () => ({ id: null, usage: null, @@ -588,7 +590,7 @@ describe('ModelResolverService', () => { }); }); - it('passes Kimi supportEfforts through to the protocol adapter', async () => { + it('keeps Kimi supportEfforts as model metadata instead of adapter options', async () => { providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' }; models['m'] = { provider: 'p', @@ -597,29 +599,61 @@ describe('ModelResolverService', () => { supportEfforts: ['low', 'high', 'max'], }; + const model = ix.get(IModelResolver).resolve('m'); + + expect(model.supportEfforts).toEqual(['low', 'high', 'max']); + }); + + it('applies overridden Kimi supportEfforts to model metadata', async () => { + providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' }; + models['m'] = { + provider: 'p', + model: 'kimi-for-coding', + maxContextSize: 1000, + supportEfforts: ['low', 'high', 'max'], + overrides: { supportEfforts: ['low', 'high'] }, + }; + + const model = ix.get(IModelResolver).resolve('m'); + + expect(model.supportEfforts).toEqual(['low', 'high']); + }); + + it('does not pass supportEfforts through for non-Kimi providers', async () => { + providers['p'] = { type: 'anthropic', baseUrl: 'https://example.test', apiKey: 'sk' }; + models['m'] = { + provider: 'p', + model: 'kimi-for-coding', + maxContextSize: 1000, + supportEfforts: ['low', 'high', 'max'], + }; + const config = await resolveAndCreateProvider(); expect(config).toMatchObject({ - protocol: 'kimi', - providerOptions: { supportEfforts: ['low', 'high', 'max'] }, + protocol: 'anthropic', }); + const providerOptions = config?.['providerOptions'] as + | { readonly supportEfforts?: readonly string[] } + | undefined; + expect(providerOptions?.supportEfforts).toBeUndefined(); }); - it('passes overridden Kimi supportEfforts through to the protocol adapter', async () => { - providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' }; + it('marks the Anthropic adapter when it transports a Kimi provider', async () => { + providers['p'] = { type: 'kimi', baseUrl: 'https://example.test', apiKey: 'sk' }; models['m'] = { provider: 'p', + protocol: 'anthropic', model: 'kimi-for-coding', maxContextSize: 1000, supportEfforts: ['low', 'high', 'max'], - overrides: { supportEfforts: ['low', 'high'] }, }; const config = await resolveAndCreateProvider(); expect(config).toMatchObject({ - protocol: 'kimi', - providerOptions: { supportEfforts: ['low', 'high'] }, + protocol: 'anthropic', + providerOptions: { kimiThinking: true }, }); }); @@ -714,13 +748,17 @@ describe('ModelResolverService', () => { }); describe('default thinking', () => { - function resolveEffort(capabilities?: string[]): string | null { + function resolveEffort( + capabilities?: string[], + supportEfforts?: string[], + ): string | null { providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' }; models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000, ...(capabilities === undefined ? {} : { capabilities }), + supportEfforts, }; return ix.get(IModelResolver).resolve('m').thinkingEffort; } @@ -767,7 +805,70 @@ describe('ModelResolverService', () => { it('uses the configured thinking.effort', () => { configValues['thinking'] = { effort: 'medium' }; - expect(resolveEffort()).toBe('medium'); + expect(resolveEffort(['thinking'], ['low', 'medium', 'high'])).toBe('medium'); + }); + + it('derives Kimi effort semantics for a flat kimi-protocol model', () => { + configValues['thinking'] = { effort: 'ultra' }; + models['m'] = { + protocol: 'kimi', + baseUrl: 'https://example.test/v1', + apiKey: 'sk', + model: 'wire-name', + maxContextSize: 1000, + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'high'], + defaultEffort: 'medium', + }; + + const model = ix.get(IModelResolver).resolve('m'); + + expect(model.providerType).toBe('kimi'); + expect(model.thinkingEffort).toBe('medium'); + }); + + it('applies the forced effort to a direct Kimi-over-Anthropic request', async () => { + configValues['thinking'] = { effort: 'low', forcedEffort: 'max' }; + providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' }; + models['m'] = { + provider: 'p', + protocol: 'anthropic', + model: 'wire-name', + maxContextSize: 1000, + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + }; + + const model = ix.get(IModelResolver).resolve('m'); + for await (const _event of model.request({ systemPrompt: '', tools: [], messages: [] })) { + void _event; + } + + expect(model.thinkingEffort).toBe('max'); + expect(appliedThinkingEfforts).toEqual(['max']); + expect(createdProtocolConfigs[0]).toMatchObject({ + protocol: 'anthropic', + providerOptions: { kimiThinking: true }, + }); + }); + + it('ignores the forced Kimi effort when thinking is off', async () => { + configValues['thinking'] = { enabled: false, forcedEffort: 'max' }; + providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' }; + models['m'] = { + provider: 'p', + model: 'wire-name', + maxContextSize: 1000, + capabilities: ['thinking'], + }; + + const model = ix.get(IModelResolver).resolve('m'); + for await (const _event of model.request({ systemPrompt: '', tools: [], messages: [] })) { + void _event; + } + + expect(model.thinkingEffort).toBeNull(); + expect(appliedThinkingEfforts).toEqual([]); }); it('clamps an explicit off back to on for always_thinking models', () => { @@ -894,7 +995,8 @@ const fakeChatProvider: ChatProvider = { if (uploadVideoImpl === undefined) throw new Error('uploadVideo not configured'); return uploadVideoImpl(input, options); }, - withThinking() { + withThinking(effort) { + appliedThinkingEfforts.push(effort); return this; }, }; diff --git a/packages/agent-core-v2/test/app/protocol/protocolAdapterRegistry.test.ts b/packages/agent-core-v2/test/app/protocol/protocolAdapterRegistry.test.ts index f5be6ed65d..7b3be26a23 100644 --- a/packages/agent-core-v2/test/app/protocol/protocolAdapterRegistry.test.ts +++ b/packages/agent-core-v2/test/app/protocol/protocolAdapterRegistry.test.ts @@ -41,6 +41,7 @@ describe('ProtocolAdapterRegistry', () => { providerOptions: { defaultMaxTokens: 12345, adaptiveThinking: false, + kimiThinking: true, betaApi: true, metadata: { user_id: 'session-test' }, }, @@ -48,28 +49,31 @@ describe('ProtocolAdapterRegistry', () => { expect(Reflect.get(provider, '_generationKwargs')).toMatchObject({ max_tokens: 12345 }); expect(Reflect.get(provider, '_adaptiveThinking')).toBe(false); + expect(Reflect.get(provider, '_kimiThinking')).toBe(true); expect(Reflect.get(provider, '_betaApi')).toBe(true); expect(Reflect.get(provider, '_metadata')).toEqual({ user_id: 'session-test' }); }); - it('maps providerOptions into Kimi provider config', () => { + it('passes concrete efforts through the Kimi provider config', () => { const provider = new ProtocolAdapterRegistry().createChatProvider({ protocol: 'kimi', baseUrl: 'https://example.test/v1', modelName: 'kimi-for-coding', apiKey: 'sk', - providerOptions: { supportEfforts: ['low', 'high', 'max'] }, }); - expect(Reflect.get(provider, '_supportEfforts')).toEqual(['low', 'high', 'max']); expect(Reflect.get(provider.withThinking('high'), '_generationKwargs')).toEqual({ extra_body: { thinking: { type: 'enabled', effort: 'high' } }, }); expect(provider.withThinking('high').thinkingEffort).toBe('high'); expect(Reflect.get(provider.withThinking('medium'), '_generationKwargs')).toEqual({ - extra_body: { thinking: { type: 'enabled' } }, + extra_body: { thinking: { type: 'enabled', effort: 'medium' } }, }); - expect(provider.withThinking('medium').thinkingEffort).toBe('on'); + expect(provider.withThinking('medium').thinkingEffort).toBe('medium'); + expect(Reflect.get(provider.withThinking('xhigh'), '_generationKwargs')).toEqual({ + extra_body: { thinking: { type: 'enabled', effort: 'xhigh' } }, + }); + expect(provider.withThinking('xhigh').thinkingEffort).toBe('xhigh'); expect( Reflect.get(provider.withThinking('high').withThinking('off'), '_generationKwargs'), ).toEqual({ @@ -78,6 +82,20 @@ describe('ProtocolAdapterRegistry', () => { expect(provider.withThinking('high').withThinking('off').thinkingEffort).toBe('off'); }); + it('passes concrete efforts through the OpenAI provider config', () => { + const provider = new ProtocolAdapterRegistry().createChatProvider({ + protocol: 'openai', + baseUrl: 'https://example.test/v1', + modelName: 'kimi-for-coding', + apiKey: 'sk', + }); + + expect(Reflect.get(provider.withThinking('max'), '_reasoningEffort')).toBe('max'); + expect(provider.withThinking('max').thinkingEffort).toBe('max'); + expect(Reflect.get(provider.withThinking('medium'), '_reasoningEffort')).toBe('medium'); + expect(provider.withThinking('medium').thinkingEffort).toBe('medium'); + }); + it('maps providerOptions into Vertex provider config', () => { const provider = new ProtocolAdapterRegistry().createChatProvider({ protocol: 'vertexai', diff --git a/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts b/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts new file mode 100644 index 0000000000..921fe7d4fc --- /dev/null +++ b/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts @@ -0,0 +1,113 @@ +/** + * Session legacy status scenarios. + * + * Resolves the edge adapter through DI and exercises its public status contract + * with real scope-handle traversal. Agent/session domain collaborators are + * narrow stubs so the scenario can model a persisted alias removed from the + * current model catalog. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { SyncDescriptor } from '#/_base/di/descriptors'; +import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiation'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { type IAgentScopeHandle, type ISessionScopeHandle, LifecycleScope } from '#/_base/di/scope'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IAgentContextSizeService } from '#/agent/contextSize/contextSize'; +import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; +import { IAgentPlanService } from '#/agent/plan/plan'; +import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentSwarmService } from '#/agent/swarm/swarm'; +import { UNKNOWN_CAPABILITY } from '#/app/llmProtocol/capability'; +import { ISessionLegacyService } from '#/app/sessionLegacy/sessionLegacy'; +import { SessionLegacyService } from '#/app/sessionLegacy/sessionLegacyService'; +import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionCronService } from '#/session/cron/sessionCronService'; +import { ISessionActivity } from '#/session/sessionActivity/sessionActivity'; + +function accessor( + entries: ReadonlyArray, unknown]>, +): ServicesAccessor { + return { + get(id: ServiceIdentifier): T { + for (const [key, value] of entries) { + if (key === id) return value as T; + } + throw new Error(`Unexpected service request: ${String(id)}`); + }, + }; +} + +describe('Session legacy status (best-effort runtime state)', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + }); + + afterEach(() => { + disposables.dispose(); + }); + + it('returns the persisted effort when the saved model alias no longer resolves', async () => { + const profile = { + _serviceBrand: undefined, + data: () => ({ + cwd: '/workspace', + modelAlias: 'removed-model', + modelCapabilities: UNKNOWN_CAPABILITY, + thinkingLevel: 'high', + systemPrompt: '', + }), + getModel: () => 'removed-model', + getModelCapabilities: () => UNKNOWN_CAPABILITY, + getEffectiveThinkingLevel: () => 'high', + resolveModelContext: () => { + throw new Error('removed-model cannot be resolved'); + }, + } as unknown as IAgentProfileService; + const agent: IAgentScopeHandle = { + id: 'main', + kind: LifecycleScope.Agent, + accessor: accessor([ + [IAgentProfileService, profile], + [IAgentContextSizeService, { get: () => ({ size: 25, measured: 20, estimated: 5 }) }], + [IAgentPermissionModeService, { mode: 'manual' }], + [IAgentPlanService, { status: () => Promise.resolve(null) }], + [IAgentSwarmService, { isActive: false }], + ]), + dispose: () => {}, + }; + const agents = { + whenReady: () => Promise.resolve(agent), + } as unknown as IAgentLifecycleService; + const session: ISessionScopeHandle = { + id: 'session-test', + kind: LifecycleScope.Session, + accessor: accessor([ + [IAgentLifecycleService, agents], + [ISessionCronService, { _serviceBrand: undefined }], + [ISessionActivity, { status: () => 'idle' }], + ]), + dispose: () => {}, + }; + ix.stub(ISessionLifecycleService, { + resume: () => Promise.resolve(session), + get: () => session, + }); + ix.set(ISessionLegacyService, new SyncDescriptor(SessionLegacyService)); + + const status = await ix.get(ISessionLegacyService).status('session-test'); + + expect(status).toMatchObject({ + status: 'idle', + model: 'removed-model', + thinking_level: 'high', + max_context_tokens: 0, + }); + }); +}); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index f68e138a62..38088a2d49 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -2070,23 +2070,29 @@ function configWithEnvOverrides(config: KimiConfig): KimiConfig { parseEnvCompletionTokens(process.env['KIMI_MODEL_MAX_TOKENS']); const temperature = parseEnvFloat(process.env['KIMI_MODEL_TEMPERATURE']); const topP = parseEnvFloat(process.env['KIMI_MODEL_TOP_P']); + const forcedEffort = process.env['KIMI_MODEL_THINKING_EFFORT']?.trim(); const thinkingKeep = process.env['KIMI_MODEL_THINKING_KEEP']?.trim(); const cron = cronEnvOverrides(asMutableRecord(config['cron'])); if ( maxCompletionTokens === undefined && temperature === undefined && topP === undefined && + (forcedEffort === undefined || forcedEffort.length === 0) && (thinkingKeep === undefined || thinkingKeep.length === 0) && cron === undefined ) { return config; } const modelOverrides = asMutableRecord(config['modelOverrides']); + const thinking = asMutableRecord(config['thinking']); if (temperature !== undefined) modelOverrides['temperature'] = temperature; if (topP !== undefined) modelOverrides['topP'] = topP; if (thinkingKeep !== undefined && thinkingKeep.length > 0) { modelOverrides['thinkingKeep'] = thinkingKeep; } + if (forcedEffort !== undefined && forcedEffort.length > 0) { + thinking['forcedEffort'] = forcedEffort; + } if (maxCompletionTokens !== undefined) { modelOverrides['maxCompletionTokens'] = maxCompletionTokens; } @@ -2094,6 +2100,8 @@ function configWithEnvOverrides(config: KimiConfig): KimiConfig { ...config, cron: cron ?? config['cron'], modelOverrides, + thinking: + forcedEffort !== undefined && forcedEffort.length > 0 ? thinking : config['thinking'], }; } diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index d4724b6fc2..c8e3678ceb 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -9,14 +9,18 @@ import { import { applyAnthropicThinkingKeep, applyKimiEnvSamplingParams, - applyKimiEnvThinkingEffort, applyKimiEnvThinkingKeep, + resolveKimiEnvThinkingEffort, } from '#/config/kimi-env-params'; import type { Agent } from '..'; import { ErrorCodes, KimiError } from '../../errors'; import type { AgentConfigData, AgentConfigUpdateData } from './types'; -import { resolveThinkingEffort, type ThinkingEffort } from './thinking'; +import { + resolveThinkingEffort, + supportsThinkingEffort, + type ThinkingEffort, +} from './thinking'; import type { ModelAlias } from '../../config/schema'; import type { ResolvedRuntimeProvider } from '../../session/provider-manager'; @@ -27,6 +31,7 @@ export class ConfigState { private _cwd: string; private _modelAlias: string | undefined; private _profileName: string | undefined; + private _unforcedThinkingEffort: ThinkingEffort = 'off'; private _thinkingEffort: ThinkingEffort = 'off'; private _systemPrompt: string = ''; @@ -38,13 +43,42 @@ export class ConfigState { update(changed: AgentConfigUpdateData): void { if (Object.keys(changed).length === 0) return; + const targetAlias = changed.modelAlias ?? this._modelAlias; + const targetProvider = this.tryResolvedProviderConfigFor(targetAlias); + const targetModel = this.modelForThinking(targetAlias, targetProvider); + const kimiProvider = targetProvider?.type === 'kimi'; + let unforcedThinkingEffort: ThinkingEffort | undefined; + let thinkingEffort: ThinkingEffort | undefined; + if (changed.thinkingEffort !== undefined) { + unforcedThinkingEffort = resolveThinkingEffort( + changed.thinkingEffort, + this.agent.kimiConfig?.thinking, + targetModel, + kimiProvider, + ); + } else if (changed.modelAlias !== undefined) { + unforcedThinkingEffort = resolveThinkingEffort( + this._modelAlias === undefined ? undefined : this._unforcedThinkingEffort, + this.agent.kimiConfig?.thinking, + targetModel, + kimiProvider, + ); + } + if (unforcedThinkingEffort !== undefined) { + thinkingEffort = + resolveKimiEnvThinkingEffort(unforcedThinkingEffort, kimiProvider) ?? + unforcedThinkingEffort; + } + const effectiveChanged = + thinkingEffort === undefined ? changed : { ...changed, thinkingEffort }; + this.agent.records.logRecord({ type: 'config.update', - ...changed, + ...effectiveChanged, }); this.agent.replayBuilder.push({ type: 'config_updated', - config: changed, + config: effectiveChanged, }); if (changed.cwd) { this._cwd = changed.cwd; @@ -56,24 +90,9 @@ export class ConfigState { if (changed.profileName) { this._profileName = changed.profileName; } - if (changed.thinkingEffort !== undefined) { - // Resolve through the single source of truth so the always_thinking - // clamp and any future normalization apply uniformly — whether the - // level comes from createSession, setThinking RPC, or subagent - // inheritance. - this._thinkingEffort = resolveThinkingEffort( - changed.thinkingEffort, - this.agent.kimiConfig?.thinking, - this.currentModel, - ); - } else if (changed.modelAlias !== undefined) { - // Re-apply the always_thinking clamp against the new model so a stale - // 'off' cannot survive a switch onto an always-thinking alias. - this._thinkingEffort = resolveThinkingEffort( - this._thinkingEffort, - this.agent.kimiConfig?.thinking, - this.currentModel, - ); + if (unforcedThinkingEffort !== undefined && thinkingEffort !== undefined) { + this._unforcedThinkingEffort = unforcedThinkingEffort; + this._thinkingEffort = thinkingEffort; } if (changed.systemPrompt !== undefined) { this._systemPrompt = changed.systemPrompt; @@ -81,7 +100,21 @@ export class ConfigState { if (this.hasProvider && (changed.cwd !== undefined || changed.modelAlias)) { this.agent.tools.initializeBuiltinTools(); } - this.agent.emitStatusUpdated(); + this.agent.emitStatusUpdated(thinkingEffort !== undefined); + } + + setThinkingEffort(effort: ThinkingEffort): void { + const model = this.currentModel; + const kimiProvider = this.tryResolvedProviderConfig()?.type === 'kimi'; + if (!supportsThinkingEffort(effort, model, kimiProvider)) { + const efforts = model?.supportEfforts ?? []; + const supported = efforts.length === 0 ? 'off' : ['off', ...efforts].join(', '); + throw new KimiError( + ErrorCodes.MODEL_CONFIG_INVALID, + `Thinking effort "${effort}" is not supported by model "${this.modelAlias}". Supported efforts: ${supported}.`, + ); + } + this.update({ thinkingEffort: effort }); } data(): AgentConfigData { @@ -122,16 +155,16 @@ export class ConfigState { // from config.provider — the main loop AND full-history compaction — carries it: // - withThinking: preserve thinking during compaction (#464) // - sampling params: KIMI_MODEL_TEMPERATURE / KIMI_MODEL_TOP_P - // - thinking.effort: KIMI_MODEL_THINKING_EFFORT (forces an effort, only while thinking is on) + // - thinking.effort: the resolved ConfigState value, including the + // KIMI_MODEL_THINKING_EFFORT override while thinking is on // - thinking.keep: env KIMI_MODEL_THINKING_KEEP > config thinking.keep > default "all" // (only while thinking is on). Drives Kimi's `thinking.keep` and, on the // Anthropic path, a `context_management` `clear_thinking_20251015` edit. const provider = createProvider(this.providerConfig).withThinking(this.thinkingEffort); const withSampling = applyKimiEnvSamplingParams(provider); - const withEffort = applyKimiEnvThinkingEffort(withSampling, this.thinkingEffort); const configKeep = this.agent.kimiConfig?.thinking?.keep; const withKimiKeep = applyKimiEnvThinkingKeep( - withEffort, + withSampling, this.thinkingEffort, undefined, configKeep, @@ -157,9 +190,31 @@ export class ConfigState { } private get currentModel(): ModelAlias | undefined { - const alias = this._modelAlias; - if (alias === undefined) return undefined; - return this.agent.kimiConfig?.models?.[alias]; + const resolved = this.tryResolvedProviderConfig(); + return this.modelForThinking(this._modelAlias, resolved); + } + + private modelForThinking( + alias: string | undefined, + resolved: ResolvedRuntimeProvider | undefined, + ): ModelAlias | undefined { + if (resolved !== undefined) { + const capabilities = resolved.alwaysThinking + ? ['always_thinking'] + : resolved.modelCapabilities.thinking + ? ['thinking'] + : []; + return { + provider: resolved.providerName, + model: resolved.provider.model, + maxContextSize: Math.max(resolved.modelCapabilities.max_context_tokens, 1), + capabilities, + supportEfforts: + resolved.supportEfforts === undefined ? undefined : [...resolved.supportEfforts], + defaultEffort: resolved.defaultEffort, + }; + } + return alias === undefined ? undefined : this.agent.kimiConfig?.models?.[alias]; } get profileName(): string | undefined { @@ -184,8 +239,14 @@ export class ConfigState { } private tryResolvedProviderConfig(): ResolvedRuntimeProvider | undefined { + return this.tryResolvedProviderConfigFor(this._modelAlias); + } + + private tryResolvedProviderConfigFor( + alias: string | undefined, + ): ResolvedRuntimeProvider | undefined { try { - return this.resolvedProviderConfig; + return alias === undefined ? undefined : this.agent.modelProvider?.resolveProviderConfig(alias); } catch { return undefined; } diff --git a/packages/agent-core/src/agent/config/thinking.ts b/packages/agent-core/src/agent/config/thinking.ts index 3b41632818..9b5e64bb71 100644 --- a/packages/agent-core/src/agent/config/thinking.ts +++ b/packages/agent-core/src/agent/config/thinking.ts @@ -19,6 +19,11 @@ function middleOf(efforts: readonly string[]): string { return efforts[Math.floor(efforts.length / 2)]!; } +function effortsFor(model: ModelAlias | undefined): readonly string[] { + const effective = model === undefined ? undefined : effectiveModelAlias(model); + return effective?.supportEfforts?.filter((effort) => effort.length > 0) ?? []; +} + /** * Resolve the default thinking effort for a model from its declared metadata: * - models that do not support thinking (or an unknown model) -> `'off'` @@ -32,13 +37,52 @@ function middleOf(efforts: readonly string[]): string { export function defaultThinkingEffortFor(model: ModelAlias | undefined): ThinkingEffort { const effective = model === undefined ? undefined : effectiveModelAlias(model); if (!supportsThinking(effective)) return 'off'; - const efforts = effective?.supportEfforts; - if (efforts !== undefined && efforts.length > 0) { - return effective?.defaultEffort ?? middleOf(efforts); + const efforts = effortsFor(effective); + if (efforts.length > 0) { + const declaredDefault = effective?.defaultEffort; + return declaredDefault !== undefined && efforts.includes(declaredDefault) + ? declaredDefault + : middleOf(efforts); } return 'on'; } +export function supportsThinkingEffort( + effort: ThinkingEffort, + model: ModelAlias | undefined, + kimiProvider: boolean, +): boolean { + if (!kimiProvider || 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); +} + +function normalizeThinkingEffortForModel( + effort: ThinkingEffort, + model: ModelAlias | undefined, + kimiProvider: boolean, +): ThinkingEffort { + const effective = model === undefined ? undefined : effectiveModelAlias(model); + if (effort === 'off' && effective?.capabilities?.includes('always_thinking') !== true) { + return 'off'; + } + + const efforts = effortsFor(effective); + if (!kimiProvider) { + return effort === 'on' && efforts.length > 0 + ? defaultThinkingEffortFor(effective) + : effort; + } + if (!supportsThinking(effective)) return 'off'; + if (efforts.length === 0) return 'on'; + if (effort === 'on' || !efforts.includes(effort)) { + return defaultThinkingEffortFor(effective); + } + return effort; +} + /** * Resolve the effective thinking effort for a session. * @@ -55,6 +99,7 @@ export function resolveThinkingEffort( requested: ThinkingEffort | undefined, config: ThinkingConfig | undefined, model: ModelAlias | undefined, + kimiProvider = false, ): ThinkingEffort { const effectiveModel = model === undefined ? undefined : effectiveModelAlias(model); let effort: ThinkingEffort; @@ -74,5 +119,5 @@ export function resolveThinkingEffort( effort = config?.effort ?? defaultThinkingEffortFor(effectiveModel); } - return effort; + return normalizeThinkingEffortForModel(effort, effectiveModel, kimiProvider); } diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 8019dc8f30..49da2182c0 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -407,7 +407,7 @@ export class Agent { }, setThinking: (payload) => { const previousEffort = this.config.thinkingEffort; - this.config.update({ thinkingEffort: payload.effort }); + this.config.setThinkingEffort(payload.effort); const effort = this.config.thinkingEffort; if (effort !== previousEffort) { this.telemetry.track('thinking_toggle', { @@ -549,7 +549,7 @@ export class Agent { void this.rpc?.emitEvent?.(event); } - emitStatusUpdated(): void { + emitStatusUpdated(includeThinkingEffort = false): void { if (this.records.restoring) return; if (!this.config.hasModel) return; @@ -565,6 +565,7 @@ export class Agent { this.emitEvent({ type: 'agent.status.updated', model, + thinkingEffort: includeThinkingEffort ? this.config.thinkingEffort : undefined, contextTokens, maxContextTokens, contextUsage, diff --git a/packages/agent-core/src/config/kimi-env-params.ts b/packages/agent-core/src/config/kimi-env-params.ts index 6c6ec3af6d..10a8e3d9ce 100644 --- a/packages/agent-core/src/config/kimi-env-params.ts +++ b/packages/agent-core/src/config/kimi-env-params.ts @@ -38,24 +38,21 @@ export function applyKimiEnvSamplingParams( } /** - * Force a specific thinking effort via `KIMI_MODEL_THINKING_EFFORT`, bypassing - * the model's declared `support_efforts`. Applied in `ConfigState.provider` - * after `withThinking`, and only while thinking is on — effort has no meaning - * when thinking is disabled. The value is forwarded verbatim as - * `thinking.effort`, so callers can target a model that accepts an effort but - * does not advertise one via `support_efforts`. + * Resolve the operational `KIMI_MODEL_THINKING_EFFORT` override after the + * model-aware effort has been resolved. The override intentionally bypasses + * `support_efforts`, but cannot turn Thinking on after the user disabled it. * - * Non-Kimi providers — and an unset/blank value — are returned unchanged. + * Provider identity is supplied separately from the wire adapter so a Kimi + * provider routed through the Anthropic protocol still receives Kimi semantics. */ -export function applyKimiEnvThinkingEffort( - provider: ChatProvider, +export function resolveKimiEnvThinkingEffort( thinkingEffort: ThinkingEffort, + kimiProvider: boolean, env: Env = process.env, -): ChatProvider { - if (!(provider instanceof KimiChatProvider)) return provider; +): ThinkingEffort | undefined { + if (!kimiProvider || thinkingEffort === 'off') return undefined; const effort = env['KIMI_MODEL_THINKING_EFFORT']?.trim(); - if (effort === undefined || effort.length === 0 || thinkingEffort === 'off') return provider; - return provider.withExtraBody({ thinking: { effort } }); + return effort === undefined || effort.length === 0 ? undefined : effort; } const KEEP_OFF_VALUES = new Set(['0', 'false', 'no', 'off', 'none', 'null']); diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index 71f3e7e022..5a84fdeaa9 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -27,6 +27,8 @@ export interface ResolvedRuntimeProvider { readonly modelCapabilities: ModelCapability; /** Declared 'always_thinking' capability — the model cannot disable thinking. */ readonly alwaysThinking?: boolean; + readonly supportEfforts?: readonly string[]; + readonly defaultEffort?: string; readonly maxOutputSize?: number; /** Configured provider wire type (`provider.type`), before any model-level protocol override. */ readonly type: ProviderType; @@ -129,7 +131,6 @@ export class ProviderManager implements ModelProvider { this.options.promptCacheKey, effectiveAlias.adaptiveThinking, alias.betaApi, - effectiveAlias.supportEfforts, ); return { @@ -139,6 +140,8 @@ export class ProviderManager implements ModelProvider { alwaysThinking: (effectiveAlias.capabilities ?? []).some( (c) => c.trim().toLowerCase() === 'always_thinking', ), + supportEfforts: effectiveAlias.supportEfforts, + defaultEffort: effectiveAlias.defaultEffort, maxOutputSize: effectiveAlias.maxOutputSize, type: providerConfig.type, protocol: alias.protocol, @@ -253,7 +256,6 @@ function toKosongProviderConfig( promptCacheKey: string | undefined, adaptiveThinking: boolean | undefined, betaApi: boolean | undefined, - supportEfforts: readonly string[] | undefined, ): KosongProviderConfig { const effectiveType = modelProtocol === 'anthropic' ? 'anthropic' : provider.type; const envCustomHeaders = parseKimiCodeCustomHeaders(); @@ -270,6 +272,7 @@ function toKosongProviderConfig( apiKey: providerApiKey(provider), ...(maxOutputSize !== undefined ? { defaultMaxTokens: maxOutputSize } : {}), ...(adaptiveThinking !== undefined ? { adaptiveThinking } : {}), + ...(provider.type === 'kimi' ? { kimiThinking: true } : {}), ...(betaApi !== undefined ? { betaApi } : {}), // Session affinity: Anthropic's analog of OpenAI `prompt_cache_key` is // `metadata.user_id` on the Messages API (cache-affinity / end-user id). @@ -308,7 +311,6 @@ function toKosongProviderConfig( baseUrl: providerValue(provider.baseUrl, provider.env, 'KIMI_BASE_URL'), apiKey: providerApiKey(provider), generationKwargs: { prompt_cache_key: promptCacheKey }, - supportEfforts, ...defaultHeadersField({ ...envCustomHeaders, ...kimiRequestHeaders, diff --git a/packages/agent-core/test/agent/compaction/full.test.ts b/packages/agent-core/test/agent/compaction/full.test.ts index ac886f3dad..e238b6eefe 100644 --- a/packages/agent-core/test/agent/compaction/full.test.ts +++ b/packages/agent-core/test/agent/compaction/full.test.ts @@ -2021,16 +2021,14 @@ describe('FullCompaction', () => { await ctx.untilTurnEnd(); expect(callCount).toBe(3); - // The catalogued model declares no supportEfforts, so the kimi provider - // normalizes to boolean thinking and reports 'on' rather than the - // requested 'high'. The agent's stored thinkingEffort ('high') is still - // carried across the compaction (see the record assertion below). + // A Kimi model without supportEfforts is boolean-only, so the effective + // state and every compaction request use 'on'. expect(providerThinkingEfforts).toEqual(['on', 'on', 'on']); expect(records).toContainEqual({ event: 'compaction_finished', properties: expect.objectContaining({ source: 'auto', - thinking_effort: 'high', + thinking_effort: 'on', }), }); }); diff --git a/packages/agent-core/test/agent/config-state.test.ts b/packages/agent-core/test/agent/config-state.test.ts index 7a0d169f0d..0947978d76 100644 --- a/packages/agent-core/test/agent/config-state.test.ts +++ b/packages/agent-core/test/agent/config-state.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it, vi } from 'vitest'; import { emptyUsage } from '@moonshot-ai/kosong'; +import { InMemoryAgentRecordPersistence } from '../../src/agent/records'; import { ProviderManager } from '../../src/session/provider-manager'; -import type { KimiConfig } from '../../src/config'; +import { + applyEnvModelConfig, + ENV_MODEL_ALIAS_KEY, + getDefaultConfig, + type KimiConfig, +} from '../../src/config'; import { testAgent } from './harness'; import { createFakeKaos } from '../tools/fixtures/fake-kaos'; @@ -202,6 +208,22 @@ describe('ConfigState thinking clamp for always-thinking models', () => { maxContextSize: 128_000, capabilities: ['thinking'], }, + 'kimi-code/ultra': { + provider: 'kimi', + model: 'kimi-ultra', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'high', 'ultra'], + defaultEffort: 'ultra', + }, + 'kimi-code/standard': { + provider: 'kimi', + model: 'kimi-standard', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'mid', 'high'], + defaultEffort: 'mid', + }, }, }; return testAgent({ @@ -246,19 +268,53 @@ describe('ConfigState thinking clamp for always-thinking models', () => { ctx.agent.config.update({ modelAlias: 'kimi-code/deep' }); expect(ctx.agent.config.thinkingEffort).toBe('on'); }); + + it('falls back to the target default when a model switch carries an unsupported effort', () => { + const ctx = alwaysThinkingAgent(); + ctx.agent.config.update({ modelAlias: 'kimi-code/ultra', thinkingEffort: 'ultra' }); + + ctx.agent.config.update({ modelAlias: 'kimi-code/standard' }); + + expect(ctx.agent.config.thinkingEffort).toBe('mid'); + }); + + it('projects an inherited concrete effort to on when switching to a boolean model', () => { + const ctx = alwaysThinkingAgent(); + ctx.agent.config.update({ modelAlias: 'kimi-code/ultra', thinkingEffort: 'ultra' }); + + ctx.agent.config.update({ modelAlias: 'kimi-code/toggle' }); + + expect(ctx.agent.config.thinkingEffort).toBe('on'); + }); + + it('rejects an unsupported effort explicitly set on the current Kimi model', () => { + const ctx = alwaysThinkingAgent(); + ctx.agent.config.update({ modelAlias: 'kimi-code/standard' }); + + expect(() => { + ctx.agent.config.setThinkingEffort('ultra'); + }).toThrow( + 'Thinking effort "ultra" is not supported by model "kimi-code/standard"', + ); + }); }); describe('ConfigState.provider applies global KIMI_MODEL_* request config', () => { function kimiAgent() { - return testAgent({ - providerManager: new ProviderManager({ - config: { - providers: { kimi: { type: 'kimi', apiKey: 'test-key' } }, - models: { - 'kimi-code': { provider: 'kimi', model: 'kimi-code', maxContextSize: 128_000 }, - }, + const config: KimiConfig = { + providers: { kimi: { type: 'kimi', apiKey: 'test-key' } }, + models: { + 'kimi-code': { + provider: 'kimi', + model: 'kimi-code', + maxContextSize: 128_000, + capabilities: ['thinking'], }, - }), + }, + }; + return testAgent({ + initialConfig: config, + providerManager: new ProviderManager({ config }), }); } @@ -268,7 +324,12 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () = const config: KimiConfig = { providers: { kimi: { type: 'kimi', apiKey: 'test-key' } }, models: { - 'kimi-code': { provider: 'kimi', model: 'kimi-code', maxContextSize: 128_000 }, + 'kimi-code': { + provider: 'kimi', + model: 'kimi-code', + maxContextSize: 128_000, + capabilities: ['thinking'], + }, }, ...(keep !== undefined ? { thinking: { keep } } : {}), }; @@ -386,7 +447,7 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () = } }); - it('injects KIMI_MODEL_THINKING_EFFORT into config.provider when thinking is on', () => { + it('keeps the forced Kimi effort synchronized between state and provider', () => { vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'max'); try { const ctx = kimiAgent(); @@ -396,12 +457,110 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () = const gen = Reflect.get(provider as object, '_generationKwargs') as { extra_body?: { thinking?: { type?: string; effort?: string } }; }; + expect(ctx.agent.config.data().thinkingEffort).toBe('max'); + expect(provider.thinkingEffort).toBe('max'); expect(gen.extra_body?.thinking).toMatchObject({ type: 'enabled', effort: 'max' }); } finally { vi.unstubAllEnvs(); } }); + it('reports the forced effort for an env-synthesized boolean Kimi model', () => { + vi.stubEnv('KIMI_MODEL_NAME', 'kimi-for-coding'); + vi.stubEnv('KIMI_MODEL_API_KEY', 'test-key'); + vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'max'); + try { + const config = applyEnvModelConfig(getDefaultConfig()); + const persistence = new InMemoryAgentRecordPersistence(); + const ctx = testAgent({ + initialConfig: config, + persistence, + providerManager: new ProviderManager({ config }), + }); + + ctx.agent.config.update({ modelAlias: ENV_MODEL_ALIAS_KEY }); + + expect(ctx.agent.config.data().thinkingEffort).toBe('max'); + expect(persistence.records).toContainEqual( + expect.objectContaining({ type: 'config.update', thinkingEffort: 'max' }), + ); + expect(ctx.agent.config.provider.thinkingEffort).toBe('max'); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('applies the Kimi force through an Anthropic protocol override', () => { + vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'max'); + try { + const config: KimiConfig = { + providers: { kimi: { type: 'kimi', apiKey: 'test-key' } }, + models: { + 'kimi-code-anthropic': { + provider: 'kimi', + protocol: 'anthropic', + model: 'kimi-code', + maxContextSize: 128_000, + capabilities: ['thinking'], + }, + }, + }; + const ctx = testAgent({ + initialConfig: config, + providerManager: new ProviderManager({ config }), + }); + + ctx.agent.config.update({ + modelAlias: 'kimi-code-anthropic', + thinkingEffort: 'high', + }); + + expect(ctx.agent.config.data().thinkingEffort).toBe('max'); + expect(ctx.agent.config.provider.thinkingEffort).toBe('max'); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('does not carry the Kimi force into a non-Kimi model switch', () => { + vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'max'); + try { + const config: KimiConfig = { + providers: { + kimi: { type: 'kimi', apiKey: 'test-key' }, + anthropic: { type: 'anthropic', apiKey: 'test-key' }, + }, + models: { + 'kimi-code': { + provider: 'kimi', + model: 'kimi-code', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + }, + claude: { + provider: 'anthropic', + model: 'claude-sonnet-4-6', + maxContextSize: 200_000, + capabilities: ['thinking'], + }, + }, + }; + const ctx = testAgent({ + initialConfig: config, + providerManager: new ProviderManager({ config }), + }); + ctx.agent.config.update({ modelAlias: 'kimi-code', thinkingEffort: 'high' }); + + ctx.agent.config.update({ modelAlias: 'claude' }); + + expect(ctx.agent.config.data().thinkingEffort).toBe('high'); + expect(ctx.agent.config.provider.thinkingEffort).toBe('high'); + } finally { + vi.unstubAllEnvs(); + } + }); + it('does NOT inject KIMI_MODEL_THINKING_EFFORT into config.provider when thinking is off', () => { vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'max'); try { @@ -412,6 +571,8 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () = const gen = Reflect.get(provider as object, '_generationKwargs') as { extra_body?: { thinking?: { effort?: string } }; }; + expect(ctx.agent.config.data().thinkingEffort).toBe('off'); + expect(provider.thinkingEffort).toBe('off'); expect(gen.extra_body?.thinking?.effort).toBeUndefined(); } finally { vi.unstubAllEnvs(); diff --git a/packages/agent-core/test/agent/config.test.ts b/packages/agent-core/test/agent/config.test.ts index 59e75007aa..ecaed3318f 100644 --- a/packages/agent-core/test/agent/config.test.ts +++ b/packages/agent-core/test/agent/config.test.ts @@ -57,7 +57,7 @@ describe('Agent config', () => { await expect(ctx.rpc.getConfig({})).resolves.toMatchObject({ provider: nextProvider, systemPrompt: 'Changed profile prompt.', - thinkingEffort: 'high', + thinkingEffort: 'on', modelCapabilities: nextCapability, }); await ctx.expectResumeMatches(); @@ -164,8 +164,8 @@ describe('Agent config', () => { ctx.mockNextResponse({ type: 'text', text: 'Still using the original turn config.' }); expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(` [wire] permission.record_approval_result { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf original-result", "result": { "decision": "approved", "selectedLabel": "approve" }, "time": "