From 057c6fbb5c3899811e02387b75e804c276bfbdb2 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 21 Jul 2026 12:22:10 +0800 Subject: [PATCH 01/11] feat(config): add env overrides for loop control and background task limits Add three operational environment overrides, resolved as env > config.toml > default in both engines (agent-core and agent-core-v2), matching the existing KIMI_IMAGE_MAX_EDGE_PX / KIMI_SUBAGENT_TIMEOUT_MS pattern: - KIMI_LOOP_MAX_STEPS_PER_TURN overrides loop_control.max_steps_per_turn - KIMI_LOOP_MAX_RETRIES_PER_STEP overrides loop_control.max_retries_per_step - KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS overrides background.max_running_tasks Invalid values are ignored and fall back to the config value. The v2 engine resolves them through config section env bindings (effective-only, never persisted); the v1 engine resolves them at the consumption point. --- .../background-max-running-tasks-env.md | 7 ++ .changeset/loop-control-env-overrides.md | 7 ++ docs/en/configuration/config-files.md | 4 +- docs/en/configuration/env-vars.md | 3 + docs/zh/configuration/config-files.md | 4 +- docs/zh/configuration/env-vars.md | 3 + .../src/agent/loop/configSection.ts | 32 ++++++- .../src/agent/task/configSection.ts | 15 +++- .../test/app/config/config.test.ts | 89 ++++++++++++++++++ .../agent-core/src/agent/background/index.ts | 21 ++++- packages/agent-core/src/agent/turn/index.ts | 38 +++++++- .../test/agent/background/manager.test.ts | 36 ++++++++ packages/agent-core/test/agent/turn.test.ts | 90 ++++++++++++++++++- 13 files changed, 337 insertions(+), 12 deletions(-) create mode 100644 .changeset/background-max-running-tasks-env.md create mode 100644 .changeset/loop-control-env-overrides.md diff --git a/.changeset/background-max-running-tasks-env.md b/.changeset/background-max-running-tasks-env.md new file mode 100644 index 0000000000..818ef1fa6c --- /dev/null +++ b/.changeset/background-max-running-tasks-env.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kimi-code": patch +--- + +Add an environment variable override for the background task concurrency cap. Set KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS to take priority over the [background] max_running_tasks config. diff --git a/.changeset/loop-control-env-overrides.md b/.changeset/loop-control-env-overrides.md new file mode 100644 index 0000000000..f13eedcf17 --- /dev/null +++ b/.changeset/loop-control-env-overrides.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kimi-code": patch +--- + +Add environment variable overrides for the agent loop limits. Set KIMI_LOOP_MAX_STEPS_PER_TURN or KIMI_LOOP_MAX_RETRIES_PER_STEP to take priority over the [loop_control] config. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 141d021fa0..5c13b212e8 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -210,6 +210,8 @@ You can also switch models temporarily without touching the config file — by s | `max_retries_per_step` | `integer` | `10` | Maximum retries after a step failure | | `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. + ## `background` `background` controls the concurrency behavior of background tasks (launched via the `Bash` tool or the `Agent` tool's `run_in_background=true` parameter). @@ -225,7 +227,7 @@ You can also switch models temporarily without touching the config file — by s | `print_wait_ceiling_s` | `integer` | `315360000` | In print mode (`kimi -p`), the wall-clock ceiling (seconds) for the wait/steer loop when `print_background_mode` is `"drain"` or `"steer"` (the default is 10 years — effectively unbounded). Has no effect outside print mode or when it is `"exit"` | | `print_max_turns` | `integer` | `100000` | In print mode (`kimi -p`) with `print_background_mode = "steer"`, the maximum number of new turns that may be triggered by background-task completions, to keep the steering loop bounded (the default is effectively unbounded) | -`keep_alive_on_exit` can be overridden by the `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` environment variable, which takes higher priority than `config.toml`. +`keep_alive_on_exit` can be overridden by the `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` environment variable, and `max_running_tasks` by `KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS`; both take higher priority than `config.toml`. In print mode (`kimi -p ""`), Kimi Code stays alive after the main agent's turn as long as background tasks are still pending: each completion is fed back to the main agent as a synthetic user message, steering it into a new turn (`print_background_mode = "steer"` by default), and the run exits once a turn ends with nothing pending. The loop is bounded by `print_wait_ceiling_s` and `print_max_turns`, both effectively unbounded by default. Background work is never killed by a wall-clock cap in print mode either: background `Bash` tasks default to no timeout (`bash_task_timeout_s = 0`), and subagents run without a timeout (`[subagent] timeout_ms = 0`), so only the model itself stops a task. Set `print_background_mode` to `"drain"` to wait for tasks without feeding results back, or `"exit"` to end the run as soon as the main agent finishes. diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index c8a9b12a54..7c2dcd3c17 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -122,11 +122,14 @@ Switches that control the behavior of subsystems such as telemetry, background t | --- | --- | --- | | `KIMI_DISABLE_TELEMETRY` | Disable anonymous telemetry reporting | `1`, `true`, `yes`, `y` (case-insensitive) | | `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | Whether to keep background tasks when the session closes; takes higher priority than `config.toml`. The default is to stop them on exit | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | +| `KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS` | Cap on concurrently running background tasks; takes higher priority than `[background] max_running_tasks` in `config.toml` (unset means no cap) | Positive integer; invalid values are ignored | | `KIMI_IMAGE_MAX_EDGE_PX` | Longest-edge ceiling (px) for image compression; takes higher priority than `[image] max_edge_px` in `config.toml` (default `2000`) | Positive integer; invalid values are ignored | | `KIMI_IMAGE_READ_BYTE_BUDGET` | Per-image byte budget for model-initiated image reads (`ReadMediaFile` default reads); takes higher priority than `[image] read_byte_budget` in `config.toml` (default `262144`, i.e. 256 KB) | Positive integer; invalid values are ignored | | `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | Override the plugin marketplace JSON loaded by `/plugins`; useful for dev loopback servers, staging CDN files, or alternate marketplace directories | `https://code.kimi.com/kimi-code/plugins/marketplace.json`; also accepts `http://`, `file://` URLs, and local paths | | `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | Cap how many AgentSwarm subagents run concurrently during the initial ramp; leave unset for no cap | Positive integer; invalid values fail fast | | `KIMI_SUBAGENT_TIMEOUT_MS` | Maximum wall-clock time (ms) a single subagent (`Agent` / `AgentSwarm`) may run; takes higher priority than `[subagent] timeout_ms` in `config.toml` (default `7200000`, i.e. 2 hours) | Positive integer; invalid values fall back to the config or default | +| `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_CODE_EXPERIMENTAL_FLAG` | Enable all registered experimental features for this process | `1`, `true`, `yes`, `on` | | `KIMI_SHELL_PATH` | Override the Git Bash path on Windows (used when auto-detection fails) | Absolute path | | `KIMI_MODEL_MAX_COMPLETION_TOKENS` | Hard cap on `max_completion_tokens` per LLM step; applies to the `kimi` provider only | Positive integer; `0` or negative disables clamping | diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 496c9c571d..9627bae6f4 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -210,6 +210,8 @@ display_name = "Kimi for Coding (custom)" | `max_retries_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` 覆盖,优先级均高于配置文件。 + ## `background` `background` 控制后台任务(通过 `Bash` 工具或 `Agent` 工具的 `run_in_background=true` 参数启动)的并发数。 @@ -225,7 +227,7 @@ display_name = "Kimi for Coding (custom)" | `print_wait_ceiling_s` | `integer` | `315360000` | print 模式(`kimi -p`)下,`print_background_mode` 为 `"drain"` 或 `"steer"` 时,等待/steer 循环的墙钟上限(秒;默认 10 年,近似不设限)。在非 print 模式或 `"exit"` 时无效 | | `print_max_turns` | `integer` | `100000` | print 模式(`kimi -p`)且 `print_background_mode = "steer"` 时,允许由后台任务完成触发的新 turn 的最大数量,防止 steer 循环失控(默认值近似不设限) | -`keep_alive_on_exit` 可被环境变量 `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` 覆盖,优先级高于配置文件。 +`keep_alive_on_exit` 可被环境变量 `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` 覆盖,`max_running_tasks` 可被 `KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS` 覆盖,优先级均高于配置文件。 在 print 模式(`kimi -p ""`)下,只要还有未决的后台任务,Kimi Code 在主 agent 的 turn 结束后不会退出:每个任务完成都会以合成 user 消息回馈给主 agent,steer 出新的 turn(默认 `print_background_mode = "steer"`),直到某 turn 结束时没有任何未决任务才退出。该循环受 `print_wait_ceiling_s` 与 `print_max_turns` 约束,默认值都近似不设限。print 模式下后台工作也不会被墙钟超时杀掉:后台 `Bash` 任务默认无超时(`bash_task_timeout_s = 0`),子代理默认无超时(`[subagent] timeout_ms = 0`),只有模型自己能停止任务。将 `print_background_mode` 设为 `"drain"` 可等待任务结束但不回馈结果,设为 `"exit"` 则在主 agent 结束后立即退出。 diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 87fa45ed6d..7e67c5c4f3 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -122,11 +122,14 @@ kimi | --- | --- | --- | | `KIMI_DISABLE_TELEMETRY` | 关闭匿名遥测上报 | `1`、`true`、`yes`、`y`(不区分大小写) | | `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | 会话关闭时是否保留后台任务,优先级高于 `config.toml`。默认会在退出时停止后台任务 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | +| `KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS` | 同时运行的后台任务数上限,优先级高于 `config.toml` 的 `[background] max_running_tasks`(不设置表示无上限) | 正整数;非法值被忽略 | | `KIMI_IMAGE_MAX_EDGE_PX` | 图片压缩的最长边上限(像素),优先级高于 `config.toml` 的 `[image] max_edge_px`(默认 `2000`) | 正整数;非法值被忽略 | | `KIMI_IMAGE_READ_BYTE_BUDGET` | 模型自行读图(`ReadMediaFile` 默认读取)的单图字节预算,优先级高于 `config.toml` 的 `[image] read_byte_budget`(默认 `262144`,即 256 KB) | 正整数;非法值被忽略 | | `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | 覆盖 `/plugins` 加载的 plugin marketplace JSON,适合 dev loopback server、测试 CDN 文件或替换 marketplace 目录 | `https://code.kimi.com/kimi-code/plugins/marketplace.json`;也接受 `http://`、`file://` URL 和本地路径 | | `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | 限制 AgentSwarm 初始提升并发阶段可同时运行的子 Agent 数量;不设置表示不限制 | 正整数;非法值会立即失败 | | `KIMI_SUBAGENT_TIMEOUT_MS` | 单个子 Agent(`Agent` / `AgentSwarm`)可运行的最长时间(毫秒);优先级高于 `config.toml` 的 `[subagent] timeout_ms`(默认 `7200000`,即 2 小时) | 正整数;非法值回退到配置或默认值 | +| `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_CODE_EXPERIMENTAL_FLAG` | 在当前进程启用所有已注册的实验功能 | `1`、`true`、`yes`、`on` | | `KIMI_SHELL_PATH` | Windows 上覆盖 Git Bash 路径(自动探测失败时使用) | 绝对路径 | | `KIMI_MODEL_MAX_COMPLETION_TOKENS` | 单步 LLM 请求的 `max_completion_tokens` 硬上限,仅对 `kimi` 供应商生效 | 正整数;`0` 或负数禁用 clamp | diff --git a/packages/agent-core-v2/src/agent/loop/configSection.ts b/packages/agent-core-v2/src/agent/loop/configSection.ts index 026faea047..ca2129c6bc 100644 --- a/packages/agent-core-v2/src/agent/loop/configSection.ts +++ b/packages/agent-core-v2/src/agent/loop/configSection.ts @@ -1,20 +1,32 @@ /** - * `loop` domain (L4) — `loopControl` config-section schema and TOML transforms. + * `loop` domain (L4) — `loopControl` config-section schema, env bindings, and + * TOML transforms. * * Owns the `[loop_control]` configuration section (step / retry / context-size * limits) consumed by `AgentLoopService` (step + retry budgets) and `AgentProfileService` * (context sizing), plus the snake_case ↔ camelCase TOML transforms (including - * the legacy `max_steps_per_run` → `maxStepsPerTurn` rename). Self-registered at - * module load via `registerConfigSection`. + * 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`. + * + * No `stripEnv` is registered: nothing calls `set`/`replace` for `loopControl`, + * and `raw`/`rawSnake` are always env-free (the env overlay lands only in + * `effective`), so an env override can never be written to `config.toml`. */ import { z } from 'zod'; +import { type EnvBindings, envBindings } from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; import { plainObjectToToml, transformPlainObject } 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_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(), @@ -25,6 +37,19 @@ export const LoopControlSchema = z.object({ export type LoopControl = z.infer; +/** Parse the env overrides; anything but a non-negative integer is ignored. */ +function parseNonNegativeInt(raw: string): number | undefined { + const value = raw.trim(); + if (value.length === 0 || !/^\d+$/.test(value)) return undefined; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : 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 }, +}); + export const loopControlFromToml = (rawSnake: unknown): unknown => { if (rawSnake === null || typeof rawSnake !== 'object' || Array.isArray(rawSnake)) return rawSnake; const out = transformPlainObject(rawSnake as Record); @@ -43,4 +68,5 @@ export const loopControlToToml = (value: unknown, rawSnake: unknown): unknown => registerConfigSection(LOOP_CONTROL_SECTION, LoopControlSchema, { fromToml: loopControlFromToml, toToml: loopControlToToml, + env: loopControlEnvBindings, }); diff --git a/packages/agent-core-v2/src/agent/task/configSection.ts b/packages/agent-core-v2/src/agent/task/configSection.ts index 405f9c0f4b..0824d6e6c2 100644 --- a/packages/agent-core-v2/src/agent/task/configSection.ts +++ b/packages/agent-core-v2/src/agent/task/configSection.ts @@ -5,8 +5,9 @@ * The legacy `[background]` section is registered with the same schema so old * configs continue to load while callers migrate; effective values use legacy * fields as the base and let `[task]` override matching fields. - * `keepAliveOnExit` also - * accepts the v1 env override `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` + * `keepAliveOnExit` and `maxRunningTasks` also + * accept the v1 env overrides `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` / + * `KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS` * (applied live by the config env overlay, never persisted). Also owns the * `kimi -p` print-mode background policy (`printBackgroundMode` / * `printWaitCeilingS` / `printMaxTurns`), resolved with v1 semantics by @@ -63,9 +64,19 @@ export function resolvePrintBackgroundMode(config: IConfigService): PrintBackgro } export const KEEP_ALIVE_ON_EXIT_ENV = 'KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT'; +export const MAX_RUNNING_TASKS_ENV = 'KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS'; + +/** Parse the env override; anything but a positive integer is ignored. */ +function parsePositiveInt(raw: string): number | undefined { + const value = raw.trim(); + if (value.length === 0 || !/^\d+$/.test(value)) return undefined; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; +} export const taskEnvBindings: EnvBindings = envBindings(AgentTaskConfigSchema, { keepAliveOnExit: { env: KEEP_ALIVE_ON_EXIT_ENV, parse: parseBooleanEnv }, + maxRunningTasks: { env: MAX_RUNNING_TASKS_ENV, parse: parsePositiveInt }, }); registerConfigSection(TASK_SECTION, AgentTaskConfigSchema, { env: taskEnvBindings }); 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 d0592270c8..b795685bf5 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -36,12 +36,20 @@ import '#/agent/permissionMode/configSection'; import { DEFAULT_PERMISSION_MODE_SECTION } from '#/agent/permissionMode/configSection'; import '#/agent/media/configSection'; import { IMAGE_SECTION, type ImageConfig } from '#/agent/media/configSection'; +import '#/agent/loop/configSection'; +import { + LOOP_CONTROL_SECTION, + LOOP_MAX_RETRIES_PER_STEP_ENV, + LOOP_MAX_STEPS_PER_TURN_ENV, + type LoopControl, +} from '#/agent/loop/configSection'; import { THINKING_SECTION, type ThinkingConfig, } from '#/agent/profile/configSection'; import { KEEP_ALIVE_ON_EXIT_ENV, + MAX_RUNNING_TASKS_ENV, resolveAgentTaskConfig, resolvePrintBackgroundMode, type AgentTaskConfig, @@ -528,6 +536,54 @@ describe('image config section', () => { }); }); +describe('loopControl config section', () => { + it('registers the loopControl section with a non-negative-int schema', () => { + const registry = new ConfigRegistry(); + + const section = registry.getSection(LOOP_CONTROL_SECTION); + expect(section).toBeDefined(); + + expect(registry.validate(LOOP_CONTROL_SECTION, {})).toEqual({}); + expect( + registry.validate(LOOP_CONTROL_SECTION, { maxStepsPerTurn: 100, maxRetriesPerStep: 3 }), + ).toEqual({ maxStepsPerTurn: 100, maxRetriesPerStep: 3 }); + expect(() => registry.validate(LOOP_CONTROL_SECTION, { maxStepsPerTurn: -1 })).toThrow(); + expect(() => registry.validate(LOOP_CONTROL_SECTION, { maxRetriesPerStep: 1.5 })).toThrow(); + }); + + it('re-applies loopControl env bindings on every get() and ignores invalid env', async () => { + const env: Record = {}; + const disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + ix.stub(ILogService, stubLog()); + ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg', env)); + ix.stub(IFileSystemStorageService, new InMemoryStorageService()); + ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); + ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.set(IConfigService, new SyncDescriptor(ConfigService)); + const config = ix.get(IConfigService); + await config.ready; + + expect(config.get(LOOP_CONTROL_SECTION)).toEqual({}); + + env[LOOP_MAX_STEPS_PER_TURN_ENV] = 'abc'; + env[LOOP_MAX_RETRIES_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'; + expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ + maxStepsPerTurn: 100, + maxRetriesPerStep: 3, + }); + + env[LOOP_MAX_STEPS_PER_TURN_ENV] = '50'; + expect(config.get(LOOP_CONTROL_SECTION).maxStepsPerTurn).toBe(50); + + disposables.dispose(); + }); +}); + describe('task config section', () => { it('re-applies the keepAliveOnExit env binding on every get()', async () => { const env: Record = {}; @@ -585,6 +641,39 @@ describe('task config section', () => { disposables.dispose(); }); + it('re-applies the maxRunningTasks env binding on every get() and ignores invalid env', async () => { + const env: Record = {}; + const { config, disposables } = await createTaskConfig(env); + + expect(config.get('task')?.maxRunningTasks).toBeUndefined(); + + env[MAX_RUNNING_TASKS_ENV] = 'abc'; + expect(config.get('task')?.maxRunningTasks).toBeUndefined(); + env[MAX_RUNNING_TASKS_ENV] = '0'; + expect(config.get('task')?.maxRunningTasks).toBeUndefined(); + + env[MAX_RUNNING_TASKS_ENV] = '4'; + expect(config.get('task')?.maxRunningTasks).toBe(4); + expect(config.get('background')?.maxRunningTasks).toBe(4); + + env[MAX_RUNNING_TASKS_ENV] = '2'; + expect(config.get('task')?.maxRunningTasks).toBe(2); + + disposables.dispose(); + }); + + it('lets the maxRunningTasks env binding override the config value', async () => { + const env: Record = { [MAX_RUNNING_TASKS_ENV]: '8' }; + const { config, disposables } = await createTaskConfig( + env, + '[background]\nmax_running_tasks = 3\n', + ); + + expect(resolveAgentTaskConfig(config)?.maxRunningTasks).toBe(8); + + disposables.dispose(); + }); + async function createTaskConfig(env: Record, toml?: string) { const disposables = new DisposableStore(); const ix = disposables.add(new TestInstantiationService()); diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index c9cb88fc7b..bb36e504c4 100644 --- a/packages/agent-core/src/agent/background/index.ts +++ b/packages/agent-core/src/agent/background/index.ts @@ -42,6 +42,23 @@ export function isBackgroundTaskTerminal(status: BackgroundTaskStatus): boolean return TERMINAL_STATUSES.has(status); } +const MAX_RUNNING_TASKS_ENV = 'KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS'; + +/** + * Resolve the effective background-task concurrency cap. Precedence: + * `KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS` (positive integer) → config + * (`background.max_running_tasks`) → `undefined` (no cap). An invalid env + * value is ignored. + */ +export function resolveMaxRunningTasks(configValue?: number): number | undefined { + const raw = process.env[MAX_RUNNING_TASKS_ENV]?.trim(); + if (raw !== undefined && raw.length > 0 && /^\d+$/.test(raw)) { + const parsed = Number(raw); + if (Number.isInteger(parsed) && parsed > 0) return parsed; + } + return configValue; +} + export { AgentBackgroundTask } from './agent-task'; export type { AgentBackgroundTaskInfo } from './agent-task'; export { ProcessBackgroundTask } from './process-task'; @@ -282,7 +299,9 @@ export class BackgroundManager { } private assertCanRegister(startedInBackground: boolean): void { - const maxRunningTasks = this.agent.kimiConfig?.background?.maxRunningTasks; + const maxRunningTasks = resolveMaxRunningTasks( + this.agent.kimiConfig?.background?.maxRunningTasks, + ); if (maxRunningTasks === undefined) return; if (!startedInBackground) return; if (this.activeBackgroundAdmissionCount() < maxRunningTasks) return; diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 506f60ae0d..d1d8c6c3c2 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -778,6 +778,8 @@ export class TurnFlow { signal.throwIfAborted(); const model = this.agent.config.model; const loopControl = this.agent.kimiConfig?.loopControl; + const maxStepsPerTurn = resolveMaxStepsPerTurn(loopControl?.maxStepsPerTurn); + const maxRetriesPerStep = resolveMaxRetriesPerStep(loopControl?.maxRetriesPerStep); let stopForGoalBudget = false; try { const result = await runTurn({ @@ -794,8 +796,8 @@ export class TurnFlow { buildTools: () => this.agent.tools.loopTools, describeMissingTool: (name) => this.agent.tools.missingToolMessage(name), log: this.agent.log, - maxSteps: loopControl?.maxStepsPerTurn, - maxRetryAttempts: loopControl?.maxRetriesPerStep, + maxSteps: maxStepsPerTurn, + maxRetryAttempts: maxRetriesPerStep, recordStepUsage: async (usage) => { try { const snapshot = await this.agent.goal.recordTokenUsage(usage.output); @@ -880,7 +882,7 @@ export class TurnFlow { ) { goalOutcomeMessageContinuationUsed = true; goalOutcomeToolResultPending = false; - if (!hasStepBudgetRemaining(loopControl?.maxStepsPerTurn, ctx.stepNumber)) { + if (!hasStepBudgetRemaining(maxStepsPerTurn, ctx.stepNumber)) { return { continue: false }; } return { continue: true }; @@ -1218,6 +1220,36 @@ export class TurnFlow { } } +const MAX_STEPS_PER_TURN_ENV = 'KIMI_LOOP_MAX_STEPS_PER_TURN'; +const MAX_RETRIES_PER_STEP_ENV = 'KIMI_LOOP_MAX_RETRIES_PER_STEP'; + +/** + * Resolve the effective per-turn step cap. Precedence: + * `KIMI_LOOP_MAX_STEPS_PER_TURN` (non-negative integer) → config + * (`loop_control.max_steps_per_turn`) → `undefined` (no cap). `0` means no + * cap, same as the config field; an invalid env value is ignored. + */ +export function resolveMaxStepsPerTurn(configValue?: number): number | undefined { + return nonNegativeIntFromEnv(MAX_STEPS_PER_TURN_ENV) ?? configValue; +} + +/** + * Resolve the effective per-step retry budget. Precedence: + * `KIMI_LOOP_MAX_RETRIES_PER_STEP` (non-negative integer) → config + * (`loop_control.max_retries_per_step`) → `undefined` (the loop's built-in + * default). An invalid env value is ignored. + */ +export function resolveMaxRetriesPerStep(configValue?: number): number | undefined { + return nonNegativeIntFromEnv(MAX_RETRIES_PER_STEP_ENV) ?? configValue; +} + +function nonNegativeIntFromEnv(name: string): number | undefined { + const raw = process.env[name]?.trim(); + if (raw === undefined || raw.length === 0 || !/^\d+$/.test(raw)) return undefined; + const parsed = Number(raw); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + function hasStepBudgetRemaining(maxSteps: number | undefined, currentStep: number): boolean { return maxSteps === undefined || maxSteps <= 0 || currentStep < maxSteps; } diff --git a/packages/agent-core/test/agent/background/manager.test.ts b/packages/agent-core/test/agent/background/manager.test.ts index 82846a4fac..b1c16cdb6c 100644 --- a/packages/agent-core/test/agent/background/manager.test.ts +++ b/packages/agent-core/test/agent/background/manager.test.ts @@ -14,6 +14,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { BackgroundTaskPersistence, ProcessBackgroundTask, + resolveMaxRunningTasks, type BackgroundManager, type BackgroundTaskInfo, } from '../../../src/agent/background'; @@ -374,6 +375,41 @@ describe('BackgroundManager', () => { }).toThrow('Too many background tasks are already running.'); }); + describe('KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS env override', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('caps registration from the env even without config', () => { + vi.stubEnv('KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS', '1'); + const { manager } = createBackgroundManager({ maxRunningTasks: 4 }); + + registerProcess(manager, pendingProcess().proc, 'sleep 60', 'first task'); + + expect(() => { + registerProcess(manager, pendingProcess().proc, 'sleep 60', 'second task'); + }).toThrow('Too many background tasks are already running.'); + }); + + it('prefers the env value over config and ignores invalid values', () => { + expect(resolveMaxRunningTasks(4)).toBe(4); + expect(resolveMaxRunningTasks()).toBeUndefined(); + + vi.stubEnv('KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS', '2'); + expect(resolveMaxRunningTasks(4)).toBe(2); + expect(resolveMaxRunningTasks()).toBe(2); + + // `0` is invalid for this field (schema minimum is 1): no cap. + vi.stubEnv('KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS', '0'); + expect(resolveMaxRunningTasks(4)).toBe(4); + + vi.stubEnv('KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS', 'abc'); + expect(resolveMaxRunningTasks(4)).toBe(4); + vi.stubEnv('KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS', '-2'); + expect(resolveMaxRunningTasks()).toBeUndefined(); + }); + }); + it('captures process output', async () => { const { manager } = createBackgroundManager(); const taskId = registerProcess( diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index e3218956b2..6e4c46de0a 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -24,13 +24,17 @@ import { type ModelCapability, type ToolCall, } from '@moonshot-ai/kosong'; -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, afterEach, vi } from 'vitest'; import { HookEngine } from '../../src/session/hooks'; import { abortError } from '../../src/utils/abort'; import type { AgentOptions, AgentRecord, AgentRecordPersistence } from '../../src/agent'; import { ProcessBackgroundTask } from '../../src/agent/background'; import { InMemoryAgentRecordPersistence } from '../../src/agent/records'; +import { + resolveMaxRetriesPerStep, + resolveMaxStepsPerTurn, +} from '../../src/agent/turn'; import { ErrorCodes, KimiError } from '../../src/errors'; import type { Logger, LogPayload } from '../../src/logging'; import type { @@ -1873,6 +1877,90 @@ describe('Agent turn flow', () => { ); }); + describe('loop control env overrides', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('honors KIMI_LOOP_MAX_STEPS_PER_TURN over config in agent turns', async () => { + vi.stubEnv('KIMI_LOOP_MAX_STEPS_PER_TURN', '1'); + const ctx = testAgent({ + initialConfig: { + providers: {}, + loopControl: { maxStepsPerTurn: 100 }, + }, + kaos: createCommandKaos('loop-output'), + }); + ctx.configure({ tools: ['Bash'] }); + await ctx.rpc.setPermission({ mode: 'yolo' }); + ctx.newEvents(); + + const bashCall: ToolCall = { + id: 'call_bash', + type: 'function', + name: 'Bash', + arguments: '{"command":"printf loop-output","timeout":60}', + }; + ctx.mockNextResponse(bashCall); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run a command once' }] }); + const events = await ctx.untilTurnEnd(); + + expect(ctx.llmCalls).toHaveLength(1); + expect(events).toContainEqual( + expect.objectContaining({ + event: 'turn.ended', + args: expect.objectContaining({ + reason: 'failed', + error: expect.objectContaining({ + code: 'loop.max_steps_exceeded', + details: expect.objectContaining({ + maxSteps: 1, + }), + }), + }), + }), + ); + }); + + it('prefers KIMI_LOOP_MAX_STEPS_PER_TURN over config and ignores invalid values', () => { + expect(resolveMaxStepsPerTurn(100)).toBe(100); + expect(resolveMaxStepsPerTurn()).toBeUndefined(); + + vi.stubEnv('KIMI_LOOP_MAX_STEPS_PER_TURN', '5'); + expect(resolveMaxStepsPerTurn(100)).toBe(5); + expect(resolveMaxStepsPerTurn()).toBe(5); + + // `0` is a valid override: it means "no cap", same as the config field. + vi.stubEnv('KIMI_LOOP_MAX_STEPS_PER_TURN', '0'); + expect(resolveMaxStepsPerTurn(100)).toBe(0); + + vi.stubEnv('KIMI_LOOP_MAX_STEPS_PER_TURN', 'abc'); + expect(resolveMaxStepsPerTurn(100)).toBe(100); + vi.stubEnv('KIMI_LOOP_MAX_STEPS_PER_TURN', '-3'); + expect(resolveMaxStepsPerTurn(100)).toBe(100); + vi.stubEnv('KIMI_LOOP_MAX_STEPS_PER_TURN', '1.5'); + expect(resolveMaxStepsPerTurn()).toBeUndefined(); + }); + + it('prefers KIMI_LOOP_MAX_RETRIES_PER_STEP over config and ignores invalid values', () => { + expect(resolveMaxRetriesPerStep(10)).toBe(10); + expect(resolveMaxRetriesPerStep()).toBeUndefined(); + + vi.stubEnv('KIMI_LOOP_MAX_RETRIES_PER_STEP', '3'); + expect(resolveMaxRetriesPerStep(10)).toBe(3); + expect(resolveMaxRetriesPerStep()).toBe(3); + + vi.stubEnv('KIMI_LOOP_MAX_RETRIES_PER_STEP', '0'); + expect(resolveMaxRetriesPerStep(10)).toBe(0); + + vi.stubEnv('KIMI_LOOP_MAX_RETRIES_PER_STEP', 'not-a-number'); + expect(resolveMaxRetriesPerStep(10)).toBe(10); + vi.stubEnv('KIMI_LOOP_MAX_RETRIES_PER_STEP', '-1'); + expect(resolveMaxRetriesPerStep(10)).toBe(10); + }); + }); + it('force-refreshes OAuth credentials and replays the request on 401', async () => { const tokenCalls: Array = []; const authKeys: string[] = []; From 37fdbca8e804036a3c679f8ec71dfd85636c9384 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 21 Jul 2026 13:56:32 +0800 Subject: [PATCH 02/11] fix(config): strip env-bound fields before persisting config writes Environment overrides resolved into the effective config could be echoed back through IConfigService.set/replace (e.g. GET then POST /api/v1/config) and persisted into config.toml, outliving the env var. This affected the pre-existing image / subagent / keep-alive bindings as well as the new loop control and max-running-tasks bindings. Extend ConfigStripEnv with a getEnv parameter and add a shared stripEnvBoundFields helper: while a field's env var is set, writes restore the field's raw on-disk value (or drop it) instead of persisting an echoed env value; when unset, normal writes persist. Register it for the loopControl, task/background, image, and subagent sections. Also fix ConfigService.stripEnv looking up rawSnake by the camelCase domain key; on-disk sections are keyed snake_case. --- .agents/skills/agent-core-dev/config.md | 13 +- .changeset/strip-env-overrides-on-persist.md | 6 + .../src/agent/loop/configSection.ts | 14 +- .../src/agent/media/configSection.ts | 14 +- .../src/agent/task/configSection.ts | 26 ++- .../agent-core-v2/src/app/config/config.ts | 46 +++++- .../src/app/config/configService.ts | 5 +- .../src/session/subagent/configSection.ts | 16 +- .../test/app/config/config.test.ts | 156 ++++++++++++++++++ 9 files changed, 278 insertions(+), 18 deletions(-) create mode 100644 .changeset/strip-env-overrides-on-persist.md diff --git a/.agents/skills/agent-core-dev/config.md b/.agents/skills/agent-core-dev/config.md index 477fd05ecd..c83b06ba14 100644 --- a/.agents/skills/agent-core-dev/config.md +++ b/.agents/skills/agent-core-dev/config.md @@ -150,8 +150,17 @@ Each field is an `EnvBinding` — a string (env var name) or 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. -`stripEnv(value, rawSnake?)` removes env-derived fields before `set`/`replace` -persists, so env overrides never leak into `config.toml`. +`stripEnv(value, rawSnake?, getEnv?)` removes env-derived fields before `set`/`replace` +persists, so env overrides never leak into `config.toml`. `rawSnake` is the +section's on-disk sub-object (looked up by the snake_case key, so camelCase +domains like `loopControl` resolve correctly), and `getEnv` reads the live env +bag. For fields that are **both user-persistable and env-overridable**, register +`stripEnvBoundFields
([{ field, env }])` (from `#/app/config/config`): +while a field's env var is set, writes restore the field's raw on-disk value +(or drop it) instead of persisting an echoed env value; when unset, normal +writes persist. Env-only fields/sections need no env check — strip them +unconditionally (e.g. thinking's `forcedEffort`, cron's whole-section +`() => undefined`). Business domains read `config.get('section')`; they never read env directly, and never write their own env-merge logic. diff --git a/.changeset/strip-env-overrides-on-persist.md b/.changeset/strip-env-overrides-on-persist.md new file mode 100644 index 0000000000..112b829fd3 --- /dev/null +++ b/.changeset/strip-env-overrides-on-persist.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kimi-code": patch +--- + +Fix environment override values (such as KIMI_IMAGE_MAX_EDGE_PX, KIMI_SUBAGENT_TIMEOUT_MS, KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT) being persisted into config.toml when a config update is written through the server config API while the env var is set. diff --git a/packages/agent-core-v2/src/agent/loop/configSection.ts b/packages/agent-core-v2/src/agent/loop/configSection.ts index ca2129c6bc..63babfb7be 100644 --- a/packages/agent-core-v2/src/agent/loop/configSection.ts +++ b/packages/agent-core-v2/src/agent/loop/configSection.ts @@ -11,14 +11,14 @@ * `env > config.toml > default` and re-applies the env binding on every read. * Self-registered at module load via `registerConfigSection`. * - * No `stripEnv` is registered: nothing calls `set`/`replace` for `loopControl`, - * and `raw`/`rawSnake` are always env-free (the env overlay lands only in - * `effective`), so an env override can never be written to `config.toml`. + * While a field's env var is set, `stripEnvBoundFields` restores its raw + * on-disk value before `set`/`replace` persists, so an env override echoed + * back through a config write can never leak into `config.toml`. */ import { z } from 'zod'; -import { type EnvBindings, envBindings } from '#/app/config/config'; +import { type EnvBindings, envBindings, stripEnvBoundFields } from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; import { plainObjectToToml, transformPlainObject } from '#/app/config/toml'; @@ -50,6 +50,11 @@ export const loopControlEnvBindings: EnvBindings = envBindings(Loop maxRetriesPerStep: { env: LOOP_MAX_RETRIES_PER_STEP_ENV, parse: parseNonNegativeInt }, }); +export const stripLoopControlEnv = stripEnvBoundFields([ + { field: 'maxStepsPerTurn', env: LOOP_MAX_STEPS_PER_TURN_ENV }, + { field: 'maxRetriesPerStep', env: LOOP_MAX_RETRIES_PER_STEP_ENV }, +]); + export const loopControlFromToml = (rawSnake: unknown): unknown => { if (rawSnake === null || typeof rawSnake !== 'object' || Array.isArray(rawSnake)) return rawSnake; const out = transformPlainObject(rawSnake as Record); @@ -69,4 +74,5 @@ registerConfigSection(LOOP_CONTROL_SECTION, LoopControlSchema, { fromToml: loopControlFromToml, toToml: loopControlToToml, env: loopControlEnvBindings, + stripEnv: stripLoopControlEnv, }); diff --git a/packages/agent-core-v2/src/agent/media/configSection.ts b/packages/agent-core-v2/src/agent/media/configSection.ts index 6cdc23c527..2559eaa14b 100644 --- a/packages/agent-core-v2/src/agent/media/configSection.ts +++ b/packages/agent-core-v2/src/agent/media/configSection.ts @@ -9,9 +9,9 @@ * `KIMI_IMAGE_READ_BYTE_BUDGET`); `config` resolves each field as * `env > config.toml > default` and re-applies the env binding on every read. * - * No `stripEnv` is registered: nothing calls `set`/`replace` for `image`, and - * `raw`/`rawSnake` are always env-free (the env overlay lands only in - * `effective`), so an env override can never be written to `config.toml`. + * While a field's env var is set, `stripEnvBoundFields` restores its raw + * on-disk value before `set`/`replace` persists, so an env override echoed + * back through a config write can never leak into `config.toml`. * * The compression support module (`#/agent/media/image-compress`) stays * config-agnostic: `ImageConfigBridge` reads this env-resolved section and @@ -22,7 +22,7 @@ import { z } from 'zod'; -import { type EnvBindings, envBindings } from '#/app/config/config'; +import { type EnvBindings, envBindings, stripEnvBoundFields } from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; export const IMAGE_SECTION = 'image'; @@ -49,7 +49,13 @@ export const imageEnvBindings: EnvBindings = envBindings(ImageConfi readByteBudget: { env: IMAGE_READ_BYTE_BUDGET_ENV, parse: parsePositiveInt }, }); +export const stripImageEnv = stripEnvBoundFields([ + { field: 'maxEdgePx', env: IMAGE_MAX_EDGE_ENV }, + { field: 'readByteBudget', env: IMAGE_READ_BYTE_BUDGET_ENV }, +]); + registerConfigSection(IMAGE_SECTION, ImageConfigSchema, { defaultValue: {}, env: imageEnvBindings, + stripEnv: stripImageEnv, }); diff --git a/packages/agent-core-v2/src/agent/task/configSection.ts b/packages/agent-core-v2/src/agent/task/configSection.ts index 0824d6e6c2..c804150120 100644 --- a/packages/agent-core-v2/src/agent/task/configSection.ts +++ b/packages/agent-core-v2/src/agent/task/configSection.ts @@ -8,7 +8,9 @@ * `keepAliveOnExit` and `maxRunningTasks` also * accept the v1 env overrides `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` / * `KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS` - * (applied live by the config env overlay, never persisted). Also owns the + * (applied live by the config env overlay; while a field's env var is set, + * `stripEnvBoundFields` restores its raw on-disk value before persistence, so + * env values never leak into `config.toml`). Also owns the * `kimi -p` print-mode background policy (`printBackgroundMode` / * `printWaitCeilingS` / `printMaxTurns`), resolved with v1 semantics by * `resolvePrintBackgroundMode`. Self-registered @@ -19,7 +21,12 @@ import { z } from 'zod'; import { parseBooleanEnv } from '#/_base/utils/env'; -import { type EnvBindings, envBindings, type IConfigService } from '#/app/config/config'; +import { + type EnvBindings, + envBindings, + stripEnvBoundFields, + type IConfigService, +} from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; export const TASK_SECTION = 'task'; @@ -79,5 +86,16 @@ export const taskEnvBindings: EnvBindings = envBindings(AgentTa maxRunningTasks: { env: MAX_RUNNING_TASKS_ENV, parse: parsePositiveInt }, }); -registerConfigSection(TASK_SECTION, AgentTaskConfigSchema, { env: taskEnvBindings }); -registerConfigSection(LEGACY_BACKGROUND_SECTION, AgentTaskConfigSchema, { env: taskEnvBindings }); +export const stripTaskEnv = stripEnvBoundFields([ + { field: 'keepAliveOnExit', env: KEEP_ALIVE_ON_EXIT_ENV }, + { field: 'maxRunningTasks', env: MAX_RUNNING_TASKS_ENV }, +]); + +registerConfigSection(TASK_SECTION, AgentTaskConfigSchema, { + env: taskEnvBindings, + stripEnv: stripTaskEnv, +}); +registerConfigSection(LEGACY_BACKGROUND_SECTION, AgentTaskConfigSchema, { + env: taskEnvBindings, + stripEnv: stripTaskEnv, +}); diff --git a/packages/agent-core-v2/src/app/config/config.ts b/packages/agent-core-v2/src/app/config/config.ts index a714d4c285..3038f92e29 100644 --- a/packages/agent-core-v2/src/app/config/config.ts +++ b/packages/agent-core-v2/src/app/config/config.ts @@ -13,6 +13,8 @@ import type { Event } from '#/_base/event'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { isPlainObject } from './configPure'; + export interface ConfigSchema { parse(value: unknown): T; } @@ -35,7 +37,49 @@ export function envBindings(_schema: ConfigSchema, bindings: EnvBindings = (value: T, rawSnake?: unknown) => T | undefined; +export type ConfigStripEnv = ( + value: T, + rawSnake?: unknown, + getEnv?: (name: string) => string | undefined, +) => T | undefined; + +export interface EnvBoundField { + /** camelCase in-memory field name; the snake_case on-disk key is derived from it. */ + readonly field: string; + /** Env var that owns the field while set. */ + readonly env: string; +} + +function camelToSnake(str: string): string { + return str.replaceAll(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`); +} + +/** + * Build a `stripEnv` for sections whose fields are both user-persistable and + * env-overridable. While a field's env var is set, the env value owns the + * field: `set`/`replace` restores the field to its raw on-disk value (or drops + * it when absent on disk), so an env overlay echoed back through a config + * write can never be persisted. Fields whose env var is not set pass through + * unchanged, so normal writes keep working. + */ +export function stripEnvBoundFields(fields: readonly EnvBoundField[]): ConfigStripEnv { + return (value, rawSnake, getEnv) => { + if (getEnv === undefined || value === null || typeof value !== 'object') return value; + const raw = isPlainObject(rawSnake) ? rawSnake : {}; + let out: Record | undefined; + for (const { field, env } of fields) { + if (getEnv(env) === undefined) continue; + out ??= { ...(value as Record) }; + const snake = camelToSnake(field); + if (raw[snake] !== undefined) { + out[field] = raw[snake]; + } else { + delete out[field]; + } + } + return (out ?? value) as T; + }; +} export type ConfigFromToml = (rawSnake: unknown) => unknown; diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index d9a9ebaab7..eafc1d41fd 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -367,7 +367,10 @@ export class ConfigService extends Disposable implements IConfigService { let result = value; const section = this.registry.getSection(domain); if (section?.stripEnv !== undefined) { - result = section.stripEnv(result, this.rawSnake[domain]); + const getEnv = (name: string): string | undefined => this.bootstrap.getEnv(name); + // On-disk sections are keyed snake_case (see applySectionToToml), so a + // camelCase domain like `loopControl` lives at rawSnake['loop_control']. + result = section.stripEnv(result, this.rawSnake[camelToSnake(domain)], getEnv); } if (result === undefined) return result; for (const overlay of this.registry.listEffectiveOverlays()) { diff --git a/packages/agent-core-v2/src/session/subagent/configSection.ts b/packages/agent-core-v2/src/session/subagent/configSection.ts index 57aa44d287..a7d560599f 100644 --- a/packages/agent-core-v2/src/session/subagent/configSection.ts +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -4,7 +4,9 @@ * * Owns the `[subagent]` configuration section (`timeout_ms` on disk) together * with the `KIMI_SUBAGENT_TIMEOUT_MS` env override, mirroring v1's - * `resolveSubagentTimeoutMs` precedence (env > config.toml > 2h default). Both + * `resolveSubagentTimeoutMs` precedence (env > config.toml > 2h default). While + * the env var is set, `stripEnvBoundFields` restores the raw on-disk value + * before persistence, so the override never leaks into `config.toml`. Both * collaboration tools — `Agent` in this domain and `AgentSwarm` in the `swarm` * domain — resolve their per-run timeout through `resolveSubagentTimeoutMs`, * and render the timeout message with `formatSubagentTimeoutDescription`. @@ -14,7 +16,12 @@ import { z } from 'zod'; -import { type EnvBindings, envBindings, type IConfigService } from '#/app/config/config'; +import { + type EnvBindings, + envBindings, + stripEnvBoundFields, + type IConfigService, +} from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; export const SUBAGENT_SECTION = 'subagent'; @@ -44,9 +51,14 @@ export const subagentEnvBindings: EnvBindings = envBindings( }, ); +export const stripSubagentEnv = stripEnvBoundFields([ + { field: 'timeoutMs', env: SUBAGENT_TIMEOUT_ENV }, +]); + registerConfigSection(SUBAGENT_SECTION, SubagentConfigSchema, { defaultValue: { timeoutMs: DEFAULT_SUBAGENT_TIMEOUT_MS }, env: subagentEnvBindings, + stripEnv: stripSubagentEnv, }); /** 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 b795685bf5..fb020d60d6 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -58,7 +58,9 @@ import '#/session/subagent/configSection'; import { DEFAULT_SUBAGENT_TIMEOUT_MS, resolveSubagentTimeoutMs, + SUBAGENT_SECTION, SUBAGENT_TIMEOUT_ENV, + type SubagentConfig, } from '#/session/subagent/configSection'; import { ILogService } from '#/_base/log/log'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; @@ -534,6 +536,41 @@ describe('image config section', () => { disposables.dispose(); }); + + it('restores env-owned fields to the raw value on set() while the env var is set', async () => { + const env: Record = { 'KIMI_IMAGE_MAX_EDGE_PX': '1500' }; + const disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + const storage = new InMemoryStorageService(); + await storage.write( + '', + 'config.toml', + new TextEncoder().encode('[image]\nread_byte_budget = 131072\n'), + ); + 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; + + // A client echoing the env-overlaid section back (plus a genuine edit). + await config.set(IMAGE_SECTION, { maxEdgePx: 1500, readByteBudget: 262144 }); + + // Runtime resolution still lets the env win… + expect(config.get(IMAGE_SECTION)).toEqual({ + maxEdgePx: 1500, + readByteBudget: 262144, + }); + // …but persistence drops the env-owned field and keeps the genuine edit. + expect(config.inspect(IMAGE_SECTION).userValue).toEqual({ + readByteBudget: 262144, + }); + + disposables.dispose(); + }); }); describe('loopControl config section', () => { @@ -582,6 +619,76 @@ describe('loopControl config section', () => { disposables.dispose(); }); + + 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', + }; + 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_steps_per_turn = 100\n'), + ); + 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; + + // A client echoing the env-overlaid section back (plus a genuine edit). + await config.set(LOOP_CONTROL_SECTION, { + maxStepsPerTurn: 7, + maxRetriesPerStep: 2, + reservedContextSize: 5000, + }); + + // Runtime resolution still lets the env win… + expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ + maxStepsPerTurn: 7, + maxRetriesPerStep: 2, + reservedContextSize: 5000, + }); + // …but persistence keeps the raw value and drops the env-only field. + expect(config.inspect(LOOP_CONTROL_SECTION).userValue).toEqual({ + maxStepsPerTurn: 100, + reservedContextSize: 5000, + }); + 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'); + + disposables.dispose(); + }); + + it('persists env-bound fields normally when no env var is set', async () => { + const env: Record = {}; + const disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + ix.stub(ILogService, stubLog()); + ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg', env)); + ix.stub(IFileSystemStorageService, new InMemoryStorageService()); + ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); + ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.set(IConfigService, new SyncDescriptor(ConfigService)); + const config = ix.get(IConfigService); + await config.ready; + + await config.set(LOOP_CONTROL_SECTION, { maxStepsPerTurn: 50 }); + + expect(config.inspect(LOOP_CONTROL_SECTION).userValue).toEqual({ + maxStepsPerTurn: 50, + }); + + disposables.dispose(); + }); }); describe('task config section', () => { @@ -674,6 +781,38 @@ describe('task config section', () => { disposables.dispose(); }); + it('restores env-owned fields to the raw value on set() while the env var is set', async () => { + const env: Record = { + [KEEP_ALIVE_ON_EXIT_ENV]: 'true', + [MAX_RUNNING_TASKS_ENV]: '8', + }; + const { config, disposables } = await createTaskConfig( + env, + '[background]\nmax_running_tasks = 3\n', + ); + + // A client echoing the env-overlaid section back (plus a genuine edit). + await config.set('background', { + keepAliveOnExit: true, + maxRunningTasks: 8, + killGracePeriodMs: 25, + }); + + // Runtime resolution still lets the env win… + expect(config.get('background')).toEqual({ + keepAliveOnExit: true, + maxRunningTasks: 8, + killGracePeriodMs: 25, + }); + // …but persistence keeps the raw value and drops the env-only field. + expect(config.inspect('background').userValue).toEqual({ + maxRunningTasks: 3, + killGracePeriodMs: 25, + }); + + disposables.dispose(); + }); + async function createTaskConfig(env: Record, toml?: string) { const disposables = new DisposableStore(); const ix = disposables.add(new TestInstantiationService()); @@ -788,6 +927,23 @@ describe('subagent config section', () => { disposables.dispose(); }); + + it('restores the env-owned timeout to the raw value on set() while the env var is set', async () => { + const env: Record = { [SUBAGENT_TIMEOUT_ENV]: '7000' }; + const { config, disposables } = await createConfig(env, '[subagent]\ntimeout_ms = 5000\n'); + + // A client echoing the env-overlaid section back. + await config.set(SUBAGENT_SECTION, { timeoutMs: 7000 }); + + // Runtime resolution still lets the env win… + expect(resolveSubagentTimeoutMs(config)).toBe(7000); + // …but persistence keeps the raw value. + expect(config.inspect(SUBAGENT_SECTION).userValue).toEqual({ + timeoutMs: 5000, + }); + + disposables.dispose(); + }); }); function toolNames(value: unknown): string[] { From 599bd08e842bc6f71327f687e96aeac1d5d73844 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 21 Jul 2026 14:33:03 +0800 Subject: [PATCH 03/11] fix(config): honor binding parsers when stripping env fields on persist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An invalid env value (e.g. KIMI_LOOP_MAX_STEPS_PER_TURN=abc) is ignored on the read path but still marked the field env-owned on the write path, so a config write for that field was silently dropped. stripEnvBoundFields now derives the guard from the section's envBindings and skips fields whose env value fails the binding's parse, so invalid env values are ignored on both paths — and the duplicated field/env descriptor list is gone. Also drop function-level comments added beside helpers; agent-core-v2 keeps comments solely in the top-of-file block, so the strip semantics now live in the config.ts / configService.ts headers. --- .agents/skills/agent-core-dev/config.md | 13 ++++--- .../src/agent/loop/configSection.ts | 6 +-- .../src/agent/media/configSection.ts | 5 +-- .../src/agent/task/configSection.ts | 6 +-- .../agent-core-v2/src/app/config/config.ts | 34 ++++++++-------- .../src/app/config/configService.ts | 5 +-- .../src/session/subagent/configSection.ts | 4 +- .../test/app/config/config.test.ts | 39 +++++++++++++++++++ 8 files changed, 70 insertions(+), 42 deletions(-) diff --git a/.agents/skills/agent-core-dev/config.md b/.agents/skills/agent-core-dev/config.md index c83b06ba14..cfb38fe9c4 100644 --- a/.agents/skills/agent-core-dev/config.md +++ b/.agents/skills/agent-core-dev/config.md @@ -155,12 +155,13 @@ persists, so env overrides never leak into `config.toml`. `rawSnake` is the section's on-disk sub-object (looked up by the snake_case key, so camelCase domains like `loopControl` resolve correctly), and `getEnv` reads the live env bag. For fields that are **both user-persistable and env-overridable**, register -`stripEnvBoundFields
([{ field, env }])` (from `#/app/config/config`): -while a field's env var is set, writes restore the field's raw on-disk value -(or drop it) instead of persisting an echoed env value; when unset, normal -writes persist. Env-only fields/sections need no env check — strip them -unconditionally (e.g. thinking's `forcedEffort`, cron's whole-section -`() => undefined`). +`stripEnv: stripEnvBoundFields(sectionEnvBindings)` (from `#/app/config/config`) +— it derives the guard from the same bindings the read path uses: while a +field's env var resolves to a value, writes restore the field's raw on-disk +value (or drop it) instead of persisting an echoed env value; an env value that +fails the binding's `parse` owns nothing, so writes pass through. Env-only +fields/sections need no env check — strip them unconditionally (e.g. thinking's +`forcedEffort`, cron's whole-section `() => undefined`). Business domains read `config.get('section')`; they never read env directly, and never write their own env-merge logic. diff --git a/packages/agent-core-v2/src/agent/loop/configSection.ts b/packages/agent-core-v2/src/agent/loop/configSection.ts index 63babfb7be..6699dd2fe5 100644 --- a/packages/agent-core-v2/src/agent/loop/configSection.ts +++ b/packages/agent-core-v2/src/agent/loop/configSection.ts @@ -37,7 +37,6 @@ export const LoopControlSchema = z.object({ export type LoopControl = z.infer; -/** Parse the env overrides; anything but a non-negative integer is ignored. */ function parseNonNegativeInt(raw: string): number | undefined { const value = raw.trim(); if (value.length === 0 || !/^\d+$/.test(value)) return undefined; @@ -50,10 +49,7 @@ export const loopControlEnvBindings: EnvBindings = envBindings(Loop maxRetriesPerStep: { env: LOOP_MAX_RETRIES_PER_STEP_ENV, parse: parseNonNegativeInt }, }); -export const stripLoopControlEnv = stripEnvBoundFields([ - { field: 'maxStepsPerTurn', env: LOOP_MAX_STEPS_PER_TURN_ENV }, - { field: 'maxRetriesPerStep', env: LOOP_MAX_RETRIES_PER_STEP_ENV }, -]); +export const stripLoopControlEnv = stripEnvBoundFields(loopControlEnvBindings); export const loopControlFromToml = (rawSnake: unknown): unknown => { if (rawSnake === null || typeof rawSnake !== 'object' || Array.isArray(rawSnake)) return rawSnake; diff --git a/packages/agent-core-v2/src/agent/media/configSection.ts b/packages/agent-core-v2/src/agent/media/configSection.ts index 2559eaa14b..12749b6e46 100644 --- a/packages/agent-core-v2/src/agent/media/configSection.ts +++ b/packages/agent-core-v2/src/agent/media/configSection.ts @@ -49,10 +49,7 @@ export const imageEnvBindings: EnvBindings = envBindings(ImageConfi readByteBudget: { env: IMAGE_READ_BYTE_BUDGET_ENV, parse: parsePositiveInt }, }); -export const stripImageEnv = stripEnvBoundFields([ - { field: 'maxEdgePx', env: IMAGE_MAX_EDGE_ENV }, - { field: 'readByteBudget', env: IMAGE_READ_BYTE_BUDGET_ENV }, -]); +export const stripImageEnv = stripEnvBoundFields(imageEnvBindings); registerConfigSection(IMAGE_SECTION, ImageConfigSchema, { defaultValue: {}, diff --git a/packages/agent-core-v2/src/agent/task/configSection.ts b/packages/agent-core-v2/src/agent/task/configSection.ts index c804150120..b4774894a7 100644 --- a/packages/agent-core-v2/src/agent/task/configSection.ts +++ b/packages/agent-core-v2/src/agent/task/configSection.ts @@ -73,7 +73,6 @@ export function resolvePrintBackgroundMode(config: IConfigService): PrintBackgro export const KEEP_ALIVE_ON_EXIT_ENV = 'KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT'; export const MAX_RUNNING_TASKS_ENV = 'KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS'; -/** Parse the env override; anything but a positive integer is ignored. */ function parsePositiveInt(raw: string): number | undefined { const value = raw.trim(); if (value.length === 0 || !/^\d+$/.test(value)) return undefined; @@ -86,10 +85,7 @@ export const taskEnvBindings: EnvBindings = envBindings(AgentTa maxRunningTasks: { env: MAX_RUNNING_TASKS_ENV, parse: parsePositiveInt }, }); -export const stripTaskEnv = stripEnvBoundFields([ - { field: 'keepAliveOnExit', env: KEEP_ALIVE_ON_EXIT_ENV }, - { field: 'maxRunningTasks', env: MAX_RUNNING_TASKS_ENV }, -]); +export const stripTaskEnv = stripEnvBoundFields(taskEnvBindings); registerConfigSection(TASK_SECTION, AgentTaskConfigSchema, { env: taskEnvBindings, diff --git a/packages/agent-core-v2/src/app/config/config.ts b/packages/agent-core-v2/src/app/config/config.ts index 3038f92e29..0f09aec054 100644 --- a/packages/agent-core-v2/src/app/config/config.ts +++ b/packages/agent-core-v2/src/app/config/config.ts @@ -8,6 +8,14 @@ * to edits through two change events — `onDidChangeConfiguration` (a domain was touched) and * `onDidSectionChange` (the delivered value actually changed, deep-diffed) — * each carrying the delivered `value` and `previousValue`. + * + * Sections may bind fields to env vars (`envBindings`), resolved as + * env > user config > default on every read; an env value that fails its + * 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 raw on-disk value + * (or drops it) instead of persisting an echoed env value; otherwise writes + * pass through untouched. */ import type { Event } from '#/_base/event'; @@ -43,32 +51,26 @@ export type ConfigStripEnv = ( getEnv?: (name: string) => string | undefined, ) => T | undefined; -export interface EnvBoundField { - /** camelCase in-memory field name; the snake_case on-disk key is derived from it. */ - readonly field: string; - /** Env var that owns the field while set. */ - readonly env: string; +function isEnvBinding(value: unknown): value is EnvBinding { + return typeof value === 'string' || (isPlainObject(value) && 'env' in value); } function camelToSnake(str: string): string { return str.replaceAll(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`); } -/** - * Build a `stripEnv` for sections whose fields are both user-persistable and - * env-overridable. While a field's env var is set, the env value owns the - * field: `set`/`replace` restores the field to its raw on-disk value (or drops - * it when absent on disk), so an env overlay echoed back through a config - * write can never be persisted. Fields whose env var is not set pass through - * unchanged, so normal writes keep working. - */ -export function stripEnvBoundFields(fields: readonly EnvBoundField[]): ConfigStripEnv { +export function stripEnvBoundFields(bindings: EnvBindings): ConfigStripEnv { return (value, rawSnake, getEnv) => { if (getEnv === undefined || value === null || typeof value !== 'object') return value; + if (!isPlainObject(bindings) || isEnvBinding(bindings)) return value; const raw = isPlainObject(rawSnake) ? rawSnake : {}; let out: Record | undefined; - for (const { field, env } of fields) { - if (getEnv(env) === undefined) continue; + 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; out ??= { ...(value as Record) }; const snake = camelToSnake(field); if (raw[snake] !== undefined) { diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index eafc1d41fd..c5bef2e09d 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -5,7 +5,8 @@ * value by precedence across defaults, the user config file, and per-run memory * overrides (highest, never persisted), and persists writes only for the `User` * target. Maintains four layered views of a domain — `rawSnake` (snake_case - * write base, kept for lossless round-trip), `raw` (camelCase, env-free), + * write base keyed by the on-disk section key, kept for lossless round-trip), + * `raw` (camelCase, env-free), * `effective` (validated, env overlay applied), and `memory` (per-run overrides) * — plus a `delivered` snapshot per domain used as the diff base for * `onDidSectionChange`. Reads config paths and the environment overlay through @@ -368,8 +369,6 @@ export class ConfigService extends Disposable implements IConfigService { const section = this.registry.getSection(domain); if (section?.stripEnv !== undefined) { const getEnv = (name: string): string | undefined => this.bootstrap.getEnv(name); - // On-disk sections are keyed snake_case (see applySectionToToml), so a - // camelCase domain like `loopControl` lives at rawSnake['loop_control']. result = section.stripEnv(result, this.rawSnake[camelToSnake(domain)], getEnv); } if (result === undefined) return result; diff --git a/packages/agent-core-v2/src/session/subagent/configSection.ts b/packages/agent-core-v2/src/session/subagent/configSection.ts index a7d560599f..42e5430ce4 100644 --- a/packages/agent-core-v2/src/session/subagent/configSection.ts +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -51,9 +51,7 @@ export const subagentEnvBindings: EnvBindings = envBindings( }, ); -export const stripSubagentEnv = stripEnvBoundFields([ - { field: 'timeoutMs', env: SUBAGENT_TIMEOUT_ENV }, -]); +export const stripSubagentEnv = stripEnvBoundFields(subagentEnvBindings); registerConfigSection(SUBAGENT_SECTION, SubagentConfigSchema, { defaultValue: { timeoutMs: DEFAULT_SUBAGENT_TIMEOUT_MS }, 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 fb020d60d6..9c56b34405 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -689,6 +689,30 @@ describe('loopControl config section', () => { disposables.dispose(); }); + + it('does not strip a field whose env value fails to parse', async () => { + const env: Record = { [LOOP_MAX_STEPS_PER_TURN_ENV]: 'abc' }; + const disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + ix.stub(ILogService, stubLog()); + ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg', env)); + ix.stub(IFileSystemStorageService, new InMemoryStorageService()); + ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); + ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.set(IConfigService, new SyncDescriptor(ConfigService)); + const config = ix.get(IConfigService); + await config.ready; + + await config.set(LOOP_CONTROL_SECTION, { maxStepsPerTurn: 50 }); + + // The invalid env value is ignored on both the read and the write path. + expect(config.get(LOOP_CONTROL_SECTION).maxStepsPerTurn).toBe(50); + expect(config.inspect(LOOP_CONTROL_SECTION).userValue).toEqual({ + maxStepsPerTurn: 50, + }); + + disposables.dispose(); + }); }); describe('task config section', () => { @@ -813,6 +837,21 @@ describe('task config section', () => { disposables.dispose(); }); + it('does not strip a field whose env value fails to parse', async () => { + const env: Record = { [KEEP_ALIVE_ON_EXIT_ENV]: 'abc' }; + const { config, disposables } = await createTaskConfig(env); + + await config.set('background', { keepAliveOnExit: true }); + + // The invalid env value is ignored on both the read and the write path. + expect(config.get('background')?.keepAliveOnExit).toBe(true); + expect(config.inspect('background').userValue).toEqual({ + keepAliveOnExit: true, + }); + + disposables.dispose(); + }); + async function createTaskConfig(env: Record, toml?: string) { const disposables = new DisposableStore(); const ix = disposables.add(new TestInstantiationService()); From f2106cf997b6906aecd97e6a54ca93c4bd510c75 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 21 Jul 2026 15:33:31 +0800 Subject: [PATCH 04/11] fix(config): re-apply env overlays from the env-free base on every read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from review: - ConfigService.get()/getAll() re-applied section env bindings on the already-overlaid effective cache (and get() mutated it in place), so a valid override degraded to invalid or unset kept serving the stale value until the next reload — and an echoed stale value could then be persisted by a config write. Reads now recompute from a cached env-free validated base, so degraded or removed env values fall back to the file immediately. - stripEnvBoundFields restored env-owned fields from the raw snake sub-object, missing values persisted under legacy keys (e.g. max_steps_per_run). Section stripEnv now receives the env-free, fromToml-normalized raw base, so legacy aliases are honored. --- .agents/skills/agent-core-dev/config.md | 19 ++-- .changeset/fresh-env-reapply-on-read.md | 6 ++ .../src/agent/loop/configSection.ts | 4 +- .../src/agent/media/configSection.ts | 4 +- .../src/agent/task/configSection.ts | 2 +- .../agent-core-v2/src/app/config/config.ts | 22 ++--- .../src/app/config/configService.ts | 92 ++++++++++--------- .../src/session/subagent/configSection.ts | 2 +- .../test/app/config/config.test.ts | 71 ++++++++++++++ 9 files changed, 152 insertions(+), 70 deletions(-) create mode 100644 .changeset/fresh-env-reapply-on-read.md diff --git a/.agents/skills/agent-core-dev/config.md b/.agents/skills/agent-core-dev/config.md index cfb38fe9c4..84d66fa293 100644 --- a/.agents/skills/agent-core-dev/config.md +++ b/.agents/skills/agent-core-dev/config.md @@ -150,15 +150,15 @@ Each field is an `EnvBinding` — a string (env var name) or 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. -`stripEnv(value, rawSnake?, getEnv?)` removes env-derived fields before `set`/`replace` -persists, so env overrides never leak into `config.toml`. `rawSnake` is the -section's on-disk sub-object (looked up by the snake_case key, so camelCase -domains like `loopControl` resolve correctly), and `getEnv` reads the live env -bag. For fields that are **both user-persistable and env-overridable**, register +`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 +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 -field's env var resolves to a value, writes restore the field's raw on-disk -value (or drop it) instead of persisting an echoed env value; an env value that +field's env var resolves to a value, writes restore the field's raw-base value +(or drop it) instead of persisting an echoed env value; an env value that fails the binding's `parse` owns nothing, so writes pass through. Env-only fields/sections need no env check — strip them unconditionally (e.g. thinking's `forcedEffort`, cron's whole-section `() => undefined`). @@ -227,11 +227,12 @@ This means registration order is never a correctness concern — you do not need - **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. - **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 three views: +`ConfigService` keeps four views: - `rawSnake` — snake_case clone of the file; the write base, never carries the env overlay. - `raw` — camelCase, env-free; the read/set/replace base. -- `effective` — validated `raw` plus the env overlay; what `get()` returns. +- `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. ### `KIMI_MODEL_*` env overlay diff --git a/.changeset/fresh-env-reapply-on-read.md b/.changeset/fresh-env-reapply-on-read.md new file mode 100644 index 0000000000..7d894c908c --- /dev/null +++ b/.changeset/fresh-env-reapply-on-read.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kimi-code": patch +--- + +Fix config env overrides (such as KIMI_IMAGE_MAX_EDGE_PX or KIMI_LOOP_MAX_STEPS_PER_TURN) sticking after the env var is changed to an invalid value or removed: reads now recompute from the on-disk config, so the value falls back immediately instead of keeping the previous override until the next config reload. diff --git a/packages/agent-core-v2/src/agent/loop/configSection.ts b/packages/agent-core-v2/src/agent/loop/configSection.ts index 6699dd2fe5..2e63973cf4 100644 --- a/packages/agent-core-v2/src/agent/loop/configSection.ts +++ b/packages/agent-core-v2/src/agent/loop/configSection.ts @@ -11,8 +11,8 @@ * `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 raw - * on-disk value before `set`/`replace` persists, so an env override echoed + * While a field's env var is set, `stripEnvBoundFields` restores its env-free + * raw value before `set`/`replace` persists, so an env override echoed * back through a config write can never leak into `config.toml`. */ diff --git a/packages/agent-core-v2/src/agent/media/configSection.ts b/packages/agent-core-v2/src/agent/media/configSection.ts index 12749b6e46..2e432cd768 100644 --- a/packages/agent-core-v2/src/agent/media/configSection.ts +++ b/packages/agent-core-v2/src/agent/media/configSection.ts @@ -9,8 +9,8 @@ * `KIMI_IMAGE_READ_BYTE_BUDGET`); `config` resolves each field as * `env > config.toml > default` and re-applies the env binding on every read. * - * While a field's env var is set, `stripEnvBoundFields` restores its raw - * on-disk value before `set`/`replace` persists, so an env override echoed + * While a field's env var is set, `stripEnvBoundFields` restores its env-free + * raw value before `set`/`replace` persists, so an env override echoed * back through a config write can never leak into `config.toml`. * * The compression support module (`#/agent/media/image-compress`) stays diff --git a/packages/agent-core-v2/src/agent/task/configSection.ts b/packages/agent-core-v2/src/agent/task/configSection.ts index b4774894a7..e0e518f406 100644 --- a/packages/agent-core-v2/src/agent/task/configSection.ts +++ b/packages/agent-core-v2/src/agent/task/configSection.ts @@ -9,7 +9,7 @@ * accept the v1 env overrides `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` / * `KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS` * (applied live by the config env overlay; while a field's env var is set, - * `stripEnvBoundFields` restores its raw on-disk value before persistence, so + * `stripEnvBoundFields` restores its env-free raw value before persistence, so * env values never leak into `config.toml`). Also owns the * `kimi -p` print-mode background policy (`printBackgroundMode` / * `printWaitCeilingS` / `printMaxTurns`), resolved with v1 semantics by diff --git a/packages/agent-core-v2/src/app/config/config.ts b/packages/agent-core-v2/src/app/config/config.ts index 0f09aec054..b12cfbc9de 100644 --- a/packages/agent-core-v2/src/app/config/config.ts +++ b/packages/agent-core-v2/src/app/config/config.ts @@ -13,9 +13,10 @@ * env > user config > default on every read; an env value that fails its * 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 raw on-disk value - * (or drops it) instead of persisting an echoed env value; otherwise writes - * pass through untouched. + * 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. */ import type { Event } from '#/_base/event'; @@ -47,7 +48,7 @@ export function envBindings(_schema: ConfigSchema, bindings: EnvBindings = ( value: T, - rawSnake?: unknown, + raw?: unknown, getEnv?: (name: string) => string | undefined, ) => T | undefined; @@ -55,15 +56,11 @@ function isEnvBinding(value: unknown): value is EnvBinding { return typeof value === 'string' || (isPlainObject(value) && 'env' in value); } -function camelToSnake(str: string): string { - return str.replaceAll(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`); -} - export function stripEnvBoundFields(bindings: EnvBindings): ConfigStripEnv { - return (value, rawSnake, getEnv) => { + return (value, raw, getEnv) => { if (getEnv === undefined || value === null || typeof value !== 'object') return value; if (!isPlainObject(bindings) || isEnvBinding(bindings)) return value; - const raw = isPlainObject(rawSnake) ? rawSnake : {}; + const base = isPlainObject(raw) ? raw : {}; let out: Record | undefined; for (const [field, binding] of Object.entries(bindings)) { if (binding === undefined || !isEnvBinding(binding)) continue; @@ -72,9 +69,8 @@ export function stripEnvBoundFields(bindings: EnvBindings): ConfigStripEnv const parse = typeof binding === 'string' ? undefined : binding.parse; if (parse !== undefined && parse(rawEnv) === undefined) continue; out ??= { ...(value as Record) }; - const snake = camelToSnake(field); - if (raw[snake] !== undefined) { - out[field] = raw[snake]; + if (base[field] !== undefined) { + out[field] = base[field]; } else { delete out[field]; } diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index c5bef2e09d..e0f4129d78 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -4,10 +4,13 @@ * Owns the section registry and the layered global config state: resolves a * value by precedence across defaults, the user config file, and per-run memory * overrides (highest, never persisted), and persists writes only for the `User` - * target. Maintains four layered views of a domain — `rawSnake` (snake_case + * target. Maintains five layered views of a domain — `rawSnake` (snake_case * write base keyed by the on-disk section key, kept for lossless round-trip), - * `raw` (camelCase, env-free), - * `effective` (validated, env overlay applied), and `memory` (per-run overrides) + * `raw` (camelCase, env-free), `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), and `memory` + * (per-run overrides) * — plus a `delivered` snapshot per domain used as the diff base for * `onDidSectionChange`. Reads config paths and the environment overlay through * `bootstrap`, persists the TOML document through the `storage` TOML @@ -228,6 +231,7 @@ export class ConfigService extends Disposable implements IConfigService { private rawSnake: ResolvedConfig = {}; private raw: ResolvedConfig = {}; + private validated: ResolvedConfig = {}; private effective: ResolvedConfig = {}; private memory: ResolvedConfig = {}; private delivered: ResolvedConfig = {}; @@ -265,12 +269,7 @@ export class ConfigService extends Disposable implements IConfigService { } const section = this.registry.getSection(domain); if (section?.env !== undefined) { - const getEnv = (name: string): string | undefined => this.bootstrap.getEnv(name); - try { - const next = applySectionEnv(this.effective[domain], section.env, getEnv); - this.effective[domain] = this.registry.validate(domain, next); - } catch { - } + return this.freshEffective()[domain] as T; } return this.effective[domain] as T; } @@ -286,19 +285,14 @@ export class ConfigService extends Disposable implements IConfigService { } getAll(): ResolvedConfig { - const effective: ResolvedConfig = { ...this.effective }; - const getEnv = (name: string): string | undefined => this.bootstrap.getEnv(name); - for (const section of this.registry.listSections()) { - if (section.env === undefined || effective[section.domain] === undefined) continue; - try { - effective[section.domain] = this.registry.validate( - section.domain, - applySectionEnv(effective[section.domain], section.env, getEnv), - ); - } catch { - } - } - return { ...effective, ...this.memory }; + return { ...this.freshEffective(), ...this.memory }; + } + + private freshEffective(): ResolvedConfig { + const effective: ResolvedConfig = { ...this.validated }; + this.applySectionEnvBindings(effective, false); + this.applyEnvOverlay(effective, false); + return effective; } diagnostics(): readonly ConfigDiagnostic[] { @@ -369,7 +363,7 @@ export class ConfigService extends Disposable implements IConfigService { const section = this.registry.getSection(domain); if (section?.stripEnv !== undefined) { const getEnv = (name: string): string | undefined => this.bootstrap.getEnv(name); - result = section.stripEnv(result, this.rawSnake[camelToSnake(domain)], getEnv); + result = section.stripEnv(result, this.raw[domain], getEnv); } if (result === undefined) return result; for (const overlay of this.registry.listEffectiveOverlays()) { @@ -422,7 +416,9 @@ export class ConfigService extends Disposable implements IConfigService { domains?: readonly string[], ): void { const previous = this.effective; - const next = this.buildEffective(this.raw); + this.validated = this.buildValidated(this.raw); + const next = { ...this.validated }; + this.applySectionEnvBindings(next, true); this.applyEnvOverlay(next); this.effective = next; @@ -450,11 +446,11 @@ export class ConfigService extends Disposable implements IConfigService { } } - private buildEffective(raw: ResolvedConfig): ResolvedConfig { - const effective: ResolvedConfig = {}; + private buildValidated(raw: ResolvedConfig): ResolvedConfig { + const validated: ResolvedConfig = {}; for (const [domain, value] of Object.entries(raw)) { try { - effective[domain] = this.registry.validate(domain, value); + validated[domain] = this.registry.validate(domain, value); } catch (error) { this.diagnosticsList.push({ domain, @@ -464,10 +460,14 @@ export class ConfigService extends Disposable implements IConfigService { } } for (const section of this.registry.listSections()) { - if (effective[section.domain] === undefined && section.defaultValue !== undefined) { - effective[section.domain] = section.defaultValue; + if (validated[section.domain] === undefined && section.defaultValue !== undefined) { + validated[section.domain] = section.defaultValue; } } + return validated; + } + + private applySectionEnvBindings(effective: ResolvedConfig, reportErrors: boolean): void { const getEnv = (name: string): string | undefined => this.bootstrap.getEnv(name); for (const section of this.registry.listSections()) { if (section.env === undefined) continue; @@ -476,17 +476,18 @@ export class ConfigService extends Disposable implements IConfigService { const next = applySectionEnv(base, section.env, getEnv); effective[section.domain] = this.registry.validate(section.domain, next); } catch (error) { - this.diagnosticsList.push({ - domain: section.domain, - severity: 'warning', - message: `Ignoring env overlay for '${section.domain}': ${describeUnknownError(error)}`, - }); + if (reportErrors) { + this.diagnosticsList.push({ + domain: section.domain, + severity: 'warning', + message: `Ignoring env overlay for '${section.domain}': ${describeUnknownError(error)}`, + }); + } } } - return effective; } - private applyEnvOverlay(effective: ResolvedConfig): void { + private applyEnvOverlay(effective: ResolvedConfig, reportErrors = true): void { const getEnv = (name: string): string | undefined => this.bootstrap.getEnv(name); const validate = (domain: string, value: unknown): unknown => this.registry.validate(domain, value); @@ -494,17 +495,21 @@ export class ConfigService extends Disposable implements IConfigService { try { overlay.apply(effective, getEnv, validate); } catch (error) { - this.diagnosticsList.push({ - severity: 'warning', - message: `Ignoring config environment overlay: ${describeUnknownError(error)}`, - }); + if (reportErrors) { + this.diagnosticsList.push({ + severity: 'warning', + message: `Ignoring config environment overlay: ${describeUnknownError(error)}`, + }); + } } } } private reapplyOverlays(): void { const before = this.effective; - const next = this.buildEffective(this.raw); + this.validated = this.buildValidated(this.raw); + const next = { ...this.validated }; + this.applySectionEnvBindings(next, true); this.applyEnvOverlay(next); this.effective = next; this.commit('reload', [...new Set([...Object.keys(before), ...Object.keys(next)])]); @@ -523,11 +528,14 @@ export class ConfigService extends Disposable implements IConfigService { if (this.raw[domain] !== undefined) { try { - this.effective[domain] = this.registry.validate(domain, this.raw[domain]); + const validatedValue = this.registry.validate(domain, this.raw[domain]); + this.validated[domain] = validatedValue; + this.effective[domain] = validatedValue; } catch { return; } } else if (section.defaultValue !== undefined && this.effective[domain] === undefined) { + this.validated[domain] = section.defaultValue; this.effective[domain] = section.defaultValue; } else { return; diff --git a/packages/agent-core-v2/src/session/subagent/configSection.ts b/packages/agent-core-v2/src/session/subagent/configSection.ts index 42e5430ce4..af1b0ab376 100644 --- a/packages/agent-core-v2/src/session/subagent/configSection.ts +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -5,7 +5,7 @@ * Owns the `[subagent]` configuration section (`timeout_ms` on disk) together * with the `KIMI_SUBAGENT_TIMEOUT_MS` env override, mirroring v1's * `resolveSubagentTimeoutMs` precedence (env > config.toml > 2h default). While - * the env var is set, `stripEnvBoundFields` restores the raw on-disk value + * the env var is set, `stripEnvBoundFields` restores the env-free raw value * before persistence, so the override never leaks into `config.toml`. Both * collaboration tools — `Agent` in this domain and `AgentSwarm` in the `swarm` * domain — resolve their per-run timeout through `resolveSubagentTimeoutMs`, 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 9c56b34405..6f2b0ec967 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -713,6 +713,77 @@ describe('loopControl config section', () => { disposables.dispose(); }); + + it('recomputes env bindings from the env-free base when the env value degrades or is unset', async () => { + const env: Record = {}; + 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_steps_per_turn = 100\n'), + ); + 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; + + env[LOOP_MAX_STEPS_PER_TURN_ENV] = '7'; + expect(config.get(LOOP_CONTROL_SECTION).maxStepsPerTurn).toBe(7); + + // A degraded env value falls back to the file, not to the previous override. + env[LOOP_MAX_STEPS_PER_TURN_ENV] = 'abc'; + expect(config.get(LOOP_CONTROL_SECTION).maxStepsPerTurn).toBe(100); + + env[LOOP_MAX_STEPS_PER_TURN_ENV] = '9'; + expect(config.get(LOOP_CONTROL_SECTION).maxStepsPerTurn).toBe(9); + + // Unsetting falls back to the file as well, on both get() and getAll(). + delete env[LOOP_MAX_STEPS_PER_TURN_ENV]; + expect(config.get(LOOP_CONTROL_SECTION).maxStepsPerTurn).toBe(100); + + env[LOOP_MAX_STEPS_PER_TURN_ENV] = '7'; + expect(config.getAll()[LOOP_CONTROL_SECTION]).toEqual({ maxStepsPerTurn: 7 }); + delete env[LOOP_MAX_STEPS_PER_TURN_ENV]; + expect(config.getAll()[LOOP_CONTROL_SECTION]).toEqual({ maxStepsPerTurn: 100 }); + + disposables.dispose(); + }); + + it('restores the env-owned field from the normalized raw base when the config uses the legacy key', async () => { + const env: Record = { [LOOP_MAX_STEPS_PER_TURN_ENV]: '7' }; + 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_steps_per_run = 100\n'), + ); + 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; + + 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. + expect(config.inspect(LOOP_CONTROL_SECTION).userValue).toEqual({ + maxStepsPerTurn: 100, + }); + + disposables.dispose(); + }); }); describe('task config section', () => { From ade62707eda06f699c3780bbab86b43ab7a75663 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 21 Jul 2026 15:53:33 +0800 Subject: [PATCH 05/11] test(kap-server): retry temp-home cleanup in auth tests auth.test.ts removed its temp home with a plain recursive rm, which races late async writers in the server shutdown path and flakes with ENOTEMPTY (seen on main CI and locally on main). Harden the cleanup with the same maxRetries/retryDelay options sessions.test.ts already uses. --- packages/kap-server/test/auth.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kap-server/test/auth.test.ts b/packages/kap-server/test/auth.test.ts index 73bf30d6bb..86d0b8c7da 100644 --- a/packages/kap-server/test/auth.test.ts +++ b/packages/kap-server/test/auth.test.ts @@ -30,7 +30,7 @@ describe('server-v2 GET /api/v1/auth', () => { server = undefined; } if (home !== undefined) { - await rm(home, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as never); home = undefined; } }); From d790d1b88c8b08a158f4318940d5d32309911a80 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 21 Jul 2026 16:12:11 +0800 Subject: [PATCH 06/11] chore(changeset): consolidate the env-override changesets into two entries One feature entry (loop/background env overrides, both engines plus the CLI) and one fix entry (env values persisting on writes and sticking after degrade/unset, agent-core-v2 plus the CLI). --- .changeset/background-max-running-tasks-env.md | 7 ------- .changeset/config-env-overrides.md | 7 +++++++ .changeset/config-env-persist-and-stale-fixes.md | 6 ++++++ .changeset/fresh-env-reapply-on-read.md | 6 ------ .changeset/loop-control-env-overrides.md | 7 ------- .changeset/strip-env-overrides-on-persist.md | 6 ------ 6 files changed, 13 insertions(+), 26 deletions(-) delete mode 100644 .changeset/background-max-running-tasks-env.md create mode 100644 .changeset/config-env-overrides.md create mode 100644 .changeset/config-env-persist-and-stale-fixes.md delete mode 100644 .changeset/fresh-env-reapply-on-read.md delete mode 100644 .changeset/loop-control-env-overrides.md delete mode 100644 .changeset/strip-env-overrides-on-persist.md diff --git a/.changeset/background-max-running-tasks-env.md b/.changeset/background-max-running-tasks-env.md deleted file mode 100644 index 818ef1fa6c..0000000000 --- a/.changeset/background-max-running-tasks-env.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@moonshot-ai/agent-core": patch -"@moonshot-ai/agent-core-v2": patch -"@moonshot-ai/kimi-code": patch ---- - -Add an environment variable override for the background task concurrency cap. Set KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS to take priority over the [background] max_running_tasks config. diff --git a/.changeset/config-env-overrides.md b/.changeset/config-env-overrides.md new file mode 100644 index 0000000000..3d9bad14aa --- /dev/null +++ b/.changeset/config-env-overrides.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kimi-code": patch +--- + +Add environment variable overrides for agent loop and background task limits. Set KIMI_LOOP_MAX_STEPS_PER_TURN, KIMI_LOOP_MAX_RETRIES_PER_STEP, or KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS to take priority over the [loop_control] and [background] config. diff --git a/.changeset/config-env-persist-and-stale-fixes.md b/.changeset/config-env-persist-and-stale-fixes.md new file mode 100644 index 0000000000..e692ff91fe --- /dev/null +++ b/.changeset/config-env-persist-and-stale-fixes.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kimi-code": patch +--- + +Fix config environment overrides (such as KIMI_IMAGE_MAX_EDGE_PX or KIMI_SUBAGENT_TIMEOUT_MS) being persisted into config.toml by config API writes while the env var is set, and keeping the old value after the env var is changed to an invalid value or removed. diff --git a/.changeset/fresh-env-reapply-on-read.md b/.changeset/fresh-env-reapply-on-read.md deleted file mode 100644 index 7d894c908c..0000000000 --- a/.changeset/fresh-env-reapply-on-read.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/agent-core-v2": patch -"@moonshot-ai/kimi-code": patch ---- - -Fix config env overrides (such as KIMI_IMAGE_MAX_EDGE_PX or KIMI_LOOP_MAX_STEPS_PER_TURN) sticking after the env var is changed to an invalid value or removed: reads now recompute from the on-disk config, so the value falls back immediately instead of keeping the previous override until the next config reload. diff --git a/.changeset/loop-control-env-overrides.md b/.changeset/loop-control-env-overrides.md deleted file mode 100644 index f13eedcf17..0000000000 --- a/.changeset/loop-control-env-overrides.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@moonshot-ai/agent-core": patch -"@moonshot-ai/agent-core-v2": patch -"@moonshot-ai/kimi-code": patch ---- - -Add environment variable overrides for the agent loop limits. Set KIMI_LOOP_MAX_STEPS_PER_TURN or KIMI_LOOP_MAX_RETRIES_PER_STEP to take priority over the [loop_control] config. diff --git a/.changeset/strip-env-overrides-on-persist.md b/.changeset/strip-env-overrides-on-persist.md deleted file mode 100644 index 112b829fd3..0000000000 --- a/.changeset/strip-env-overrides-on-persist.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/agent-core-v2": patch -"@moonshot-ai/kimi-code": patch ---- - -Fix environment override values (such as KIMI_IMAGE_MAX_EDGE_PX, KIMI_SUBAGENT_TIMEOUT_MS, KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT) being persisted into config.toml when a config update is written through the server config API while the env var is set. From 9f46ac4d1601abef045e466dbc54df5990231fdd Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 21 Jul 2026 16:53:18 +0800 Subject: [PATCH 07/11] fix(config): clear fully-stripped sections and refresh overlay domains on get() Two follow-ups from review: - stripEnvBoundFields returned an empty object when every written field was env-owned, so a section with a non-empty default (e.g. subagent) stored raw = {} and the default stopped applying until the next reload. A fully stripped result now clears the raw section instead. - get(domain) only recomputed env overlays for sections with env bindings, so domains written solely by a ConfigEffectiveOverlay (models / defaultModel from KIMI_MODEL_NAME) kept serving stale cached values after the env changed. get() now derives every non-memory domain from the fresh env-free base, matching getAll(). --- .../agent-core-v2/src/app/config/config.ts | 3 +- .../src/app/config/configService.ts | 6 +-- .../test/app/config/config.test.ts | 51 +++++++++++++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/packages/agent-core-v2/src/app/config/config.ts b/packages/agent-core-v2/src/app/config/config.ts index b12cfbc9de..6ceabdf9e7 100644 --- a/packages/agent-core-v2/src/app/config/config.ts +++ b/packages/agent-core-v2/src/app/config/config.ts @@ -75,7 +75,8 @@ export function stripEnvBoundFields(bindings: EnvBindings): ConfigStripEnv delete out[field]; } } - return (out ?? value) as T; + if (out === undefined) return value; + return (Object.keys(out).length > 0 ? out : undefined) as T | undefined; }; } diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index e0f4129d78..4a60b20c1b 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -267,11 +267,7 @@ export class ConfigService extends Disposable implements IConfigService { if (Object.prototype.hasOwnProperty.call(this.memory, domain)) { return this.memory[domain] as T; } - const section = this.registry.getSection(domain); - if (section?.env !== undefined) { - return this.freshEffective()[domain] as T; - } - return this.effective[domain] as T; + return this.freshEffective()[domain] as T; } inspect(domain: string): ConfigInspectValue { 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 6f2b0ec967..865df69035 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -43,6 +43,7 @@ import { LOOP_MAX_STEPS_PER_TURN_ENV, type LoopControl, } from '#/agent/loop/configSection'; +import { ENV_MODEL_ALIAS_KEY } from '#/app/model/envOverlay'; import { THINKING_SECTION, type ThinkingConfig, @@ -1054,6 +1055,56 @@ describe('subagent config section', () => { disposables.dispose(); }); + + it('clears the raw section when stripping removes the last persisted field', async () => { + const env: Record = { [SUBAGENT_TIMEOUT_ENV]: '7000' }; + const { config, disposables } = await createConfig(env); + + // A client echoing the env-overlaid section back: nothing persistable + // remains, so the raw section is cleared instead of shadowing the default + // with an empty object. + await config.set(SUBAGENT_SECTION, { timeoutMs: 7000 }); + + expect(resolveSubagentTimeoutMs(config)).toBe(7000); + expect(config.inspect(SUBAGENT_SECTION).userValue).toBeUndefined(); + + delete env[SUBAGENT_TIMEOUT_ENV]; + expect(config.get(SUBAGENT_SECTION)).toEqual({ + timeoutMs: DEFAULT_SUBAGENT_TIMEOUT_MS, + }); + + disposables.dispose(); + }); +}); + +describe('get() freshness for overlay-written domains', () => { + it('recomputes overlay values on every get()', async () => { + const env: Record = {}; + const disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + ix.stub(ILogService, stubLog()); + ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg', env)); + ix.stub(IFileSystemStorageService, new InMemoryStorageService()); + ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); + ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.set(IConfigService, new SyncDescriptor(ConfigService)); + const config = ix.get(IConfigService); + await config.ready; + + env['KIMI_MODEL_NAME'] = 'env-model'; + env['KIMI_MODEL_API_KEY'] = 'sk-test'; + expect(config.get>('models')).toHaveProperty(ENV_MODEL_ALIAS_KEY); + expect(config.get('defaultModel')).toBe(ENV_MODEL_ALIAS_KEY); + + delete env['KIMI_MODEL_NAME']; + delete env['KIMI_MODEL_API_KEY']; + expect(config.get | undefined>('models') ?? {}).not.toHaveProperty( + ENV_MODEL_ALIAS_KEY, + ); + expect(config.get('defaultModel')).toBeUndefined(); + + disposables.dispose(); + }); }); function toolNames(value: unknown): string[] { From 142f9b309b8741cece93aeda744c164894e770d6 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 21 Jul 2026 17:32:11 +0800 Subject: [PATCH 08/11] test(config): drive the get() overlay freshness test with an inline overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous version imported the model env overlay to activate it, which the CI runners failed to resolve from this test file (both tsgo and vitest, while the same specifier resolves elsewhere — not reproducible locally). An inline ConfigEffectiveOverlay double exercises the same ConfigService contract with a tighter seam and no module dependency. --- .../test/app/config/config.test.ts | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) 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 865df69035..286535b40f 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -43,7 +43,6 @@ import { LOOP_MAX_STEPS_PER_TURN_ENV, type LoopControl, } from '#/agent/loop/configSection'; -import { ENV_MODEL_ALIAS_KEY } from '#/app/model/envOverlay'; import { THINKING_SECTION, type ThinkingConfig, @@ -1091,17 +1090,19 @@ describe('get() freshness for overlay-written domains', () => { const config = ix.get(IConfigService); await config.ready; - env['KIMI_MODEL_NAME'] = 'env-model'; - env['KIMI_MODEL_API_KEY'] = 'sk-test'; - expect(config.get>('models')).toHaveProperty(ENV_MODEL_ALIAS_KEY); - expect(config.get('defaultModel')).toBe(ENV_MODEL_ALIAS_KEY); + ix.get(IConfigRegistry).registerEffectiveOverlay({ + apply(effective, getEnv) { + if (getEnv('SMOKE_OVERLAY_FLAG') !== '1') return []; + effective['overlayDomain'] = { flag: true }; + return ['overlayDomain']; + }, + }); - delete env['KIMI_MODEL_NAME']; - delete env['KIMI_MODEL_API_KEY']; - expect(config.get | undefined>('models') ?? {}).not.toHaveProperty( - ENV_MODEL_ALIAS_KEY, - ); - expect(config.get('defaultModel')).toBeUndefined(); + expect(config.get('overlayDomain')).toBeUndefined(); + env['SMOKE_OVERLAY_FLAG'] = '1'; + expect(config.get('overlayDomain')).toEqual({ flag: true }); + delete env['SMOKE_OVERLAY_FLAG']; + expect(config.get('overlayDomain')).toBeUndefined(); disposables.dispose(); }); From a2754630083aba2868091f9d1a8817cc389fa0a9 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 21 Jul 2026 18:12:01 +0800 Subject: [PATCH 09/11] fix(config): preserve unknown fields on full strip and clone nested env targets Two follow-ups from review: - stripEnvBoundFields cleared the raw section whenever the stripped result was empty, so an env echo write could drop unknown forward-compatible fields from the TOML table. An emptied section now keeps its raw table while the env-free base still holds other fields, and is cleared only when nothing remains (defaults keep applying). - applyEnvBindings reused nested child objects in place, so a nested env binding on a persistable key would mutate the env-free validated base and serve stale values after the env var is removed. Children are now cloned before descending. --- .../agent-core-v2/src/app/config/config.ts | 8 ++- .../src/app/config/configService.ts | 16 ++--- .../test/app/config/config.test.ts | 67 +++++++++++++++++++ 3 files changed, 80 insertions(+), 11 deletions(-) diff --git a/packages/agent-core-v2/src/app/config/config.ts b/packages/agent-core-v2/src/app/config/config.ts index 6ceabdf9e7..dd153569a5 100644 --- a/packages/agent-core-v2/src/app/config/config.ts +++ b/packages/agent-core-v2/src/app/config/config.ts @@ -16,7 +16,10 @@ * 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. + * env value; otherwise writes pass through untouched. A section emptied this + * way keeps its raw table while the base still holds other (e.g. unknown + * forward-compatible) fields, and is cleared only when nothing remains, so + * registered defaults keep applying. */ import type { Event } from '#/_base/event'; @@ -76,7 +79,8 @@ export function stripEnvBoundFields(bindings: EnvBindings): ConfigStripEnv } } if (out === undefined) return value; - return (Object.keys(out).length > 0 ? out : undefined) as T | undefined; + if (Object.keys(out).length > 0) return out as T; + return (Object.keys(base).length > 0 ? {} : undefined) as T | undefined; }; } diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index 4a60b20c1b..6f8c017c9c 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -7,8 +7,9 @@ * target. Maintains five layered views of a domain — `rawSnake` (snake_case * write base keyed by the on-disk section key, kept for lossless round-trip), * `raw` (camelCase, env-free), `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` + * base every live env re-application starts from and never mutates, 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), and `memory` * (per-run overrides) * — plus a `delivered` snapshot per domain used as the diff base for @@ -94,13 +95,10 @@ function applyEnvBindings( const resolved = resolveBinding(binding, getEnv, target[key]); if (resolved !== undefined) target[key] = resolved; } else if (binding !== undefined) { - let child: Record; - if (isPlainObject(target[key])) { - child = target[key]; - } else { - child = {}; - target[key] = child; - } + const child: Record = isPlainObject(target[key]) + ? { ...target[key] } + : {}; + target[key] = child; applyEnvBindings(child, binding as AnyEnvBindings, getEnv); if (Object.keys(child).length === 0) { delete target[key]; 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 286535b40f..ede696ed04 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -784,6 +784,34 @@ describe('loopControl config section', () => { disposables.dispose(); }); + + it('preserves unknown on-disk fields when the stripped result is otherwise empty', async () => { + const env: Record = { [LOOP_MAX_STEPS_PER_TURN_ENV]: '7' }; + const disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + const storage = new InMemoryStorageService(); + await storage.write( + '', + 'config.toml', + new TextEncoder().encode('[loop_control]\nfuture_field = 1\n'), + ); + 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; + + await config.set(LOOP_CONTROL_SECTION, { maxStepsPerTurn: 7 }); + + const onDisk = new TextDecoder().decode(await storage.read('', 'config.toml')); + expect(onDisk).toContain('future_field = 1'); + expect(onDisk).not.toContain('max_steps_per_turn'); + + disposables.dispose(); + }); }); describe('task config section', () => { @@ -1108,6 +1136,45 @@ describe('get() freshness for overlay-written domains', () => { }); }); +describe('nested env bindings', () => { + it('does not mutate the env-free base when applying nested bindings', async () => { + const env: Record = {}; + const disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + const storage = new InMemoryStorageService(); + await storage.write( + '', + 'config.toml', + new TextEncoder().encode('[nested_demo.inner]\nvalue = "file"\n'), + ); + 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; + + const nestedSchema = { parse: (value: unknown) => value as { inner?: { value?: string } } }; + ix.get(IConfigRegistry).registerSection('nestedDemo', nestedSchema, { + env: { inner: { value: 'SMOKE_NESTED_ENV' } }, + }); + + env['SMOKE_NESTED_ENV'] = 'env-value'; + expect(config.get<{ inner?: { value?: string } }>('nestedDemo')).toEqual({ + inner: { value: 'env-value' }, + }); + + delete env['SMOKE_NESTED_ENV']; + expect(config.get<{ inner?: { value?: string } }>('nestedDemo')).toEqual({ + inner: { value: 'file' }, + }); + + disposables.dispose(); + }); +}); + function toolNames(value: unknown): string[] { if (!Array.isArray(value)) return []; return value From 8d63af4626690f85a6db3d1515dede152c68a6fb Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 21 Jul 2026 18:32:43 +0800 Subject: [PATCH 10/11] fix(config): keep the env-free base when a strip leaves nothing to persist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returning {} for a fully stripped write stored the empty object into the raw layer while the TOML table survived via rawSnake, so the bases diverged; a second echo write then saw an empty base, cleared the section, and deleted the table — dropping unknown forward-compatible fields. When nothing persistable remains, the write is now a no-op for the section (the env-free base is kept as-is), and the section is cleared only when the base is empty. --- packages/agent-core-v2/src/app/config/config.ts | 11 ++++++----- packages/agent-core-v2/test/app/config/config.test.ts | 6 +++++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/agent-core-v2/src/app/config/config.ts b/packages/agent-core-v2/src/app/config/config.ts index dd153569a5..6c51b63a76 100644 --- a/packages/agent-core-v2/src/app/config/config.ts +++ b/packages/agent-core-v2/src/app/config/config.ts @@ -16,10 +16,11 @@ * 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. A section emptied this - * way keeps its raw table while the base still holds other (e.g. unknown - * forward-compatible) fields, and is cleared only when nothing remains, so - * registered defaults keep applying. + * 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. */ import type { Event } from '#/_base/event'; @@ -80,7 +81,7 @@ export function stripEnvBoundFields(bindings: EnvBindings): ConfigStripEnv } if (out === undefined) return value; if (Object.keys(out).length > 0) return out as T; - return (Object.keys(base).length > 0 ? {} : undefined) as T | undefined; + return (Object.keys(base).length > 0 ? base : undefined) as T | undefined; }; } 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 ede696ed04..6450006e2f 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -785,7 +785,7 @@ describe('loopControl config section', () => { disposables.dispose(); }); - it('preserves unknown on-disk fields when the stripped result is otherwise empty', async () => { + it('preserves unknown on-disk fields across repeated stripped writes', async () => { const env: Record = { [LOOP_MAX_STEPS_PER_TURN_ENV]: '7' }; const disposables = new DisposableStore(); const ix = disposables.add(new TestInstantiationService()); @@ -804,11 +804,15 @@ describe('loopControl config section', () => { const config = ix.get(IConfigService); await config.ready; + await config.set(LOOP_CONTROL_SECTION, { maxStepsPerTurn: 7 }); await config.set(LOOP_CONTROL_SECTION, { maxStepsPerTurn: 7 }); const onDisk = new TextDecoder().decode(await storage.read('', 'config.toml')); expect(onDisk).toContain('future_field = 1'); expect(onDisk).not.toContain('max_steps_per_turn'); + expect(config.inspect(LOOP_CONTROL_SECTION).userValue).toEqual({ + futureField: 1, + }); disposables.dispose(); }); From dcbd760ec010708540b22fe3d32dd5c50525c06f Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 21 Jul 2026 19:38:21 +0800 Subject: [PATCH 11/11] fix(config): revalidate stripped results so restored raw values never persist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A strip may restore on-disk values from the unvalidated raw base (e.g. an env-masked invalid field), smuggling them past the merge-time validation into the stored config: the section would then fail the next buildValidated pass, dropping accompanying valid edits from the runtime while the invalid value stayed persisted. set() now revalidates the stripped result — discarding the parse output so unknown fields survive — and rejects the write instead, matching replace(). --- .../src/app/config/configService.ts | 5 ++- .../test/app/config/config.test.ts | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index 6f8c017c9c..b54bfd99cd 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -4,7 +4,9 @@ * Owns the section registry and the layered global config state: resolves a * value by precedence across defaults, the user config file, and per-run memory * overrides (highest, never persisted), and persists writes only for the `User` - * target. Maintains five layered views of a domain — `rawSnake` (snake_case + * target — validating the merged patch and re-validating the stripped result, + * so a strip can never smuggle an unvalidated raw value (e.g. an env-masked + * invalid field) to disk. Maintains five layered views of a domain — `rawSnake` (snake_case * write base keyed by the on-disk section key, kept for lossless round-trip), * `raw` (camelCase, env-free), `validated` (validated `raw`, env-free — the * base every live env re-application starts from and never mutates, so a @@ -318,6 +320,7 @@ export class ConfigService extends Disposable implements IConfigService { if (stripped === undefined) { delete this.raw[domain]; } else { + this.registry.validate(domain, stripped); this.raw[domain] = stripped; } await this.persist(domain); 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 301282055a..76c66ae566 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -816,6 +816,38 @@ describe('loopControl config section', () => { disposables.dispose(); }); + + it('rejects the write when the env-masked on-disk value is invalid', async () => { + const env: Record = { [LOOP_MAX_STEPS_PER_TURN_ENV]: '7' }; + 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_steps_per_turn = -1\n'), + ); + 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; + + await expect( + config.set(LOOP_CONTROL_SECTION, { maxStepsPerTurn: 7, reservedContextSize: 5000 }), + ).rejects.toThrow(); + + // Nothing is persisted: the invalid value stays quarantined on disk and + // the accompanying valid edit is not written either. + const onDisk = new TextDecoder().decode(await storage.read('', 'config.toml')); + expect(onDisk).toContain('max_steps_per_turn = -1'); + expect(onDisk).not.toContain('reserved_context_size'); + + disposables.dispose(); + }); }); describe('task config section', () => {