From 9707b682b0b47f5edfbd4a810a202c44da76f38e Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 3 Aug 2026 19:19:34 +0800 Subject: [PATCH 1/3] feat(config): add deprecation mechanism and rename loop retry limit - agent-core-v2 config: declarative section `deprecations` (deprecated TOML keys are ignored and report a warning diagnostic; the file is never rewritten) and env binding `deprecatedEnv` (old var still resolves as a fallback with a warning), surfaced via the new `IConfigService.onDidChangeDiagnostics` event - loop_control: rename `max_retries_per_step` to `max_attempts_per_step` and `KIMI_LOOP_MAX_RETRIES_PER_STEP` to `KIMI_LOOP_MAX_ATTEMPTS_PER_STEP`; `max_steps_per_run` moves onto the same mechanism (no longer silently mapped) - kap-server: push the global `event.config.warning` WS event to every connection whenever the config warning set changes - TUI: show config diagnostics in warning yellow at startup instead of the dim startup notice - docs: config-files/env-vars (en+zh), regenerated config manifest, and the agent-core-dev config guide --- .agents/skills/agent-core-dev/config.md | 28 ++- .changeset/kap-server-config-warning-event.md | 5 + .../loop-control-attempt-limit-rename.md | 5 + apps/kimi-code/src/cli/run-shell.ts | 7 +- apps/kimi-code/src/tui/kimi-tui.ts | 4 + apps/kimi-code/test/cli/run-shell.test.ts | 7 +- docs/en/configuration/config-files.md | 10 +- docs/en/configuration/env-vars.md | 2 +- docs/zh/configuration/config-files.md | 10 +- docs/zh/configuration/env-vars.md | 2 +- .../agent-core-v2/docs/config-manifest.toml | 9 +- .../scripts/gen-config-manifest.mts | 11 + .../src/agent/loop/configSection.ts | 44 ++-- .../src/agent/stepRetry/stepRetryService.ts | 2 +- .../agent-core-v2/src/app/config/config.ts | 62 ++++- .../src/app/config/configService.ts | 137 +++++++++-- .../src/app/config/deprecations.ts | 41 ++++ .../app/skillCatalog/builtin/update-config.md | 9 +- .../test/agent/goal/goal.test.ts | 2 +- .../test/agent/stepRetry/stepRetry.test.ts | 4 +- .../test/app/config/config.test.ts | 227 ++++++++++++++++-- packages/agent-core-v2/test/kosong/stubs.ts | 3 + .../src/skill/builtin/update-config.md | 9 +- .../kap-server/src/protocol/events-zod.ts | 10 + packages/kap-server/src/start.ts | 35 +++ .../kap-server/src/transport/ws/v1/events.ts | 18 ++ .../ws/v1/sessionEventBroadcaster.ts | 46 +++- .../test/sessionEventBroadcaster.test.ts | 43 ++++ .../test/e2e/invalid-input-matrix.test.ts | 4 +- 29 files changed, 700 insertions(+), 96 deletions(-) create mode 100644 .changeset/kap-server-config-warning-event.md create mode 100644 .changeset/loop-control-attempt-limit-rename.md create mode 100644 packages/agent-core-v2/src/app/config/deprecations.ts diff --git a/.agents/skills/agent-core-dev/config.md b/.agents/skills/agent-core-dev/config.md index 2eb48516eb..6529924c11 100644 --- a/.agents/skills/agent-core-dev/config.md +++ b/.agents/skills/agent-core-dev/config.md @@ -152,15 +152,18 @@ registerSection('providers', ProvidersSectionSchema, { ``` Each field is an `EnvBinding` — a string (env var name) or -`{ env, parse?, default? }`. IConfig resolves every field by +`{ env, deprecatedEnv?, parse?, default? }`. IConfig resolves every field by `env > config.toml > default`, sets it on the effective value, and validates the section. Empty nested entries (no field resolved) are omitted, so a synthetic entry like `__kimi_env__` only appears when at least one of its env vars is set. +When `deprecatedEnv` is set and `env` itself is absent or fails `parse`, the +deprecated var still supplies the value and a warning diagnostic is reported — +use it to rename an env var without breaking existing setups. `stripEnv(value, raw?, getEnv?)` removes env-derived fields before `set`/`replace` persists, so env overrides never leak into `config.toml`. `raw` is the section's -env-free camelCase base (already `fromToml`-normalized, so legacy key renames -are honored), and `getEnv` reads the live env bag. For fields that are **both +env-free camelCase base (already `fromToml`-normalized), and `getEnv` reads the +live env bag. For fields that are **both user-persistable and env-overridable**, register `stripEnv: stripEnvBoundFields(sectionEnvBindings)` (from `#/app/config/config`) — it derives the guard from the same bindings the read path uses: while a @@ -231,7 +234,7 @@ This means registration order is never a correctness concern — you do not need `config.toml` stores keys in **snake_case**; in-memory values are **camelCase**. `ConfigService` converts both ways by dispatching to each section's registered transform: -- **Read**: `transformTomlData(fileData, registry)` maps each top-level key to a domain and applies that domain's `fromToml` hook (or a plain key-casing pass when none is registered). Owner domains register their own normalization — e.g. provider `oauth`/`env`/`customHeaders`, permission `deny/allow/ask` → `rules`, `loop_control.max_steps_per_run` → `maxStepsPerTurn`, `experimental` keys preserved verbatim. When a section registers after the initial load, `ConfigService` re-applies its `fromToml` against the preserved snake_case raw value (see "Late registration"), so registration order is never a correctness concern. +- **Read**: `transformTomlData(fileData, registry)` maps each top-level key to a domain and applies that domain's `fromToml` hook (or a plain key-casing pass when none is registered). Owner domains register their own normalization — e.g. provider `oauth`/`env`/`customHeaders`, permission `deny/allow/ask` → `rules`, `experimental` keys preserved verbatim. When a section registers after the initial load, `ConfigService` re-applies its `fromToml` against the preserved snake_case raw value (see "Late registration"), so registration order is never a correctness concern. - **Write**: `applySectionToToml(rawSnake, domain, value, registry)` applies the domain's `toToml` hook (or a plain camelCase→snake_case mapping) into a raw clone of the file, preserving unknown top-level keys and unknown sub-fields (lossless round-trip). `ConfigService` keeps four views: @@ -241,6 +244,23 @@ This means registration order is never a correctness concern — you do not need - `validated` — validated `raw`, env-free; the base every live env re-application starts from, so a degraded or removed env value falls back to the file instead of a stale overlay. - `effective` — `validated` plus the env overlay, recomputed on load/set; `get()`/`getAll()` re-apply the overlay on a fresh `validated` copy per read rather than caching it. +### Renaming config keys and env vars (deprecations) + +Renames are declared once on the section, never hand-rolled in `fromToml`: + +```ts +registerSection(MY_SECTION, MySectionSchema, { + deprecations: [{ key: 'old_key', replacement: 'new_key' }], // snake_case, on-disk + env: envBindings(MySectionSchema, { + newKey: { env: 'KIMI_NEW_KEY', deprecatedEnv: 'KIMI_OLD_KEY', parse }, + }), +}); +``` + +- A deprecated TOML key is **ignored** (its value no longer applies — the schema only knows the new key) and reports a warning `ConfigDiagnostic` while present; the file is never rewritten, so the warning is the migration guide. Diagnostics are recomputed on every load/reload and surface to clients via `IConfigService.diagnostics()` and `onDidChangeDiagnostics` (kap-server republishes them as the global `event.config.warning` WS event). +- A deprecated env var still **resolves** as a fallback (new var first), with the same warning treatment, and `stripEnvBoundFields` treats it as env-owned for writes. +- See `src/agent/loop/configSection.ts` for a worked example (`max_retries_per_step` → `max_attempts_per_step`). + ### `KIMI_MODEL_*` env overlay When `KIMI_MODEL_NAME` is set, the `kosongConfig` wrapper's `kimiModelEnvOverlay` (`src/app/kosongConfig/envOverlay.ts`) injects a reserved model alias (`__kimi_env_model__`) into `effective`, points `defaultModel` at it, and merges the request `modelOverrides`; the reserved provider (`__kimi_env__`) comes from the `providers` section env bindings. The overlay is registered via `IConfigRegistry.registerEffectiveOverlay` and applied **only to `effective`**, never to `rawSnake`, so it is never persisted. Its `strip` (plus the providers section `stripEnv`) is the final guard so a caller that read `effective` (with the overlay) cannot write the reserved entries or the shell API key back to disk. `config` itself only runs registered overlays — it does not know the `KIMI_MODEL_*` semantics. diff --git a/.changeset/kap-server-config-warning-event.md b/.changeset/kap-server-config-warning-event.md new file mode 100644 index 0000000000..7312d1e3e2 --- /dev/null +++ b/.changeset/kap-server-config-warning-event.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kap-server": patch +--- + +Add the global `event.config.warning` WebSocket event that pushes the current set of config warnings (deprecated config keys or environment variables in use) to every connection whenever it changes. diff --git a/.changeset/loop-control-attempt-limit-rename.md b/.changeset/loop-control-attempt-limit-rename.md new file mode 100644 index 0000000000..ec6a0fc9ef --- /dev/null +++ b/.changeset/loop-control-attempt-limit-rename.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Rename the `[loop_control] max_retries_per_step` config key to `max_attempts_per_step` and `max_steps_per_run` to `max_steps_per_turn`: on the v2 engine the old keys no longer take effect and a startup warning prompts the rename in `config.toml`. The `KIMI_LOOP_MAX_RETRIES_PER_STEP` env var is likewise deprecated in favor of `KIMI_LOOP_MAX_ATTEMPTS_PER_STEP` but keeps working with a warning. diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 84ad5897bb..3e1a9b89c2 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -26,7 +26,6 @@ import { loadTuiConfig, TuiConfigParseError } from '#/tui/config'; import { CHROME_GUTTER } from '#/tui/constant/rendering'; import { KimiTUI } from '#/tui/index'; import { currentTheme, getColorPalette } from '#/tui/theme'; -import { combineStartupNotice } from '#/tui/utils/startup'; import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; import { restoreTerminalModes } from '#/utils/terminal-restore'; @@ -108,9 +107,9 @@ export async function runShell( return; } const config = await harness.getConfig(); - for (const warning of (await harness.getConfigDiagnostics()).warnings) { - configWarning = combineStartupNotice(configWarning, warning); - } + // Config diagnostics (deprecated keys, invalid sections, ...) are surfaced + // by the TUI itself at `finishStartup` via `showConfigWarningsIfAny` — + // folded into the dim startup notice they were too easy to miss. const configMs = Date.now() - configStartedAt; // Resolve --agent/--agent-file once for the startup session; validateOptions // has already rejected them alongside --session/--continue. diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 834bd77771..c64c65dff4 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -703,6 +703,10 @@ export class KimiTUI { this.startupNotice = undefined; } void this.showTmuxKeyboardWarningIfNeeded(); + // Config diagnostics (deprecated keys/env vars, invalid sections) in + // warning yellow at boot; `run-prompt`/`run-v2-print` print them to + // stderr for non-interactive runs. + void this.showConfigWarningsIfAny(); if (this.state.startupState === 'picker') { void this.bootstrapFromPicker(); return; diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index 8f0c17d9d7..5f0dc3f04d 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -595,7 +595,7 @@ describe('runShell', () => { }); }); - it('forwards config.toml diagnostics as startup notices', async () => { + it('leaves config.toml diagnostics to the TUI instead of the startup notice', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', editorCommand: null, @@ -623,9 +623,12 @@ describe('runShell', () => { '1.2.3-test', ); + // Diagnostics render in warning yellow via `showConfigWarningsIfAny` at + // `finishStartup`; the (dim) startup notice stays reserved for things like + // tui.toml parse errors, so the same warning is not shown twice. const [, , startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!; expect(startupInput).toMatchObject({ - startupNotice: 'Ignored invalid config in config.toml: loop_control.', + startupNotice: undefined, }); }); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 23633b2939..08ff88e999 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -61,7 +61,7 @@ effort = "high" keep = "all" [loop_control] -max_retries_per_step = 10 +max_attempts_per_step = 10 reserved_context_size = 50000 [background] @@ -235,18 +235,20 @@ When the experiment is enabled, the configuration is validated as the session st | --- | --- | --- | | `default_thinking` | 0.21.0 | Top-level boolean, replaced by `[thinking] enabled`. Migrate `default_thinking = true` to `enabled = true`, and `default_thinking = false` to `enabled = false`. | | `thinking.mode` | 0.21.0 | One of `auto` / `on` / `off`, replaced by `[thinking] enabled`. `mode = "off"` becomes `enabled = false`; `mode = "on"` and `mode = "auto"` are equivalent to `enabled = true` (the default) and can be removed. | +| `loop_control.max_retries_per_step` | 0.32.0 | Replaced by `loop_control.max_attempts_per_step` (the value was always a total-attempt limit, including the first try). The old key is ignored and reports a warning on startup; rename it in `config.toml`. | +| `loop_control.max_steps_per_run` | 0.32.0 | Replaced by `loop_control.max_steps_per_turn`. The old key is ignored and reports a warning on startup; rename it in `config.toml`. | ## `loop_control` -`loop_control` governs the step count limit, per-step retry count, and the threshold that triggers automatic context compaction in the Agent execution loop. +`loop_control` governs the step count limit, the per-step attempt limit, and the threshold that triggers automatic context compaction in the Agent execution loop. | Field | Type | Default | Description | | --- | --- | --- | --- | | `max_steps_per_turn` | `integer` | — | Maximum steps per turn; unset or `0` means unlimited | -| `max_retries_per_step` | `integer` | `10` | Maximum retries after a step failure | +| `max_attempts_per_step` | `integer` | `10` | Maximum total attempts for a failing step, including the initial attempt | | `reserved_context_size` | `integer` | — | Number of tokens reserved for model output; automatic compaction is triggered when the remaining context window falls below this value | -`max_steps_per_turn` can be overridden by the `KIMI_LOOP_MAX_STEPS_PER_TURN` environment variable, and `max_retries_per_step` by `KIMI_LOOP_MAX_RETRIES_PER_STEP`; both take higher priority than the config file. +`max_steps_per_turn` can be overridden by the `KIMI_LOOP_MAX_STEPS_PER_TURN` environment variable, and `max_attempts_per_step` by `KIMI_LOOP_MAX_ATTEMPTS_PER_STEP`; both take higher priority than the config file. The former `KIMI_LOOP_MAX_RETRIES_PER_STEP` variable is deprecated but still honored (with a startup warning) when the new one is unset. Retries only apply to transient failures — connection errors, timeouts, HTTP 429 rate limits, and 5xx server errors. A 429 caused by an exhausted quota or insufficient account balance is not retried and fails immediately, since it cannot succeed until the account is recharged. diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 50e3c96898..bf86651c00 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -134,7 +134,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_MCP_STARTUP_TIMEOUT_MS` | Global default connection timeout (ms) for all MCP servers; takes higher priority than `[mcp] startup_timeout_ms` in `config.toml`, but a per-server `startupTimeoutMs` in `mcp.json` still wins (default `30000`) | Integer from `1` to `2147483647`; invalid values are ignored | | `KIMI_MCP_TOOL_TIMEOUT_MS` | Global default single tool-call timeout (ms) for all MCP servers; takes higher priority than `[mcp] tool_timeout_ms` in `config.toml`, but a per-server `toolTimeoutMs` in `mcp.json` still wins (default `60000`) | Integer from `1` to `2147483647`; invalid values are ignored | | `KIMI_LOOP_MAX_STEPS_PER_TURN` | Maximum Agent steps per turn; takes higher priority than `[loop_control] max_steps_per_turn` in `config.toml` (unset or `0` means unlimited) | Non-negative integer; invalid values are ignored | -| `KIMI_LOOP_MAX_RETRIES_PER_STEP` | Maximum retries after a step failure; takes higher priority than `[loop_control] max_retries_per_step` in `config.toml` (default `10`) | Non-negative integer; invalid values are ignored | +| `KIMI_LOOP_MAX_ATTEMPTS_PER_STEP` | Maximum total attempts for a failing step (including the initial attempt); takes higher priority than `[loop_control] max_attempts_per_step` in `config.toml` (default `10`). The deprecated `KIMI_LOOP_MAX_RETRIES_PER_STEP` is still honored with a warning when this variable is unset | Non-negative integer; invalid values are ignored | | `KIMI_WEB_SEARCH_BASE_URL` | API URL of the web search (`WebSearch`) service; takes higher priority than `[services.moonshot_search] base_url` in `config.toml`, and enables the service without that config section. Persisted credentials and custom headers are not forwarded to an env-selected endpoint | Non-blank string; blank values are ignored | | `KIMI_WEB_SEARCH_API_KEY` | API key of the web search (`WebSearch`) service; replaces both the configured API key and OAuth credential when set | Non-blank string; blank values are ignored | | `KIMI_WEB_FETCH_BASE_URL` | API URL of the web fetch (`FetchURL`) service; takes higher priority than `[services.moonshot_fetch] base_url`. Persisted credentials and custom headers are not forwarded to an env-selected endpoint. Without an env or config endpoint, signed-in users try the managed Kimi OAuth fetch service before direct local requests | Non-blank string; blank values are ignored | diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index aaeea89b25..e210d47e0a 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -61,7 +61,7 @@ effort = "high" keep = "all" [loop_control] -max_retries_per_step = 10 +max_attempts_per_step = 10 reserved_context_size = 50000 [background] @@ -235,18 +235,20 @@ max_output_size = 8192 | --- | --- | --- | | `default_thinking` | 0.21.0 | 顶层布尔值,由 `[thinking] enabled` 取代。将 `default_thinking = true` 迁移为 `enabled = true`,`default_thinking = false` 迁移为 `enabled = false`。 | | `thinking.mode` | 0.21.0 | 可选值 `auto` / `on` / `off`,由 `[thinking] enabled` 取代。`mode = "off"` 改为 `enabled = false`;`mode = "on"` 和 `mode = "auto"` 等价于 `enabled = true`(默认值),可删除该行。 | +| `loop_control.max_retries_per_step` | 0.32.0 | 由 `loop_control.max_attempts_per_step` 取代(该值本来就是含首次尝试的总尝试次数上限)。旧 key 不再生效,启动时会给出警告,请在 `config.toml` 中手动改名。 | +| `loop_control.max_steps_per_run` | 0.32.0 | 由 `loop_control.max_steps_per_turn` 取代。旧 key 不再生效,启动时会给出警告,请在 `config.toml` 中手动改名。 | ## `loop_control` -`loop_control` 控制 Agent 执行循环的步数上限、单步重试次数,以及触发上下文自动压缩的阈值。 +`loop_control` 控制 Agent 执行循环的步数上限、单步尝试次数上限,以及触发上下文自动压缩的阈值。 | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `max_steps_per_turn` | `integer` | — | 单轮最大步数;不设或设为 `0` 则无上限 | -| `max_retries_per_step` | `integer` | `10` | 单步失败后的最大重试次数 | +| `max_attempts_per_step` | `integer` | `10` | 单步失败后的最大总尝试次数(含首次尝试) | | `reserved_context_size` | `integer` | — | 预留给模型输出的 token 数;上下文窗口剩余量低于此值时触发自动压缩 | -`max_steps_per_turn` 可被环境变量 `KIMI_LOOP_MAX_STEPS_PER_TURN` 覆盖,`max_retries_per_step` 可被 `KIMI_LOOP_MAX_RETRIES_PER_STEP` 覆盖,优先级均高于配置文件。 +`max_steps_per_turn` 可被环境变量 `KIMI_LOOP_MAX_STEPS_PER_TURN` 覆盖,`max_attempts_per_step` 可被 `KIMI_LOOP_MAX_ATTEMPTS_PER_STEP` 覆盖,优先级均高于配置文件。旧的 `KIMI_LOOP_MAX_RETRIES_PER_STEP` 已废弃,但在新变量未设置时仍生效(启动时会给出警告)。 重试仅针对瞬时故障——连接错误、超时、HTTP 429 限流和 5xx 服务端错误。账户额度耗尽或余额不足导致的 429 不会重试,会立即失败:在充值之前重试不可能成功。 diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 40a31d6c31..b127e525b8 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -134,7 +134,7 @@ kimi | `KIMI_MCP_STARTUP_TIMEOUT_MS` | 所有 MCP server 的全局默认连接超时(毫秒);优先级高于 `config.toml` 的 `[mcp] startup_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `startupTimeoutMs`(默认 `30000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | | `KIMI_MCP_TOOL_TIMEOUT_MS` | 所有 MCP server 的全局默认单次工具调用超时(毫秒);优先级高于 `config.toml` 的 `[mcp] tool_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `toolTimeoutMs`(默认 `60000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | | `KIMI_LOOP_MAX_STEPS_PER_TURN` | Agent 单轮最大步数;优先级高于 `config.toml` 的 `[loop_control] max_steps_per_turn`(不设或 `0` 表示无上限) | 非负整数;非法值被忽略 | -| `KIMI_LOOP_MAX_RETRIES_PER_STEP` | 单步失败后的最大重试次数;优先级高于 `config.toml` 的 `[loop_control] max_retries_per_step`(默认 `10`) | 非负整数;非法值被忽略 | +| `KIMI_LOOP_MAX_ATTEMPTS_PER_STEP` | 单步失败后的最大总尝试次数(含首次尝试);优先级高于 `config.toml` 的 `[loop_control] max_attempts_per_step`(默认 `10`)。旧的 `KIMI_LOOP_MAX_RETRIES_PER_STEP` 已废弃,但在本变量未设置时仍生效并给出警告 | 非负整数;非法值被忽略 | | `KIMI_WEB_SEARCH_BASE_URL` | 网页搜索(`WebSearch`)服务的 API URL;优先级高于 `config.toml` 的 `[services.moonshot_search] base_url`,未写配置段时也可启用服务。文件中持久化的凭据和自定义 header 不会发送到环境变量指定的端点 | 非空字符串;空白值被忽略 | | `KIMI_WEB_SEARCH_API_KEY` | 网页搜索(`WebSearch`)服务的 API 密钥;设置后同时替换配置中的 API 密钥和 OAuth 凭据 | 非空字符串;空白值被忽略 | | `KIMI_WEB_FETCH_BASE_URL` | 网页抓取(`FetchURL`)服务的 API URL;优先级高于 `[services.moonshot_fetch] base_url`。文件中持久化的凭据和自定义 header 不会发送到环境变量指定的端点。环境变量和配置都没有指定端点时,已登录用户会先尝试 Kimi OAuth 托管抓取服务,再回退到本地直接请求 | 非空字符串;空白值被忽略 | diff --git a/packages/agent-core-v2/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml index 2fdf45d6d0..3be81759ea 100644 --- a/packages/agent-core-v2/docs/config-manifest.toml +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -152,15 +152,18 @@ extra_skill_dirs = [] # loopControl (config.toml: loop_control) # owner: src/agent/loop/configSection.ts # scope: core -# hooks: custom fromToml · custom toToml · stripEnv +# hooks: custom toToml · stripEnv +# deprecations (old key is ignored + warns; rename manually): +# max_retries_per_step -> max_attempts_per_step +# max_steps_per_run -> max_steps_per_turn # env: # max_steps_per_turn <- KIMI_LOOP_MAX_STEPS_PER_TURN (custom parse) -# max_retries_per_step <- KIMI_LOOP_MAX_RETRIES_PER_STEP (custom parse) +# max_attempts_per_step <- KIMI_LOOP_MAX_ATTEMPTS_PER_STEP (custom parse; deprecated fallback KIMI_LOOP_MAX_RETRIES_PER_STEP) # ########################################################################## [loop_control] # max_steps_per_turn: integer -# max_retries_per_step: integer +# max_attempts_per_step: integer # max_ralph_iterations: integer # reserved_context_size: integer # compaction_trigger_ratio: number diff --git a/packages/agent-core-v2/scripts/gen-config-manifest.mts b/packages/agent-core-v2/scripts/gen-config-manifest.mts index 01662774d1..5853bce215 100644 --- a/packages/agent-core-v2/scripts/gen-config-manifest.mts +++ b/packages/agent-core-v2/scripts/gen-config-manifest.mts @@ -119,6 +119,7 @@ interface EnvRow { /** Property access shape of an `EnvBinding` object (avoids index-signature access). */ interface EnvBindingFields { readonly env?: unknown; + readonly deprecatedEnv?: unknown; readonly parse?: unknown; readonly default?: unknown; } @@ -133,6 +134,9 @@ function flattenEnvBindings(bindings: unknown, path: string[] = []): EnvRow[] { const detail: string[] = []; if (binding.parse !== undefined) detail.push('custom parse'); if (binding.default !== undefined) detail.push(`default ${JSON.stringify(binding.default)}`); + if (typeof binding.deprecatedEnv === 'string') { + detail.push(`deprecated fallback ${binding.deprecatedEnv}`); + } return [{ field: path.join('.'), env: binding.env, detail: detail.join('; ') }]; } return Object.entries(bindings).flatMap(([key, value]) => flattenEnvBindings(value, [...path, key])); @@ -278,6 +282,13 @@ function renderSection(section: ConfigSectionContribution, owner: string | undef if (options.toToml !== undefined) hooks.push('custom toToml'); if (options.stripEnv !== undefined) hooks.push('stripEnv'); if (hooks.length > 0) lines.push(`# hooks: ${hooks.join(' · ')}`); + const deprecations = options.deprecations ?? []; + if (deprecations.length > 0) { + lines.push('# deprecations (old key is ignored + warns; rename manually):'); + for (const deprecation of deprecations) { + lines.push(`# ${deprecation.key} -> ${deprecation.replacement}`); + } + } const envRows = flattenEnvBindings(options.env); if (envRows.length > 0) { lines.push('# env:'); diff --git a/packages/agent-core-v2/src/agent/loop/configSection.ts b/packages/agent-core-v2/src/agent/loop/configSection.ts index 0c0cc76737..d169a48305 100644 --- a/packages/agent-core-v2/src/agent/loop/configSection.ts +++ b/packages/agent-core-v2/src/agent/loop/configSection.ts @@ -3,12 +3,17 @@ * TOML transforms. * * Owns the `[loop_control]` configuration section (step / retry / context-size - * limits), plus the snake_case ↔ camelCase TOML transforms (including - * the legacy `max_steps_per_run` → `maxStepsPerTurn` rename). The step and retry - * budgets also accept operational env overrides (`KIMI_LOOP_MAX_STEPS_PER_TURN` - * / `KIMI_LOOP_MAX_RETRIES_PER_STEP`); `config` resolves each field as - * `env > config.toml > default` and re-applies the env binding on every read. - * Self-registered at module load via `registerConfigSection`. + * limits). Renamed keys are declared through the config domain's deprecation + * mechanism (`deprecations`): a deprecated key in `config.toml` no longer + * applies and reports a warning pointing at its replacement — this covers the + * `max_retries_per_step` → `max_attempts_per_step` rename and the older + * `max_steps_per_run` → `max_steps_per_turn` one. The step and retry budgets + * also accept operational env overrides (`KIMI_LOOP_MAX_STEPS_PER_TURN` / + * `KIMI_LOOP_MAX_ATTEMPTS_PER_STEP`; the former + * `KIMI_LOOP_MAX_RETRIES_PER_STEP` still resolves as a deprecated fallback + * with a warning); `config` resolves each field as `env > config.toml > + * default` and re-applies the env binding on every read. Self-registered at + * module load via `registerConfigSection`. * * While a field's env var is set, `stripEnvBoundFields` restores its env-free * raw value before `set`/`replace` persists, so an env override echoed @@ -19,16 +24,18 @@ import { z } from 'zod'; import { type EnvBindings, envBindings, stripEnvBoundFields } from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; -import { plainObjectToToml, transformPlainObject } from '#/app/config/toml'; +import { plainObjectToToml } from '#/app/config/toml'; export const LOOP_CONTROL_SECTION = 'loopControl'; export const LOOP_MAX_STEPS_PER_TURN_ENV = 'KIMI_LOOP_MAX_STEPS_PER_TURN'; +export const LOOP_MAX_ATTEMPTS_PER_STEP_ENV = 'KIMI_LOOP_MAX_ATTEMPTS_PER_STEP'; +/** Deprecated former name of {@link LOOP_MAX_ATTEMPTS_PER_STEP_ENV}. */ export const LOOP_MAX_RETRIES_PER_STEP_ENV = 'KIMI_LOOP_MAX_RETRIES_PER_STEP'; export const LoopControlSchema = z.object({ maxStepsPerTurn: z.number().int().min(0).optional(), - maxRetriesPerStep: z.number().int().min(0).optional(), + maxAttemptsPerStep: z.number().int().min(0).optional(), maxRalphIterations: z.number().int().min(-1).optional(), reservedContextSize: z.number().int().min(0).optional(), compactionTriggerRatio: z.number().min(0.5).max(0.99).optional(), @@ -45,29 +52,26 @@ function parseNonNegativeInt(raw: string): number | undefined { export const loopControlEnvBindings: EnvBindings = envBindings(LoopControlSchema, { maxStepsPerTurn: { env: LOOP_MAX_STEPS_PER_TURN_ENV, parse: parseNonNegativeInt }, - maxRetriesPerStep: { env: LOOP_MAX_RETRIES_PER_STEP_ENV, parse: parseNonNegativeInt }, + maxAttemptsPerStep: { + env: LOOP_MAX_ATTEMPTS_PER_STEP_ENV, + deprecatedEnv: LOOP_MAX_RETRIES_PER_STEP_ENV, + parse: parseNonNegativeInt, + }, }); export const stripLoopControlEnv = stripEnvBoundFields(loopControlEnvBindings); -export const loopControlFromToml = (rawSnake: unknown): unknown => { - if (rawSnake === null || typeof rawSnake !== 'object' || Array.isArray(rawSnake)) return rawSnake; - const out = transformPlainObject(rawSnake as Record); - if (out['maxStepsPerTurn'] === undefined && out['maxStepsPerRun'] !== undefined) { - out['maxStepsPerTurn'] = out['maxStepsPerRun']; - } - delete out['maxStepsPerRun']; - return out; -}; - export const loopControlToToml = (value: unknown, rawSnake: unknown): unknown => { if (value === null || typeof value !== 'object' || Array.isArray(value)) return value; return plainObjectToToml(value as Record, rawSnake); }; registerConfigSection(LOOP_CONTROL_SECTION, LoopControlSchema, { - fromToml: loopControlFromToml, toToml: loopControlToToml, env: loopControlEnvBindings, stripEnv: stripLoopControlEnv, + deprecations: [ + { key: 'max_retries_per_step', replacement: 'max_attempts_per_step' }, + { key: 'max_steps_per_run', replacement: 'max_steps_per_turn' }, + ], }); diff --git a/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts b/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts index c2f4fb9bbe..70cb2aa6fe 100644 --- a/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts +++ b/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts @@ -127,7 +127,7 @@ export class AgentStepRetryService extends Disposable implements IAgentStepRetry this.failedAttempts += 1; const maxAttempts = Math.max( - this.config.get(LOOP_CONTROL_SECTION)?.maxRetriesPerStep ?? + this.config.get(LOOP_CONTROL_SECTION)?.maxAttemptsPerStep ?? DEFAULT_MAX_RETRY_ATTEMPTS, 1, ); diff --git a/packages/agent-core-v2/src/app/config/config.ts b/packages/agent-core-v2/src/app/config/config.ts index f51d61259b..c0d668b7d0 100644 --- a/packages/agent-core-v2/src/app/config/config.ts +++ b/packages/agent-core-v2/src/app/config/config.ts @@ -14,13 +14,18 @@ * binding's `parse` is ignored. `stripEnvBoundFields` builds the matching * write guard for persistable env-bound fields: while a field's env var * resolves to a value, `set`/`replace` restores the field's value from the - * env-free raw base (already `fromToml`-normalized, so legacy key renames are - * honored) — or drops it when absent there — instead of persisting an echoed - * env value; otherwise writes pass through untouched. When nothing + * env-free raw base (already `fromToml`-normalized) — or drops it when absent + * there — instead of persisting an echoed env value; otherwise writes pass + * through untouched. When nothing * persistable remains, the write is a no-op for the section — the env-free * raw base is kept as-is (unknown forward-compatible fields survive repeated * stripped writes) — and the section is cleared only when the base is empty, * so registered defaults keep applying. + * + * Sections declare key renames through `deprecations` and env-var renames + * through a binding's `deprecatedEnv`: a deprecated TOML key is ignored (its + * value no longer applies) and a deprecated env var still resolves as a + * fallback; both surface warning `ConfigDiagnostic`s while in use. */ import type { Event } from '#/_base/event'; @@ -38,10 +43,29 @@ export type EnvBinding = | string | { readonly env: string; + /** + * Deprecated former name of `env`. Still honored (with a deprecation + * warning) when `env` itself is absent or fails to parse, so existing + * setups keep working until the user renames the variable. + */ + readonly deprecatedEnv?: string; readonly parse?: (raw: string) => unknown; readonly default?: unknown; }; +/** + * A declared config-key rename: `key` (snake_case, as written on disk) is + * deprecated in favor of `replacement`. While the old key is present in the + * user's config file the service reports a warning diagnostic; the old value + * is NOT honored — only `replacement` (or the section default) applies. + */ +export interface ConfigKeyDeprecation { + readonly key: string; + readonly replacement: string; + /** Optional extra guidance appended to the generated warning message. */ + readonly message?: string; +} + export type EnvBindings = EnvBinding | { [K in keyof T]?: EnvBinding | EnvBindings }; export type AnyEnvBindings = EnvBinding | { readonly [key: string]: EnvBinding | AnyEnvBindings }; @@ -68,10 +92,7 @@ export function stripEnvBoundFields(bindings: EnvBindings): ConfigStripEnv let out: Record | undefined; for (const [field, binding] of Object.entries(bindings)) { if (binding === undefined || !isEnvBinding(binding)) continue; - const rawEnv = getEnv(typeof binding === 'string' ? binding : binding.env); - if (rawEnv === undefined) continue; - const parse = typeof binding === 'string' ? undefined : binding.parse; - if (parse !== undefined && parse(rawEnv) === undefined) continue; + if (!resolvesFromEnv(binding, getEnv)) continue; out ??= { ...(value as Record) }; if (base[field] !== undefined) { out[field] = base[field]; @@ -85,6 +106,25 @@ export function stripEnvBoundFields(bindings: EnvBindings): ConfigStripEnv }; } +/** + * Whether a leaf binding currently resolves from the environment: the primary + * var wins when set and parseable, then the deprecated fallback (same rule as + * the read path in `configService`'s `resolveBinding`). + */ +function resolvesFromEnv(binding: EnvBinding, getEnv: (name: string) => string | undefined): boolean { + const parse = typeof binding === 'string' ? undefined : binding.parse; + const names = + typeof binding === 'string' + ? [binding] + : binding.deprecatedEnv === undefined + ? [binding.env] + : [binding.env, binding.deprecatedEnv]; + return names.some((name) => { + const raw = getEnv(name); + return raw !== undefined && (parse === undefined || parse(raw) !== undefined); + }); +} + export type ConfigFromToml = (rawSnake: unknown) => unknown; export type ConfigToToml = (value: unknown, rawSnake: unknown) => unknown; @@ -99,6 +139,7 @@ export interface ConfigSection { readonly stripEnv?: ConfigStripEnv; readonly fromToml?: ConfigFromToml; readonly toToml?: ConfigToToml; + readonly deprecations?: readonly ConfigKeyDeprecation[]; } export interface RegisterSectionOptions { @@ -109,6 +150,7 @@ export interface RegisterSectionOptions { readonly stripEnv?: ConfigStripEnv; readonly fromToml?: ConfigFromToml; readonly toToml?: ConfigToToml; + readonly deprecations?: readonly ConfigKeyDeprecation[]; } export interface ConfigEffectiveOverlay { @@ -198,6 +240,12 @@ export interface IConfigService { readonly ready: Promise; readonly onDidChangeConfiguration: Event; readonly onDidSectionChange: Event; + /** + * Fired when the diagnostics list changes (load / reload / env overlay + * re-application), carrying the full current list — including an empty + * list when the last diagnostic clears. + */ + readonly onDidChangeDiagnostics: Event; get(domain: string): T; inspect(domain: string): ConfigInspectValue; getAll(): ResolvedConfig; diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index 8546c80c9b..56ea4d19e0 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -19,7 +19,13 @@ * `bootstrap`, persists the TOML document through the `storage` TOML * atomic-document store (reloading when the document changes on disk), and logs * through `log`. Late section / overlay registration re-validates the - * already-loaded raw value and re-runs overlays. Bound at App scope. + * already-loaded raw value and re-runs overlays. Section-declared key + * `deprecations` are detected from the on-disk document on every load and + * reported as warning diagnostics (the deprecated value is NOT applied, and + * the file is never rewritten); env-var renames declared via a binding's + * `deprecatedEnv` still resolve as a fallback, likewise with a warning. + * Diagnostics changes are published through `onDidChangeDiagnostics`. Bound + * at App scope. */ import { Disposable } from '#/_base/di/lifecycle'; @@ -57,6 +63,7 @@ import { import { deepEqual, deepMerge, describeUnknownError, isPlainObject } from './configPure'; import { getConfigSectionContributions } from './configSectionContributions'; import { getConfigOverlayContributions } from './configOverlayContributions'; +import { collectKeyDeprecations } from './deprecations'; import { migrateThinkingEffortMaxToHigh } from './migrations'; import { applySectionToToml, @@ -71,15 +78,42 @@ const CONFIG_SCOPE = ''; type GetEnv = (name: string) => string | undefined; +/** Reports a deprecated env var actually supplying a value: (oldName, newName). */ +type OnDeprecatedEnv = (oldName: string, newName: string) => void; + function isEnvBinding(value: unknown): value is EnvBinding { return typeof value === 'string' || (isPlainObject(value) && 'env' in value); } -function resolveBinding(binding: EnvBinding, getEnv: GetEnv, existing: unknown): unknown { - const envName = typeof binding === 'string' ? binding : binding.env; - const raw = getEnv(envName); - if (raw !== undefined) { - return typeof binding === 'string' ? raw : binding.parse ? binding.parse(raw) : raw; +function parseBoundRaw(binding: EnvBinding, raw: string): unknown { + return typeof binding === 'string' ? raw : binding.parse ? binding.parse(raw) : raw; +} + +function resolveBinding( + binding: EnvBinding, + getEnv: GetEnv, + existing: unknown, + onDeprecatedEnv?: OnDeprecatedEnv, +): unknown { + if (typeof binding !== 'string') { + const raw = getEnv(binding.env); + if (raw !== undefined) { + const parsed = parseBoundRaw(binding, raw); + if (parsed !== undefined) return parsed; + } + if (binding.deprecatedEnv !== undefined) { + const deprecatedRaw = getEnv(binding.deprecatedEnv); + if (deprecatedRaw !== undefined) { + const parsed = parseBoundRaw(binding, deprecatedRaw); + if (parsed !== undefined) { + onDeprecatedEnv?.(binding.deprecatedEnv, binding.env); + return parsed; + } + } + } + } else { + const raw = getEnv(binding); + if (raw !== undefined) return raw; } if (typeof binding === 'object' && binding.default !== undefined && existing === undefined) { return binding.default; @@ -91,17 +125,18 @@ function applyEnvBindings( target: Record, bindings: AnyEnvBindings, getEnv: GetEnv, + onDeprecatedEnv?: OnDeprecatedEnv, ): void { for (const [key, binding] of Object.entries(bindings)) { if (isEnvBinding(binding)) { - const resolved = resolveBinding(binding, getEnv, target[key]); + const resolved = resolveBinding(binding, getEnv, target[key], onDeprecatedEnv); if (resolved !== undefined) target[key] = resolved; } else if (binding !== undefined) { const child: Record = isPlainObject(target[key]) ? { ...target[key] } : {}; target[key] = child; - applyEnvBindings(child, binding as AnyEnvBindings, getEnv); + applyEnvBindings(child, binding as AnyEnvBindings, getEnv, onDeprecatedEnv); if (Object.keys(child).length === 0) { delete target[key]; } @@ -109,12 +144,17 @@ function applyEnvBindings( } } -function applySectionEnv(base: unknown, env: AnyEnvBindings, getEnv: GetEnv): unknown { +function applySectionEnv( + base: unknown, + env: AnyEnvBindings, + getEnv: GetEnv, + onDeprecatedEnv?: OnDeprecatedEnv, +): unknown { if (isEnvBinding(env)) { - return resolveBinding(env, getEnv, base); + return resolveBinding(env, getEnv, base, onDeprecatedEnv); } const target: Record = isPlainObject(base) ? { ...base } : {}; - applyEnvBindings(target, env, getEnv); + applyEnvBindings(target, env, getEnv, onDeprecatedEnv); return target; } @@ -131,7 +171,8 @@ function isSameSection( existing.stripEnv === (options.stripEnv as ConfigSection['stripEnv']) && existing.fromToml === options.fromToml && existing.toToml === options.toToml && - deepEqual(existing.defaultValue, options.defaultValue) + deepEqual(existing.defaultValue, options.defaultValue) && + deepEqual(existing.deprecations, options.deprecations) ); } @@ -183,6 +224,7 @@ export class ConfigRegistry implements IConfigRegistry { stripEnv: options.stripEnv as ConfigSection['stripEnv'], fromToml: options.fromToml, toToml: options.toToml, + deprecations: options.deprecations, }); this._onDidRegisterSection.fire({ domain }); } @@ -225,6 +267,11 @@ export class ConfigService extends Disposable implements IConfigService { readonly onDidChangeConfiguration: Event = this._onDidChangeConfiguration.event; private readonly _onDidSectionChange = this._register(new Emitter()); readonly onDidSectionChange: Event = this._onDidSectionChange.event; + private readonly _onDidChangeDiagnostics = this._register( + new Emitter(), + ); + readonly onDidChangeDiagnostics: Event = + this._onDidChangeDiagnostics.event; readonly ready: Promise; private stateChain: Promise = Promise.resolve(); @@ -236,6 +283,7 @@ export class ConfigService extends Disposable implements IConfigService { private memory: ResolvedConfig = {}; private delivered: ResolvedConfig = {}; private readonly diagnosticsList: ConfigDiagnostic[] = []; + private lastDiagnosticsSnapshot = '[]'; private readonly configKey: string; constructor( @@ -293,6 +341,24 @@ export class ConfigService extends Disposable implements IConfigService { return [...this.diagnosticsList]; } + /** Append a diagnostic, skipping exact duplicates (rebuilds re-run the same checks). */ + private pushDiagnostic(diagnostic: ConfigDiagnostic): void { + const duplicate = this.diagnosticsList.some( + (existing) => + existing.domain === diagnostic.domain && + existing.severity === diagnostic.severity && + existing.message === diagnostic.message, + ); + if (!duplicate) this.diagnosticsList.push(diagnostic); + } + + private emitDiagnosticsIfChanged(): void { + const snapshot = JSON.stringify(this.diagnosticsList); + if (snapshot === this.lastDiagnosticsSnapshot) return; + this.lastDiagnosticsSnapshot = snapshot; + this._onDidChangeDiagnostics.fire(this.diagnostics()); + } + async set( domain: string, patch: unknown, @@ -436,11 +502,25 @@ export class ConfigService extends Disposable implements IConfigService { error instanceof TomlError ? `Failed to parse ${this.bootstrap.configPath}: ${describeTomlSyntaxError(error)}` : describeUnknownError(error); - this.diagnosticsList.push({ severity: 'error', message }); + this.pushDiagnostic({ severity: 'error', message }); this.log.warn('config load failed', { error: describeUnknownError(error) }); } const nextRawSnake = cloneRecord(fileData); + // Key-deprecation warnings derive from the on-disk document, so collect + // them before the unchanged-file early return — the list was just cleared + // above and a no-op reload must not drop them. + for (const diagnostic of collectKeyDeprecations(nextRawSnake, this.registry.listSections())) { + this.pushDiagnostic(diagnostic); + } if (source !== 'load' && JSON.stringify(nextRawSnake) === JSON.stringify(this.rawSnake)) { + // The file is unchanged, so values and change events stay as they are — + // but env-derived diagnostics (deprecated env fallbacks, overlay + // failures) were cleared above and must be recollected over a scratch + // copy, or a no-op reload would silently drop them. + const scratch = { ...this.validated }; + this.applySectionEnvBindings(scratch, true); + this.applyEnvOverlay(scratch); + this.emitDiagnosticsIfChanged(); return; } this.rawSnake = nextRawSnake; @@ -466,6 +546,7 @@ export class ConfigService extends Disposable implements IConfigService { if (!deepEqual(previous[domain], next[domain])) candidates.add(domain); } this.commit(source, [...candidates]); + this.emitDiagnosticsIfChanged(); } private deliveredValue(domain: string): unknown { @@ -492,7 +573,7 @@ export class ConfigService extends Disposable implements IConfigService { try { validated[domain] = this.registry.validate(domain, value); } catch (error) { - this.diagnosticsList.push({ + this.pushDiagnostic({ domain, severity: 'warning', message: `Ignored invalid config section '${domain}': ${describeUnknownError(error)}`, @@ -513,11 +594,20 @@ export class ConfigService extends Disposable implements IConfigService { if (section.env === undefined) continue; try { const base = effective[section.domain]; - const next = applySectionEnv(base, section.env, getEnv); + const onDeprecatedEnv: OnDeprecatedEnv | undefined = reportErrors + ? (oldName, newName) => { + this.pushDiagnostic({ + domain: section.domain, + severity: 'warning', + message: `Environment variable ${oldName} is deprecated; use ${newName} instead.`, + }); + } + : undefined; + const next = applySectionEnv(base, section.env, getEnv, onDeprecatedEnv); effective[section.domain] = this.registry.validate(section.domain, next); } catch (error) { if (reportErrors) { - this.diagnosticsList.push({ + this.pushDiagnostic({ domain: section.domain, severity: 'warning', message: `Ignoring env overlay for '${section.domain}': ${describeUnknownError(error)}`, @@ -536,7 +626,7 @@ export class ConfigService extends Disposable implements IConfigService { overlay.apply(effective, getEnv, validate); } catch (error) { if (reportErrors) { - this.diagnosticsList.push({ + this.pushDiagnostic({ severity: 'warning', message: `Ignoring config environment overlay: ${describeUnknownError(error)}`, }); @@ -553,6 +643,7 @@ export class ConfigService extends Disposable implements IConfigService { this.applyEnvOverlay(next); this.effective = next; this.commit('reload', [...new Set([...Object.keys(before), ...Object.keys(next)])]); + this.emitDiagnosticsIfChanged(); } private revalidateDomain(domain: string): void { @@ -585,10 +676,17 @@ export class ConfigService extends Disposable implements IConfigService { if (section.env !== undefined) { const getEnv = (name: string): string | undefined => this.bootstrap.getEnv(name); try { - const next = applySectionEnv(this.effective[domain], section.env, getEnv); + const onDeprecatedEnv: OnDeprecatedEnv = (oldName, newName) => { + this.pushDiagnostic({ + domain, + severity: 'warning', + message: `Environment variable ${oldName} is deprecated; use ${newName} instead.`, + }); + }; + const next = applySectionEnv(this.effective[domain], section.env, getEnv, onDeprecatedEnv); this.effective[domain] = this.registry.validate(domain, next); } catch (error) { - this.diagnosticsList.push({ + this.pushDiagnostic({ domain, severity: 'warning', message: `Ignoring env overlay for '${domain}': ${describeUnknownError(error)}`, @@ -596,6 +694,7 @@ export class ConfigService extends Disposable implements IConfigService { } } this.commit('reload', [domain]); + this.emitDiagnosticsIfChanged(); } private async persist(domain: string): Promise { diff --git a/packages/agent-core-v2/src/app/config/deprecations.ts b/packages/agent-core-v2/src/app/config/deprecations.ts new file mode 100644 index 0000000000..fa42847d80 --- /dev/null +++ b/packages/agent-core-v2/src/app/config/deprecations.ts @@ -0,0 +1,41 @@ +/** + * `config` domain — declarative config-key deprecation detection. + * + * A section declares its renames once (`RegisterSectionOptions.deprecations`, + * snake_case keys as written on disk) and this module turns the presence of a + * deprecated key in the on-disk document into a warning `ConfigDiagnostic`. + * Detection is read-only: the old value is never mapped onto the new key (the + * section schema no longer knows the old key, so it is dropped at validation), + * and the user's file is left untouched — the warning is the migration guide. + */ + +import type { ConfigDiagnostic, ConfigSection } from './config'; +import { isPlainObject } from './configPure'; +import { camelToSnake } from './toml'; + +export function collectKeyDeprecations( + rawSnake: Record, + sections: readonly ConfigSection[], +): ConfigDiagnostic[] { + const diagnostics: ConfigDiagnostic[] = []; + for (const section of sections) { + const deprecations = section.deprecations; + if (deprecations === undefined || deprecations.length === 0) continue; + const snakeDomain = camelToSnake(section.domain); + const rawSection = rawSnake[snakeDomain]; + if (!isPlainObject(rawSection)) continue; + for (const deprecation of deprecations) { + if (rawSection[deprecation.key] === undefined) continue; + diagnostics.push({ + domain: section.domain, + severity: 'warning', + message: + `[${snakeDomain}] '${deprecation.key}' is deprecated and no longer used; ` + + `rename it to '${deprecation.replacement}'.` + + (deprecation.message === undefined ? '' : ` ${deprecation.message}`) + + ' Run /update-config to fix it.', + }); + } + } + return diagnostics; +} diff --git a/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.md b/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.md index 8590114244..1558387748 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.md +++ b/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.md @@ -1,6 +1,6 @@ --- name: update-config -description: Inspect or edit kimi-code's own config — `config.toml` (model, provider, permission, hooks) and `tui.toml` (theme, editor, notifications, auto-update). Use when the user asks what a setting does or wants to change one. +description: Inspect or edit kimi-code's own config — `config.toml` (model, provider, permission, hooks) and `tui.toml` (theme, editor, notifications, auto-update). Use when the user asks what a setting does, wants to change one, or needs to fix a deprecated config key / environment variable warning. --- # Configure kimi-code (update-config) @@ -97,6 +97,13 @@ Once local validation passes, tell the user how to make the change take effect Note: `/reload` is available **only when idle** — if a reply is streaming, press Esc / Ctrl-C to stop first. `kimi doctor` already validated the schema before the overwrite, so reload should apply cleanly; if it still errors, follow the message to fix it or recover from the most recent timestamped backup. If you don't want to reload now, the **next new session** picks it up automatically. +## Capability 5: fix a deprecated key or env-var warning + +kimi reports configuration deprecations as warnings — in the TUI startup notices and pushed to clients as the `event.config.warning` event. There are two shapes, handled differently: + +- **Deprecated TOML key** — e.g. `[loop_control] 'max_retries_per_step' is deprecated and no longer used; rename it to 'max_attempts_per_step'.` The old value no longer applies, so fix it promptly: follow the Capability 2 flow (copy → Edit → validate → back up → overwrite) and **rename the key in `config.toml`, keeping its value unchanged**. The warning names the exact section and replacement key — use those; never guess other renames. After `/reload`, the warning disappears. +- **Deprecated environment variable** — e.g. `Environment variable KIMI_LOOP_MAX_RETRIES_PER_STEP is deprecated; use KIMI_LOOP_MAX_ATTEMPTS_PER_STEP instead.` The old variable still works, but this is **not** fixable by editing `config.toml`/`tui.toml` — tell the user to rename the variable where they set it (shell profile, CI environment, launcher script). Do not add anything to the config files for this. + ## Don'ts - **Always back up before overwriting**, with a **timestamped name and all history kept** — don't skip the backup, don't keep only a single `.bak`, don't overwrite an old backup. diff --git a/packages/agent-core-v2/test/agent/goal/goal.test.ts b/packages/agent-core-v2/test/agent/goal/goal.test.ts index 1f24896d5f..8f5ecb81f8 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -1755,7 +1755,7 @@ describe('goal pause classification on provider errors', () => { return { initialConfig: { providers: {}, - loopControl: { maxRetriesPerStep: 1 }, + loopControl: { maxAttemptsPerStep: 1 }, }, }; } diff --git a/packages/agent-core-v2/test/agent/stepRetry/stepRetry.test.ts b/packages/agent-core-v2/test/agent/stepRetry/stepRetry.test.ts index e99b50274d..14872ee072 100644 --- a/packages/agent-core-v2/test/agent/stepRetry/stepRetry.test.ts +++ b/packages/agent-core-v2/test/agent/stepRetry/stepRetry.test.ts @@ -202,14 +202,14 @@ describe('stepRetry plugin', () => { expect(result.type).toBe('cancelled'); }); - it('honors loop_control.max_retries_per_step', async () => { + it('honors loop_control.max_attempts_per_step', async () => { vi.useFakeTimers(); let calls = 0; ctx = createTestAgent(llmGenerateServices(async () => { calls += 1; throw new APIConnectionError('terminated'); }), { - initialConfig: { loopControl: { maxRetriesPerStep: 1 } }, + initialConfig: { loopControl: { maxAttemptsPerStep: 1 } }, }); const result = await runTurn(1); 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 995bf58dfd..cf201ee0bf 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -41,6 +41,7 @@ import { IMAGE_SECTION, type ImageConfig } from '#/agent/media/configSection'; import '#/agent/loop/configSection'; import { LOOP_CONTROL_SECTION, + LOOP_MAX_ATTEMPTS_PER_STEP_ENV, LOOP_MAX_RETRIES_PER_STEP_ENV, LOOP_MAX_STEPS_PER_TURN_ENV, type LoopControl, @@ -772,10 +773,10 @@ describe('loopControl config section', () => { expect(registry.validate(LOOP_CONTROL_SECTION, {})).toEqual({}); expect( - registry.validate(LOOP_CONTROL_SECTION, { maxStepsPerTurn: 100, maxRetriesPerStep: 3 }), - ).toEqual({ maxStepsPerTurn: 100, maxRetriesPerStep: 3 }); + registry.validate(LOOP_CONTROL_SECTION, { maxStepsPerTurn: 100, maxAttemptsPerStep: 3 }), + ).toEqual({ maxStepsPerTurn: 100, maxAttemptsPerStep: 3 }); expect(() => registry.validate(LOOP_CONTROL_SECTION, { maxStepsPerTurn: -1 })).toThrow(); - expect(() => registry.validate(LOOP_CONTROL_SECTION, { maxRetriesPerStep: 1.5 })).toThrow(); + expect(() => registry.validate(LOOP_CONTROL_SECTION, { maxAttemptsPerStep: 1.5 })).toThrow(); }); it('re-applies loopControl env bindings on every get() and ignores invalid env', async () => { @@ -794,14 +795,14 @@ describe('loopControl config section', () => { expect(config.get(LOOP_CONTROL_SECTION)).toEqual({}); env[LOOP_MAX_STEPS_PER_TURN_ENV] = 'abc'; - env[LOOP_MAX_RETRIES_PER_STEP_ENV] = '-1'; + env[LOOP_MAX_ATTEMPTS_PER_STEP_ENV] = '-1'; expect(config.get(LOOP_CONTROL_SECTION)).toEqual({}); env[LOOP_MAX_STEPS_PER_TURN_ENV] = '100'; - env[LOOP_MAX_RETRIES_PER_STEP_ENV] = '3'; + env[LOOP_MAX_ATTEMPTS_PER_STEP_ENV] = '3'; expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ maxStepsPerTurn: 100, - maxRetriesPerStep: 3, + maxAttemptsPerStep: 3, }); env[LOOP_MAX_STEPS_PER_TURN_ENV] = '50'; @@ -813,7 +814,7 @@ describe('loopControl config section', () => { it('restores env-owned fields to the raw value on set() while the env var is set', async () => { const env: Record = { [LOOP_MAX_STEPS_PER_TURN_ENV]: '7', - [LOOP_MAX_RETRIES_PER_STEP_ENV]: '2', + [LOOP_MAX_ATTEMPTS_PER_STEP_ENV]: '2', }; const disposables = new DisposableStore(); const ix = disposables.add(new TestInstantiationService()); @@ -835,14 +836,14 @@ describe('loopControl config section', () => { // A client echoing the env-overlaid section back (plus a genuine edit). await config.set(LOOP_CONTROL_SECTION, { maxStepsPerTurn: 7, - maxRetriesPerStep: 2, + maxAttemptsPerStep: 2, reservedContextSize: 5000, }); // Runtime resolution still lets the env win… expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ maxStepsPerTurn: 7, - maxRetriesPerStep: 2, + maxAttemptsPerStep: 2, reservedContextSize: 5000, }); // …but persistence keeps the raw value and drops the env-only field. @@ -853,7 +854,7 @@ describe('loopControl config section', () => { const onDisk = new TextDecoder().decode(await storage.read('', 'config.toml')); expect(onDisk).toContain('max_steps_per_turn = 100'); expect(onDisk).toContain('reserved_context_size = 5000'); - expect(onDisk).not.toContain('max_retries_per_step'); + expect(onDisk).not.toContain('max_attempts_per_step'); disposables.dispose(); }); @@ -945,7 +946,7 @@ describe('loopControl config section', () => { disposables.dispose(); }); - it('restores the env-owned field from the normalized raw base when the config uses the legacy key', async () => { + it('warns and ignores the deprecated max_steps_per_run key without rewriting the file', async () => { const env: Record = { [LOOP_MAX_STEPS_PER_TURN_ENV]: '7' }; const disposables = new DisposableStore(); const ix = disposables.add(new TestInstantiationService()); @@ -964,13 +965,25 @@ describe('loopControl config section', () => { const config = ix.get(IConfigService); await config.ready; - await config.set(LOOP_CONTROL_SECTION, { maxStepsPerTurn: 7 }); - - expect(config.get(LOOP_CONTROL_SECTION).maxStepsPerTurn).toBe(7); - // The legacy `max_steps_per_run` value is honored as the field's raw value. + // The deprecated key no longer maps onto maxStepsPerTurn: the resolved + // section carries only the env override, and the raw user value is the + // un-normalized echo of the file (preserved, not applied). + expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ maxStepsPerTurn: 7 }); expect(config.inspect(LOOP_CONTROL_SECTION).userValue).toEqual({ - maxStepsPerTurn: 100, + maxStepsPerRun: 100, }); + // …its presence is reported as a deprecation warning… + expect(config.diagnostics()).toContainEqual({ + domain: LOOP_CONTROL_SECTION, + severity: 'warning', + message: + "[loop_control] 'max_steps_per_run' is deprecated and no longer used; rename it to 'max_steps_per_turn'. Run /update-config to fix it.", + }); + // …and a stripped write leaves the on-disk legacy key untouched. + await config.set(LOOP_CONTROL_SECTION, { maxStepsPerTurn: 7 }); + expect(config.get(LOOP_CONTROL_SECTION).maxStepsPerTurn).toBe(7); + const onDisk = new TextDecoder().decode(await storage.read('', 'config.toml')); + expect(onDisk).toContain('max_steps_per_run = 100'); disposables.dispose(); }); @@ -1040,6 +1053,184 @@ describe('loopControl config section', () => { }); }); +describe('config deprecations', () => { + async function createConfig(env: Record, toml?: string) { + const disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + const storage = new InMemoryStorageService(); + if (toml !== undefined) { + await storage.write('', 'config.toml', new TextEncoder().encode(toml)); + } + ix.stub(ILogService, stubLog()); + ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg', env)); + ix.stub(IFileSystemStorageService, storage); + ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); + ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.set(IConfigService, new SyncDescriptor(ConfigService)); + const config = ix.get(IConfigService); + await config.ready; + return { config, disposables, storage }; + } + + it('warns and ignores a deprecated TOML key whose value no longer applies', async () => { + const { config, disposables } = await createConfig( + {}, + '[loop_control]\nmax_retries_per_step = 3\n', + ); + + // The old value is NOT mapped onto the new field… + expect(config.get(LOOP_CONTROL_SECTION)).toEqual({}); + // …and the file is left untouched — the warning is the migration guide. + expect(config.diagnostics()).toContainEqual({ + domain: LOOP_CONTROL_SECTION, + severity: 'warning', + message: + "[loop_control] 'max_retries_per_step' is deprecated and no longer used; rename it to 'max_attempts_per_step'. Run /update-config to fix it.", + }); + + disposables.dispose(); + }); + + it('lets the replacement key win when both are present, still warning', async () => { + const { config, disposables } = await createConfig( + {}, + '[loop_control]\nmax_retries_per_step = 3\nmax_attempts_per_step = 2\n', + ); + + expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ maxAttemptsPerStep: 2 }); + expect(config.diagnostics()).toContainEqual({ + domain: LOOP_CONTROL_SECTION, + severity: 'warning', + message: + "[loop_control] 'max_retries_per_step' is deprecated and no longer used; rename it to 'max_attempts_per_step'. Run /update-config to fix it.", + }); + + disposables.dispose(); + }); + + it('resolves a deprecated env var as a fallback with a warning, new var first', async () => { + const env: Record = { [LOOP_MAX_RETRIES_PER_STEP_ENV]: '4' }; + const { config, disposables } = await createConfig(env); + + // The deprecated var still supplies the value… + expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ maxAttemptsPerStep: 4 }); + // …with a deprecation warning… + expect(config.diagnostics()).toContainEqual({ + domain: LOOP_CONTROL_SECTION, + severity: 'warning', + message: `Environment variable ${LOOP_MAX_RETRIES_PER_STEP_ENV} is deprecated; use ${LOOP_MAX_ATTEMPTS_PER_STEP_ENV} instead.`, + }); + // …and the replacement var wins as soon as it appears. + env[LOOP_MAX_ATTEMPTS_PER_STEP_ENV] = '2'; + expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ maxAttemptsPerStep: 2 }); + + disposables.dispose(); + }); + + it('reports no env deprecation when only the replacement var is set', async () => { + const env: Record = { [LOOP_MAX_ATTEMPTS_PER_STEP_ENV]: '4' }; + const { config, disposables } = await createConfig(env); + + expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ maxAttemptsPerStep: 4 }); + expect(config.diagnostics()).toEqual([]); + + disposables.dispose(); + }); + + it('keeps the deprecated env warning across a no-op reload', async () => { + const env: Record = { [LOOP_MAX_RETRIES_PER_STEP_ENV]: '4' }; + const { config, disposables } = await createConfig(env); + + const warning = { + domain: LOOP_CONTROL_SECTION, + severity: 'warning' as const, + message: `Environment variable ${LOOP_MAX_RETRIES_PER_STEP_ENV} is deprecated; use ${LOOP_MAX_ATTEMPTS_PER_STEP_ENV} instead.`, + }; + expect(config.diagnostics()).toContainEqual(warning); + + // The file never changed, so reload takes the unchanged early return — + // the env-derived warning must survive it. + await config.reload(); + + expect(config.diagnostics()).toContainEqual(warning); + expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ maxAttemptsPerStep: 4 }); + + disposables.dispose(); + }); + + it('restores the env-owned field on set() when only the deprecated env var is set', async () => { + const env: Record = { [LOOP_MAX_RETRIES_PER_STEP_ENV]: '2' }; + const { config, disposables, storage } = await createConfig( + env, + '[loop_control]\nmax_attempts_per_step = 9\n', + ); + + // A client echoing the env-overlaid section back (plus a genuine edit). + await config.set(LOOP_CONTROL_SECTION, { maxAttemptsPerStep: 2, reservedContextSize: 5000 }); + + expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ + maxAttemptsPerStep: 2, + reservedContextSize: 5000, + }); + // The deprecated env still owns the field: persistence restores the raw + // value instead of leaking the echoed env value. + expect(config.inspect(LOOP_CONTROL_SECTION).userValue).toEqual({ + maxAttemptsPerStep: 9, + reservedContextSize: 5000, + }); + const onDisk = new TextDecoder().decode(await storage.read('', 'config.toml')); + expect(onDisk).toContain('max_attempts_per_step = 9'); + + disposables.dispose(); + }); + + it('emits onDidChangeDiagnostics on load and again when the warning clears', async () => { + const disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + const storage = new InMemoryStorageService(); + await storage.write( + '', + 'config.toml', + new TextEncoder().encode('[loop_control]\nmax_retries_per_step = 3\n'), + ); + ix.stub(ILogService, stubLog()); + ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg', {})); + ix.stub(IFileSystemStorageService, storage); + ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); + ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.set(IConfigService, new SyncDescriptor(ConfigService)); + const config = ix.get(IConfigService); + const emissions: Array = []; + config.onDidChangeDiagnostics((diagnostics) => { + emissions.push(diagnostics); + }); + await config.ready; + + expect(emissions).toHaveLength(1); + expect(emissions[0]).toContainEqual({ + domain: LOOP_CONTROL_SECTION, + severity: 'warning', + message: + "[loop_control] 'max_retries_per_step' is deprecated and no longer used; rename it to 'max_attempts_per_step'. Run /update-config to fix it.", + }); + + // Renaming the key on disk clears the warning on the next reload. + await storage.write( + '', + 'config.toml', + new TextEncoder().encode('[loop_control]\nmax_attempts_per_step = 3\n'), + ); + await config.reload(); + + expect(emissions).toHaveLength(2); + expect(emissions[1]).toEqual([]); + expect(config.diagnostics()).toEqual([]); + expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ maxAttemptsPerStep: 3 }); + + disposables.dispose(); + }); +}); + describe('task config section', () => { it('re-applies the keepAliveOnExit env binding on every get()', async () => { const env: Record = {}; @@ -1319,7 +1510,7 @@ describe('applyPrintModeConfigDefaults', () => { it('keeps sibling user keys of a filled section visible', async () => { const { config, disposables } = await createConfig( {}, - '[task]\nprint_background_mode = "drain"\n\n[loop_control]\nmax_retries_per_step = 5\n', + '[task]\nprint_background_mode = "drain"\n\n[loop_control]\nmax_attempts_per_step = 5\n', ); await applyPrintModeConfigDefaults(config); @@ -1327,7 +1518,7 @@ describe('applyPrintModeConfigDefaults', () => { expect(resolvePrintBackgroundMode(config)).toBe('drain'); expect(resolveAgentTaskConfig(config)?.bashTaskTimeoutS).toBe(0); expect(config.get(LOOP_CONTROL_SECTION)).toMatchObject({ - maxRetriesPerStep: 5, + maxAttemptsPerStep: 5, maxStepsPerTurn: 0, }); diff --git a/packages/agent-core-v2/test/kosong/stubs.ts b/packages/agent-core-v2/test/kosong/stubs.ts index 50b6262c40..6ad9ade999 100644 --- a/packages/agent-core-v2/test/kosong/stubs.ts +++ b/packages/agent-core-v2/test/kosong/stubs.ts @@ -22,6 +22,9 @@ export class StubConfigService implements IConfigService { private readonly _onDidChange = new Emitter(); readonly onDidChangeConfiguration: Event = this._onDidChange.event; readonly onDidSectionChange: Event = this._onDidChange.event; + private readonly _onDidChangeDiagnostics = new Emitter(); + readonly onDidChangeDiagnostics: Event = + this._onDidChangeDiagnostics.event; private readonly _values = new Map(); constructor(initial?: Record) { diff --git a/packages/agent-core/src/skill/builtin/update-config.md b/packages/agent-core/src/skill/builtin/update-config.md index 8590114244..1558387748 100644 --- a/packages/agent-core/src/skill/builtin/update-config.md +++ b/packages/agent-core/src/skill/builtin/update-config.md @@ -1,6 +1,6 @@ --- name: update-config -description: Inspect or edit kimi-code's own config — `config.toml` (model, provider, permission, hooks) and `tui.toml` (theme, editor, notifications, auto-update). Use when the user asks what a setting does or wants to change one. +description: Inspect or edit kimi-code's own config — `config.toml` (model, provider, permission, hooks) and `tui.toml` (theme, editor, notifications, auto-update). Use when the user asks what a setting does, wants to change one, or needs to fix a deprecated config key / environment variable warning. --- # Configure kimi-code (update-config) @@ -97,6 +97,13 @@ Once local validation passes, tell the user how to make the change take effect Note: `/reload` is available **only when idle** — if a reply is streaming, press Esc / Ctrl-C to stop first. `kimi doctor` already validated the schema before the overwrite, so reload should apply cleanly; if it still errors, follow the message to fix it or recover from the most recent timestamped backup. If you don't want to reload now, the **next new session** picks it up automatically. +## Capability 5: fix a deprecated key or env-var warning + +kimi reports configuration deprecations as warnings — in the TUI startup notices and pushed to clients as the `event.config.warning` event. There are two shapes, handled differently: + +- **Deprecated TOML key** — e.g. `[loop_control] 'max_retries_per_step' is deprecated and no longer used; rename it to 'max_attempts_per_step'.` The old value no longer applies, so fix it promptly: follow the Capability 2 flow (copy → Edit → validate → back up → overwrite) and **rename the key in `config.toml`, keeping its value unchanged**. The warning names the exact section and replacement key — use those; never guess other renames. After `/reload`, the warning disappears. +- **Deprecated environment variable** — e.g. `Environment variable KIMI_LOOP_MAX_RETRIES_PER_STEP is deprecated; use KIMI_LOOP_MAX_ATTEMPTS_PER_STEP instead.` The old variable still works, but this is **not** fixable by editing `config.toml`/`tui.toml` — tell the user to rename the variable where they set it (shell profile, CI environment, launcher script). Do not add anything to the config files for this. + ## Don'ts - **Always back up before overwriting**, with a **timestamped name and all history kept** — don't skip the backup, don't keep only a single `.bak`, don't overwrite an old backup. diff --git a/packages/kap-server/src/protocol/events-zod.ts b/packages/kap-server/src/protocol/events-zod.ts index 22ce7ca705..9c5cbba17d 100644 --- a/packages/kap-server/src/protocol/events-zod.ts +++ b/packages/kap-server/src/protocol/events-zod.ts @@ -615,6 +615,16 @@ export const configChangedEventSchema = z.object({ config: configResponseSchema, }); +export const configWarningEventSchema = z.object({ + type: z.literal('event.config.warning'), + warnings: z.array( + z.object({ + domain: z.string().optional(), + message: z.string(), + }), + ), +}); + export const goalUpdatedEventSchema = z.object({ type: z.literal('goal.updated'), snapshot: goalSnapshotSchema.nullable(), diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index fcf986dbdc..43da335353 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -10,12 +10,14 @@ import { bootstrap, IConfigService, + IEventService, IProviderDiscoveryService, IWorkspaceService, logSeed, resolveConfigPath, resolveKimiHome, resolveLoggingConfig, + type ConfigDiagnostic, type Scope, type ScopeSeed, } from '@moonshot-ai/agent-core-v2'; @@ -49,6 +51,7 @@ import { } from './transport/ws/connectionRegistry'; import { extractWsBearerToken } from './transport/ws/bearerProtocol'; import { SessionEventBroadcaster } from './transport/ws/v1/sessionEventBroadcaster'; +import type { ConfigWarningItem } from './transport/ws/v1/events'; import { FsWatchBridge } from './transport/ws/v1/fsWatchBridge'; import { registerWsV1, WS_PATH as WS_PATH_V1 } from './transport/ws/v1/registerWsV1'; import { getServerVersion } from './version'; @@ -350,6 +353,7 @@ export async function startServer(opts: ServerStartOptions): Promise => { await app.close(); + configWarningSubscription.dispose(); authFailureLimiter?.dispose(); modelCatalogRefreshScheduler.dispose(); // Telemetry is best-effort and must never prevent core or instance cleanup. @@ -386,6 +390,37 @@ export async function startServer(opts: ServerStartOptions): Promise { + const warnings: ConfigWarningItem[] = diagnostics + .filter((diagnostic) => diagnostic.severity === 'warning') + .map((diagnostic) => + diagnostic.domain === undefined + ? { message: diagnostic.message } + : { domain: diagnostic.domain, message: diagnostic.message }, + ); + core.accessor.get(IEventService).publish({ + type: 'event.config.warning', + payload: { warnings }, + }); + }; + const configWarningSubscription = configService.onDidChangeDiagnostics(publishConfigWarnings); + void configService.ready + .then(() => { + if (configService.diagnostics().some((diagnostic) => diagnostic.severity === 'warning')) { + publishConfigWarnings(configService.diagnostics()); + } + }) + .catch(() => { + /* config readiness is best-effort; warnings are advisory */ + }); + async function registerOpenApi(): Promise { const { default: swagger } = await import('@fastify/swagger'); await app.register(swagger, { diff --git a/packages/kap-server/src/transport/ws/v1/events.ts b/packages/kap-server/src/transport/ws/v1/events.ts index 0225299a4a..629796ba63 100644 --- a/packages/kap-server/src/transport/ws/v1/events.ts +++ b/packages/kap-server/src/transport/ws/v1/events.ts @@ -96,6 +96,23 @@ export interface ConfigChangedEvent { readonly config: ConfigResponse; } +export interface ConfigWarningItem { + readonly domain?: string; + readonly message: string; +} + +/** + * Global config warnings (deprecated keys / env vars in use, invalid + * sections). Pushed live to every connection whenever the config service's + * warning set changes; an empty `warnings` array means the last warning + * cleared. Late joiners are not replayed — pull current warnings via the + * config diagnostics RPC surface instead. + */ +export interface ConfigWarningEvent { + readonly type: 'event.config.warning'; + readonly warnings: readonly ConfigWarningItem[]; +} + export interface PromptSubmittedEvent { readonly type: 'prompt.submitted'; readonly promptId: string; @@ -178,6 +195,7 @@ export type AgentEvent = | SessionWorkChangedEvent | SessionStatusChangedEvent | ConfigChangedEvent + | ConfigWarningEvent | PromptSubmittedEvent | BackgroundTaskStartedEvent | BackgroundTaskTerminatedEvent; diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index e49fca40fd..5f5b18d977 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -73,7 +73,12 @@ import { MAIN_AGENT_ID, getLiveSessionById, } from '@moonshot-ai/agent-core-v2'; -import type { SessionCreatedEvent, SessionMetaUpdatedEvent, Event } from './events'; +import type { + ConfigWarningItem, + SessionCreatedEvent, + SessionMetaUpdatedEvent, + Event, +} from './events'; import { isVolatileEventType } from './events'; import type { SessionCursor } from '../../../protocol/ws-control'; import type { InFlightTurn, SnapshotSubagent } from '../../../protocol/rest-snapshot'; @@ -874,6 +879,23 @@ export class SessionEventBroadcaster { } as Event).catch((error: unknown) => this.logDispatchError(sessionId, 'session.meta.updated', error), ); + return; + } + if (event.type === 'event.config.warning') { + const payload = configWarningPayload(event.payload); + if (payload === undefined) return; + // Global fan-out: every established connection learns the current config + // warning set (deprecated keys/env vars in use, invalid sections) without + // subscribing to anything. Delivery is live-only — late joiners pull the + // diagnostics RPC surface instead. + void this.dispatchGlobal({ + type: 'event.config.warning', + warnings: payload.warnings, + agentId: 'main', + sessionId: GLOBAL_SESSION_ID, + } as Event).catch((error: unknown) => + this.logDispatchError(GLOBAL_SESSION_ID, 'event.config.warning', error), + ); } } @@ -1584,3 +1606,25 @@ function sessionCreatedPayload( if (sessionId === undefined || session === undefined) return undefined; return { sessionId, session }; } + +/** + * Validate the `event.config.warning` payload published on the core + * `IEventService` (`{ warnings: [{ domain?, message }] }`). Any malformed + * entry rejects the whole batch — the publisher always sends the full current + * warning set, so a partial frame would be a lie by omission. + */ +function configWarningPayload(payload: unknown): { warnings: ConfigWarningItem[] } | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const warnings = (payload as { warnings?: unknown }).warnings; + if (!Array.isArray(warnings)) return undefined; + const items: ConfigWarningItem[] = []; + for (const warning of warnings) { + if (typeof warning !== 'object' || warning === null) return undefined; + const message = (warning as { message?: unknown }).message; + if (typeof message !== 'string' || message.length === 0) return undefined; + const domain = (warning as { domain?: unknown }).domain; + if (domain !== undefined && typeof domain !== 'string') return undefined; + items.push(typeof domain === 'string' ? { domain, message } : { message }); + } + return { warnings: items }; +} diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 234ed7ce94..fb56f9277d 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -1091,6 +1091,49 @@ describe('SessionEventBroadcaster', () => { await bc.getCursor('s1'); // drain any would-be duplicate expect(both.envelopes).toHaveLength(1); }); + + it('delivers event.config.warning to a global-only target that never subscribed', async () => { + const globalView = collectingTarget(); + bc.addGlobalTarget(globalView.target); + + const warnings = [ + { + domain: 'loopControl', + message: + "[loop_control] 'max_retries_per_step' is deprecated and no longer used; rename it to 'max_attempts_per_step'.", + }, + { message: 'Environment variable OLD_VAR is deprecated; use NEW_VAR instead.' }, + ]; + eventBus.emit({ type: 'event.config.warning', payload: { warnings } }); + + await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1)); + expect(globalView.envelopes[0]).toMatchObject({ + type: 'event.config.warning', + session_id: '__global__', + payload: { warnings }, + }); + expect(globalView.deliveries).toEqual(['immediate']); + }); + + it('drops malformed event.config.warning payloads', async () => { + const globalView = collectingTarget(); + bc.addGlobalTarget(globalView.target); + + eventBus.emit({ type: 'event.config.warning', payload: { warnings: [{ message: 42 }] } }); + eventBus.emit({ type: 'event.config.warning', payload: { warnings: 'nope' } }); + eventBus.emit({ type: 'event.config.warning', payload: null }); + + // A valid frame right after proves the malformed ones were dropped, not + // merely slow. + const warnings = [{ message: 'something deprecated' }]; + eventBus.emit({ type: 'event.config.warning', payload: { warnings } }); + + await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1)); + expect(globalView.envelopes[0]).toMatchObject({ + type: 'event.config.warning', + payload: { warnings }, + }); + }); }); it('emits a durable event.session.work_changed(busy) trailing turn.started', async () => { diff --git a/packages/klient/test/e2e/invalid-input-matrix.test.ts b/packages/klient/test/e2e/invalid-input-matrix.test.ts index 08af599028..5abd80a05d 100644 --- a/packages/klient/test/e2e/invalid-input-matrix.test.ts +++ b/packages/klient/test/e2e/invalid-input-matrix.test.ts @@ -841,7 +841,7 @@ describe('video blocks', () => { // "Unsupported media type for base64 video" does NOT match the // image-format non-retryable patterns, so stepRetry claims it. Cap the // retries at 2 attempts (1 re-run, ~500ms backoff) for the suite's sake. - await klient.global.config.set({ domain: 'loopControl', patch: { maxRetriesPerStep: 2 } }); + await klient.global.config.set({ domain: 'loopControl', patch: { maxAttemptsPerStep: 2 } }); try { const ctx = await newCase(M_ANTHROPIC, 'anthropic-video-mime'); resetMock(queueScript(OK_ANTHROPIC)); @@ -871,7 +871,7 @@ describe('video blocks', () => { expect(ctx.eventNames()).toEqual(['turn.started', 'turn.ended', 'error', 'prompt.completed']); expect(ctx.payloads('prompt.completed')[0]?.['reason']).toBe('failed'); } finally { - await klient.global.config.set({ domain: 'loopControl', patch: { maxRetriesPerStep: 10 } }); + await klient.global.config.set({ domain: 'loopControl', patch: { maxAttemptsPerStep: 10 } }); } }, 30_000); }); From 1a5af8391cedb06df56ff43acfe04e1729b2e7ac Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 3 Aug 2026 20:04:49 +0800 Subject: [PATCH 2/3] feat(cli): validate config.toml against v2 section registry in doctor - add v2/validate-config.ts: validate config.toml with the agent-core-v2 ConfigRegistry, reporting registered-section schema failures as errors and unknown top-level keys / deprecated keys and env vars as non-fatal warnings - route `kimi doctor` config validation through the v2 validator when the KIMI_CODE_EXPERIMENTAL_FLAG master switch is on (lazy dynamic import, keeping the v2 module graph off the default path) - let doctor checks surface non-fatal warning messages on OK results --- apps/kimi-code/src/cli/experimental-v2.ts | 9 +- apps/kimi-code/src/cli/sub/doctor.ts | 24 ++- apps/kimi-code/src/cli/v2/validate-config.ts | 187 +++++++++++++++++++ apps/kimi-code/test/cli/doctor.test.ts | 139 ++++++++++++++ 4 files changed, 349 insertions(+), 10 deletions(-) create mode 100644 apps/kimi-code/src/cli/v2/validate-config.ts diff --git a/apps/kimi-code/src/cli/experimental-v2.ts b/apps/kimi-code/src/cli/experimental-v2.ts index 4f53508bae..5bcbf13396 100644 --- a/apps/kimi-code/src/cli/experimental-v2.ts +++ b/apps/kimi-code/src/cli/experimental-v2.ts @@ -3,10 +3,11 @@ * * When the master switch `KIMI_CODE_EXPERIMENTAL_FLAG` is truthy, `kimi -p` * (print mode) routes to the native agent-core-v2 runner (see - * `run-prompt.ts`) and the interactive TUI builds its harness through the - * SDK's v2-backed client (see `run-shell.ts`), both instead of the default - * v1 engine. The master switch also enables every experimental feature flag - * in the engine. Read directly from the env (matching + * `run-prompt.ts`), the interactive TUI builds its harness through the + * SDK's v2-backed client (see `run-shell.ts`), and `kimi doctor` validates + * config.toml against the v2 section registry (see `sub/doctor.ts` / + * `v2/validate-config.ts`), all instead of the default v1 engine. The + * master switch also enables every experimental feature flag in the engine. Read directly from the env (matching * `cli/update/rollout.ts`) because the CLI must not depend on the core flag * registry. Unset / any non-truthy value keeps the v1 path. * diff --git a/apps/kimi-code/src/cli/sub/doctor.ts b/apps/kimi-code/src/cli/sub/doctor.ts index 0ccc38d281..d6d5db3d1b 100644 --- a/apps/kimi-code/src/cli/sub/doctor.ts +++ b/apps/kimi-code/src/cli/sub/doctor.ts @@ -10,6 +10,7 @@ import { import type { Command } from 'commander'; import { z } from 'zod'; +import { isKimiV2Enabled } from '#/cli/experimental-v2'; import { getTuiConfigPath, parseTuiConfig } from '#/tui/config'; interface WritableLike { @@ -28,7 +29,7 @@ export interface DoctorDeps { readonly configRpc?: KimiConfigRpc; readonly fileExists?: (path: string) => boolean; readonly readTextFile?: (path: string) => Promise; - readonly validateConfigToml?: (text: string, path: string) => MaybePromise; + readonly validateConfigToml?: (text: string, path: string) => MaybePromise; } export interface DoctorOptions { @@ -40,7 +41,8 @@ interface CheckSpec { readonly label: 'config.toml' | 'tui.toml'; readonly path: string; readonly explicit: boolean; - readonly parse: (text: string, path: string) => MaybePromise; + /** Throws on invalid content; may return a non-fatal warning message. */ + readonly parse: (text: string, path: string) => MaybePromise; } interface CheckResult { @@ -59,7 +61,7 @@ interface ResolvedDoctorDeps { readonly exit: (code: number) => never; readonly fileExists: (path: string) => boolean; readonly readTextFile: (path: string) => Promise; - readonly validateConfigToml: (text: string, path: string) => MaybePromise; + readonly validateConfigToml: (text: string, path: string) => MaybePromise; } export async function handleDoctor(deps: DoctorDeps, options: DoctorOptions): Promise { @@ -130,7 +132,17 @@ function resolveDeps(deps: Partial | DoctorDeps | undefined): Resolv readTextFile: deps?.readTextFile ?? ((path) => readFile(path, 'utf-8')), validateConfigToml: deps?.validateConfigToml ?? - ((text, filePath) => getConfigRpc().validateConfigToml({ text, filePath })), + (async (text, filePath) => { + if (isKimiV2Enabled()) { + // Experimental v2 route (same master switch as `kimi -p`): validate + // with the agent-core-v2 section registry instead of the v1 schema. + // Loaded lazily so the v2 module graph stays off the default path. + const { validateConfigTomlV2 } = await import('../v2/validate-config'); + return validateConfigTomlV2(text, filePath); + } + await getConfigRpc().validateConfigToml({ text, filePath }); + return undefined; + }), }; } @@ -204,8 +216,8 @@ async function checkTomlFile(deps: ResolvedDoctorDeps, spec: CheckSpec): Promise try { const text = await deps.readTextFile(spec.path); - await spec.parse(text, spec.path); - return { label: spec.label, path: spec.path, status: 'OK' }; + const warning = await spec.parse(text, spec.path); + return { label: spec.label, path: spec.path, status: 'OK', message: warning ?? undefined }; } catch (error) { return { label: spec.label, diff --git a/apps/kimi-code/src/cli/v2/validate-config.ts b/apps/kimi-code/src/cli/v2/validate-config.ts new file mode 100644 index 0000000000..89d14869b5 --- /dev/null +++ b/apps/kimi-code/src/cli/v2/validate-config.ts @@ -0,0 +1,187 @@ +/** + * Experimental v2 config.toml validation for `kimi doctor`. + * + * Loaded lazily (dynamic import) by the doctor command only when the + * agent-core-v2 master switch (`KIMI_CODE_EXPERIMENTAL_FLAG`) is on, so the + * v2 module graph stays off the default (v1) doctor path. Validation uses the + * engine's own section registry instead of v1's whole-document strict schema: + * importing the package root runs every built-in section's side-effect + * registration ("import = register"), and `ConfigRegistry` is then + * constructed directly — no DI container, no `ConfigService`, no file IO. + * + * Semantics deliberately mirror the v2 engine rather than v1: + * - a registered section that fails schema validation is an error (the + * engine would silently ignore that section at runtime; surfacing it is + * doctor's job); + * - a top-level key with no registered section passes through the engine + * untouched, so it is reported as a non-fatal warning — except the known + * schema-less domains the engine consumes directly (`default_model`, …); + * - section-declared key renames (`deprecations`) and renamed env vars + * (`deprecatedEnv` bindings actually supplying a value) surface as + * non-fatal warnings, reusing the engine's own detection + * (`collectKeyDeprecations`) and mirroring `ConfigService`'s env-fallback + * warning rule. + */ + +import { parse as parseToml } from 'smol-toml'; +import { z } from 'zod'; + +import { + ConfigRegistry, + type AnyEnvBindings, + type EnvBinding, +} from '@moonshot-ai/agent-core-v2'; +import { collectKeyDeprecations } from '@moonshot-ai/agent-core-v2/app/config/deprecations'; +import { + camelToSnake, + describeTomlSyntaxError, + isPlainObject, + transformTomlData, +} from '@moonshot-ai/agent-core-v2/app/config/toml'; + +/** + * Top-level domains the v2 engine reads via `IConfigService.get` / `inspect` + * without registering a schema (free-form values, structurally validated + * nowhere): `defaultModel` / `defaultProvider` (`kosongConfig` default + * pointers), `modelOverrides` (`llmRequester` / `profile`), and `telemetry` + * (read by the CLI itself). + */ +const SCHEMALESS_DOMAINS: ReadonlySet = new Set([ + 'defaultModel', + 'defaultProvider', + 'modelOverrides', + 'telemetry', +]); + +interface V2ConfigValidationIssue { + readonly path: readonly (string | number)[]; + readonly message: string; +} + +/** + * Matches the shape `handleDoctor` extracts from `error.details` (the SDK's + * `KimiConfigValidationIssue` list), so the doctor formatter renders v2 + * issues exactly like v1 ones. + */ +class V2ConfigValidationError extends Error { + readonly details: { readonly validationIssues: readonly V2ConfigValidationIssue[] }; + + constructor(issues: readonly V2ConfigValidationIssue[]) { + super('v2 config validation failed'); + this.details = { validationIssues: issues }; + } +} + +/** + * Validate `text` as config.toml against the v2 engine's section registry. + * Throws on TOML syntax errors and on any registered section failing its + * schema; returns non-fatal warnings (one per line) for unknown top-level + * keys, deprecated config keys, and deprecated env vars in use. + */ +export function validateConfigTomlV2( + text: string, + filePath: string, + getEnv: (name: string) => string | undefined = (name) => process.env[name], +): string | undefined { + let data: Record = {}; + if (text.trim().length > 0) { + try { + data = parseToml(text) as Record; + } catch (error) { + throw new Error(`Invalid TOML in ${filePath}: ${describeTomlSyntaxError(error)}`, { + cause: error, + }); + } + } + + const registry = new ConfigRegistry(); + const transformed = transformTomlData(data, registry); + + const issues: V2ConfigValidationIssue[] = []; + const unknownKeys: string[] = []; + for (const [domain, value] of Object.entries(transformed)) { + if (registry.getSection(domain) === undefined) { + if (!SCHEMALESS_DOMAINS.has(domain)) unknownKeys.push(camelToSnake(domain)); + continue; + } + try { + registry.validate(domain, value); + } catch (error) { + if (!(error instanceof z.ZodError)) throw error; + for (const issue of error.issues) { + issues.push({ + path: [ + domain, + ...issue.path.map((segment) => + typeof segment === 'number' ? segment : String(segment), + ), + ], + message: issue.message, + }); + } + } + } + + if (issues.length > 0) throw new V2ConfigValidationError(issues); + + const warnings: string[] = []; + for (const diagnostic of collectKeyDeprecations(data, registry.listSections())) { + warnings.push(diagnostic.message); + } + warnings.push(...collectEnvDeprecations(registry, getEnv)); + if (unknownKeys.length > 0) { + warnings.push( + `Unknown top-level ${unknownKeys.length === 1 ? 'key' : 'keys'} ignored by the v2 engine: ${unknownKeys.join(', ')}.`, + ); + } + return warnings.length > 0 ? warnings.join('\n') : undefined; +} + +/** + * Warn about renamed env vars that actually supply a value, mirroring + * `ConfigService`'s `resolveBinding`: the deprecated name only resolves (and + * thus only warns) when the primary var is absent or fails to parse. + */ +function collectEnvDeprecations( + registry: ConfigRegistry, + getEnv: (name: string) => string | undefined, +): string[] { + const warnings = new Set(); + for (const section of registry.listSections()) { + if (section.env === undefined) continue; + walkEnvBindings(section.env, (binding) => { + if (typeof binding === 'string' || binding.deprecatedEnv === undefined) return; + const primary = getEnv(binding.env); + if ( + primary !== undefined && + (binding.parse === undefined || binding.parse(primary) !== undefined) + ) { + return; + } + const deprecated = getEnv(binding.deprecatedEnv); + if (deprecated === undefined) return; + if (binding.parse !== undefined && binding.parse(deprecated) === undefined) return; + warnings.add( + `Environment variable ${binding.deprecatedEnv} is deprecated; use ${binding.env} instead.`, + ); + }); + } + return [...warnings]; +} + +function isEnvBinding(value: AnyEnvBindings): value is EnvBinding { + return typeof value === 'string' || (isPlainObject(value) && 'env' in value); +} + +function walkEnvBindings( + bindings: AnyEnvBindings, + visit: (binding: EnvBinding) => void, +): void { + if (isEnvBinding(bindings)) { + visit(bindings); + return; + } + for (const value of Object.values(bindings)) { + if (value !== undefined) walkEnvBindings(value, visit); + } +} diff --git a/apps/kimi-code/test/cli/doctor.test.ts b/apps/kimi-code/test/cli/doctor.test.ts index afbda67c01..0e2024617c 100644 --- a/apps/kimi-code/test/cli/doctor.test.ts +++ b/apps/kimi-code/test/cli/doctor.test.ts @@ -268,3 +268,142 @@ max_context_size = "large" expect(err).toContain('models.kimi.max_context_size:'); }); }); + +describe('kimi doctor (v2 config validation)', () => { + beforeEach(() => { + process.env['KIMI_CODE_EXPERIMENTAL_FLAG'] = '1'; + }); + + afterEach(() => { + delete process.env['KIMI_CODE_EXPERIMENTAL_FLAG']; + delete process.env['KIMI_LOOP_MAX_RETRIES_PER_STEP']; + delete process.env['KIMI_LOOP_MAX_ATTEMPTS_PER_STEP']; + }); + + it('accepts a config valid for the v2 engine, including schema-less keys', async () => { + await writeFile( + join(dir, 'config.toml'), + ` +default_model = "kimi" + +[providers.kimi] +type = "kimi" +base_url = "https://api.example.com/v1" +api_key = "YOUR_API_KEY" + +[models.kimi] +provider = "kimi" +model = "kimi" +max_context_size = 262144 +`, + 'utf-8', + ); + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(0); + expect(stderr.join('')).toBe(''); + expect(stdout.join('')).toContain(`OK config.toml ${join(dir, 'config.toml')}`); + }); + + it('reports schema-invalid sections with TOML-style field paths', async () => { + await writeFile( + join(dir, 'config.toml'), + ` +[models.kimi] +provider = "kimi" +model = "kimi" +max_context_size = "large" +`, + 'utf-8', + ); + const { deps, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(1); + const err = stderr.join(''); + expect(err).toContain('Validation issues:'); + expect(err).toContain('models.kimi.max_context_size:'); + }); + + it('warns about unknown top-level keys without failing', async () => { + await writeFile( + join(dir, 'config.toml'), + ` +[providrs.kimi] +type = "kimi" +`, + 'utf-8', + ); + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(0); + expect(stderr.join('')).toBe(''); + const out = stdout.join(''); + expect(out).toContain(`OK config.toml ${join(dir, 'config.toml')}`); + expect(out).toContain('Unknown top-level key ignored by the v2 engine: providrs.'); + }); + + it('reports TOML syntax errors with line and column', async () => { + await writeFile(join(dir, 'config.toml'), '[providers.kimi\ntype = "kimi"\n', 'utf-8'); + const { deps, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(1); + const err = stderr.join(''); + expect(err).toContain('Invalid TOML in'); + expect(err).toMatch(/\(line \d+, column \d+\)/); + }); + + it('warns about deprecated config keys without failing', async () => { + await writeFile( + join(dir, 'config.toml'), + ` +[loop_control] +max_retries_per_step = 3 +`, + 'utf-8', + ); + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(0); + expect(stderr.join('')).toBe(''); + const out = stdout.join(''); + expect(out).toContain(`OK config.toml ${join(dir, 'config.toml')}`); + expect(out).toContain("'max_retries_per_step' is deprecated"); + expect(out).toContain("rename it to 'max_attempts_per_step'"); + }); + + it('warns about a deprecated env var that supplies a value', async () => { + await writeFile(join(dir, 'config.toml'), '[loop_control]\n', 'utf-8'); + process.env['KIMI_LOOP_MAX_RETRIES_PER_STEP'] = '5'; + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(0); + expect(stderr.join('')).toBe(''); + expect(stdout.join('')).toContain( + 'Environment variable KIMI_LOOP_MAX_RETRIES_PER_STEP is deprecated; use KIMI_LOOP_MAX_ATTEMPTS_PER_STEP instead.', + ); + }); + + it('does not warn about the deprecated env var when the primary one is set', async () => { + await writeFile(join(dir, 'config.toml'), '[loop_control]\n', 'utf-8'); + process.env['KIMI_LOOP_MAX_RETRIES_PER_STEP'] = '5'; + process.env['KIMI_LOOP_MAX_ATTEMPTS_PER_STEP'] = '5'; + const { deps, stdout } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(0); + expect(stdout.join('')).not.toContain('KIMI_LOOP_MAX_RETRIES_PER_STEP'); + }); +}); From 7bc7a3dd40f86aef6a843d171963a7294cbf2f1c Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 3 Aug 2026 20:13:01 +0800 Subject: [PATCH 3/3] chore: downgrade loop-control changeset to patch --- .changeset/loop-control-attempt-limit-rename.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/loop-control-attempt-limit-rename.md b/.changeset/loop-control-attempt-limit-rename.md index ec6a0fc9ef..2f81943bba 100644 --- a/.changeset/loop-control-attempt-limit-rename.md +++ b/.changeset/loop-control-attempt-limit-rename.md @@ -1,5 +1,5 @@ --- -"@moonshot-ai/kimi-code": minor +"@moonshot-ai/kimi-code": patch --- Rename the `[loop_control] max_retries_per_step` config key to `max_attempts_per_step` and `max_steps_per_run` to `max_steps_per_turn`: on the v2 engine the old keys no longer take effect and a startup warning prompts the rename in `config.toml`. The `KIMI_LOOP_MAX_RETRIES_PER_STEP` env var is likewise deprecated in favor of `KIMI_LOOP_MAX_ATTEMPTS_PER_STEP` but keeps working with a warning.